Compare commits
20 Commits
8092000880
...
72042598b0
| Author | SHA1 | Date | |
|---|---|---|---|
| 72042598b0 | |||
| e95e522d4d | |||
| 40cba6a567 | |||
| 821cf12ade | |||
| deafd46a0e | |||
| dbb6bc4a55 | |||
| e833b923c8 | |||
| 2b0413c43e | |||
| a51a5c5341 | |||
| 80aaf855a3 | |||
| e42612e75f | |||
| 7a0001d8f4 | |||
| 3598d38b8e | |||
| d5ecb54d6e | |||
| ac98dc496c | |||
| 49b471eff9 | |||
| 647ebcb673 | |||
| 4c38bc05d3 | |||
| 907607e30b | |||
| bdbfef7110 |
@@ -0,0 +1,19 @@
|
||||
FROM mcr.microsoft.com/playwright/python:v1.49.1-jammy
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends xvfb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY browser_url_safe.py browser_stealth.py config.py session_manager.py main.py docker-entrypoint.sh ./
|
||||
RUN sed -i 's/\r$//' docker-entrypoint.sh && chmod +x docker-entrypoint.sh
|
||||
|
||||
ENV WORKER_BIND=0.0.0.0:8443
|
||||
ENV DISPLAY=:99
|
||||
|
||||
EXPOSE 8443
|
||||
|
||||
ENTRYPOINT ["/bin/bash", "docker-entrypoint.sh"]
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Reduce automation fingerprints for sites with CAPTCHA (e.g. 阿里云)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
CHROMIUM_ARGS = [
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-infobars",
|
||||
"--window-position=0,0",
|
||||
"--lang=zh-CN",
|
||||
]
|
||||
|
||||
# Playwright adds --enable-automation by default; drop it for CAPTCHA sites.
|
||||
IGNORE_DEFAULT_ARGS = ["--enable-automation"]
|
||||
|
||||
STEALTH_INIT_SCRIPT = """
|
||||
(() => {
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined, configurable: true });
|
||||
Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en-US', 'en'] });
|
||||
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
|
||||
const originalQuery = window.navigator.permissions.query;
|
||||
window.navigator.permissions.query = (parameters) => (
|
||||
parameters.name === 'notifications'
|
||||
? Promise.resolve({ state: Notification.permission })
|
||||
: originalQuery(parameters)
|
||||
);
|
||||
window.chrome = window.chrome || { runtime: {} };
|
||||
})();
|
||||
"""
|
||||
|
||||
DEFAULT_USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
CONTEXT_OPTIONS = {
|
||||
"user_agent": DEFAULT_USER_AGENT,
|
||||
"locale": "zh-CN",
|
||||
"timezone_id": "Asia/Shanghai",
|
||||
"color_scheme": "light",
|
||||
"device_scale_factor": 1,
|
||||
"has_touch": False,
|
||||
"is_mobile": False,
|
||||
"java_script_enabled": True,
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"""SSRF-safe URL validation (Worker copy — keep in sync with server/utils/browser_url_safe.py)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
BLOCKED_HOSTNAMES = frozenset({
|
||||
"localhost",
|
||||
"localhost.localdomain",
|
||||
"metadata",
|
||||
"metadata.google.internal",
|
||||
"169.254.169.254",
|
||||
})
|
||||
|
||||
|
||||
def _blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast:
|
||||
return True
|
||||
if isinstance(ip, ipaddress.IPv4Address):
|
||||
if ip in ipaddress.ip_network("0.0.0.0/8"):
|
||||
return True
|
||||
if ip in ipaddress.ip_network("100.64.0.0/10"):
|
||||
return True
|
||||
if isinstance(ip, ipaddress.IPv6Address):
|
||||
if ip == ipaddress.IPv6Address("::1"):
|
||||
return True
|
||||
if ip in ipaddress.ip_network("fc00::/7"):
|
||||
return True
|
||||
if ip in ipaddress.ip_network("fe80::/10"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def resolve_host_blocked(hostname: str) -> str | None:
|
||||
host = hostname.lower().strip(".")
|
||||
if not host:
|
||||
return "Empty hostname"
|
||||
if host in BLOCKED_HOSTNAMES or "metadata" in host:
|
||||
return f"Blocked hostname: {hostname}"
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
|
||||
except socket.gaierror as exc:
|
||||
return f"DNS resolution failed: {exc}"
|
||||
if not infos:
|
||||
return f"No addresses for hostname: {hostname}"
|
||||
for info in infos:
|
||||
ip_str = info[4][0]
|
||||
try:
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
except ValueError:
|
||||
return f"Invalid IP: {ip_str}"
|
||||
if _blocked_ip(ip):
|
||||
return f"Blocked IP: {ip_str}"
|
||||
return None
|
||||
|
||||
|
||||
def validate_navigation_url(url: str) -> tuple[str | None, str | None]:
|
||||
trimmed = url.strip()
|
||||
if not trimmed:
|
||||
return None, "Empty URL"
|
||||
try:
|
||||
with_proto = trimmed if "://" in trimmed else f"https://{trimmed}"
|
||||
parsed = urlparse(with_proto)
|
||||
except Exception:
|
||||
return None, "Invalid URL"
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return None, f"Unsupported scheme: {parsed.scheme}"
|
||||
if parsed.username or parsed.password:
|
||||
return None, "Credentials in URL are not allowed"
|
||||
if not parsed.hostname:
|
||||
return None, "Missing hostname"
|
||||
host_err = resolve_host_blocked(parsed.hostname)
|
||||
if host_err:
|
||||
return None, host_err
|
||||
return parsed.geturl(), None
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Browser Worker configuration."""
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class WorkerSettings(BaseSettings):
|
||||
WORKER_SECRET: str = ""
|
||||
WORKER_BIND: str = "0.0.0.0:8443"
|
||||
WORKER_MAX_SESSIONS: int = 2
|
||||
WORKER_IDLE_TIMEOUT_SEC: int = 1800
|
||||
# POST /v1/sessions without WS attach — reclaim zombie reservations
|
||||
WORKER_RESERVE_TIMEOUT_SEC: int = 120
|
||||
# false + Xvfb — better for 阿里云等验证码;true 省资源
|
||||
WORKER_HEADLESS: str = "false"
|
||||
WORKER_SCREENCAST_QUALITY: int = 90
|
||||
WORKER_DEFAULT_VIEWPORT_W: int = 1280
|
||||
WORKER_DEFAULT_VIEWPORT_H: int = 720
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
|
||||
settings = WorkerSettings()
|
||||
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
browser-worker:
|
||||
build: .
|
||||
container_name: nexus-browser-worker
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "8443:8443"
|
||||
shm_size: "1gb"
|
||||
ipc: host
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# Headed Chromium needs a virtual display (CAPTCHA / anti-bot).
|
||||
if [[ "${WORKER_HEADLESS:-false}" != "true" && "${WORKER_HEADLESS:-false}" != "1" ]]; then
|
||||
export DISPLAY="${DISPLAY:-:99}"
|
||||
if ! pgrep -x Xvfb >/dev/null 2>&1; then
|
||||
Xvfb "${DISPLAY}" -screen 0 1920x1080x24 -ac +extension GLX +render -noreset &
|
||||
sleep 1
|
||||
fi
|
||||
fi
|
||||
exec uvicorn main:app --host 0.0.0.0 --port 8443 --log-level info
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Browser Worker — Playwright Chromium + WebSocket stream."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Header, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from config import settings
|
||||
from session_manager import manager
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("browser_worker")
|
||||
|
||||
|
||||
def _check_key(header: str | None) -> None:
|
||||
if not settings.WORKER_SECRET:
|
||||
raise HTTPException(status_code=503, detail="Worker secret not configured")
|
||||
if not header or header != settings.WORKER_SECRET:
|
||||
raise HTTPException(status_code=401, detail="Invalid worker key")
|
||||
|
||||
|
||||
class CreateSessionBody(BaseModel):
|
||||
session_id: str = Field(min_length=8, max_length=64)
|
||||
viewport_w: int = Field(default=1280, ge=320, le=1920)
|
||||
viewport_h: int = Field(default=720, ge=240, le=1080)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
await manager.start()
|
||||
logger.info("Playwright Chromium ready")
|
||||
yield
|
||||
await manager.stop()
|
||||
|
||||
|
||||
app = FastAPI(title="Nexus Browser Worker", lifespan=lifespan)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {
|
||||
"status": "ok",
|
||||
"sessions": manager.session_count(),
|
||||
"max_sessions": settings.WORKER_MAX_SESSIONS,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/v1/sessions")
|
||||
async def create_session(
|
||||
body: CreateSessionBody,
|
||||
x_nexus_worker_key: str | None = Header(default=None, alias="X-Nexus-Worker-Key"),
|
||||
):
|
||||
_check_key(x_nexus_worker_key)
|
||||
try:
|
||||
await manager.reserve_session(body.session_id, body.viewport_w, body.viewport_h)
|
||||
except ValueError as exc:
|
||||
code = str(exc)
|
||||
if code == "max_sessions":
|
||||
raise HTTPException(status_code=503, detail="Max sessions reached") from exc
|
||||
if code == "session_exists":
|
||||
raise HTTPException(status_code=409, detail="Session already exists") from exc
|
||||
raise HTTPException(status_code=400, detail=code) from exc
|
||||
return {"session_id": body.session_id, "viewport_w": body.viewport_w, "viewport_h": body.viewport_h}
|
||||
|
||||
|
||||
@app.delete("/v1/sessions/{session_id}")
|
||||
async def delete_session(
|
||||
session_id: str,
|
||||
x_nexus_worker_key: str | None = Header(default=None, alias="X-Nexus-Worker-Key"),
|
||||
):
|
||||
_check_key(x_nexus_worker_key)
|
||||
if not await manager.destroy_session(session_id):
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.websocket("/v1/sessions/{session_id}/stream")
|
||||
async def session_stream(
|
||||
websocket: WebSocket,
|
||||
session_id: str,
|
||||
x_nexus_worker_key: str | None = Header(default=None, alias="X-Nexus-Worker-Key"),
|
||||
):
|
||||
key = x_nexus_worker_key or websocket.headers.get("x-nexus-worker-key")
|
||||
if not settings.WORKER_SECRET or key != settings.WORKER_SECRET:
|
||||
await websocket.close(code=4401, reason="Invalid worker key")
|
||||
return
|
||||
|
||||
if not manager.get_session(session_id):
|
||||
await websocket.close(code=4404, reason="Session not found")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
outbound: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
||||
|
||||
async def send(msg: dict[str, Any]) -> None:
|
||||
await outbound.put(msg)
|
||||
|
||||
try:
|
||||
await manager.attach_stream(session_id, send)
|
||||
except ValueError:
|
||||
await websocket.close(code=4404, reason="Session not found")
|
||||
return
|
||||
except RuntimeError:
|
||||
await manager.destroy_session(session_id)
|
||||
await websocket.close(code=4503, reason="Browser not ready")
|
||||
return
|
||||
|
||||
async def pump_out() -> None:
|
||||
while True:
|
||||
msg = await outbound.get()
|
||||
await websocket.send_json(msg)
|
||||
|
||||
pump_task = asyncio.create_task(pump_out(), name=f"ws_out_{session_id}")
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
await send({"type": "ERROR", "message": "Invalid JSON", "code": "BAD_JSON"})
|
||||
continue
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
await manager.handle_message(session_id, msg)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
pump_task.cancel()
|
||||
try:
|
||||
await pump_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await manager.destroy_session(session_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
host, _, port = settings.WORKER_BIND.partition(":")
|
||||
uvicorn.run("main:app", host=host or "0.0.0.0", port=int(port or 8443), log_level="info")
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
playwright==1.49.1
|
||||
pydantic-settings==2.7.1
|
||||
@@ -0,0 +1,346 @@
|
||||
"""Playwright Chromium session manager with CDP screencast."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Awaitable
|
||||
|
||||
from playwright.async_api import Browser, BrowserContext, Page, async_playwright
|
||||
|
||||
from browser_stealth import (
|
||||
CHROMIUM_ARGS,
|
||||
CONTEXT_OPTIONS,
|
||||
IGNORE_DEFAULT_ARGS,
|
||||
STEALTH_INIT_SCRIPT,
|
||||
)
|
||||
from browser_url_safe import validate_navigation_url
|
||||
from config import settings
|
||||
|
||||
|
||||
def _headless() -> bool:
|
||||
return settings.WORKER_HEADLESS.lower() in ("1", "true", "yes")
|
||||
|
||||
logger = logging.getLogger("browser_worker.session")
|
||||
|
||||
SendFn = Callable[[dict[str, Any]], Awaitable[None]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserSession:
|
||||
session_id: str
|
||||
viewport_w: int
|
||||
viewport_h: int
|
||||
send: SendFn
|
||||
ready: bool = False
|
||||
context: BrowserContext | None = None
|
||||
page: Page | None = None
|
||||
cdp: Any = None
|
||||
last_activity: float = field(default_factory=time.monotonic)
|
||||
reserved_at: float = field(default_factory=time.monotonic)
|
||||
_screencast_task: asyncio.Task | None = None
|
||||
_title_task: asyncio.Task | None = None
|
||||
|
||||
async def touch(self) -> None:
|
||||
self.last_activity = time.monotonic()
|
||||
|
||||
async def close(self) -> None:
|
||||
for task in (self._screencast_task, self._title_task):
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._screencast_task = None
|
||||
self._title_task = None
|
||||
if self.cdp:
|
||||
try:
|
||||
await self.cdp.send("Page.stopScreencast")
|
||||
except Exception:
|
||||
pass
|
||||
self.cdp = None
|
||||
if self.context:
|
||||
try:
|
||||
await self.context.close()
|
||||
except Exception as exc:
|
||||
logger.warning("context close failed session=%s: %s", self.session_id, exc)
|
||||
self.context = None
|
||||
self.page = None
|
||||
self.ready = False
|
||||
|
||||
|
||||
class SessionManager:
|
||||
def __init__(self) -> None:
|
||||
self._browser: Browser | None = None
|
||||
self._playwright = None
|
||||
self._sessions: dict[str, BrowserSession] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._idle_task: asyncio.Task | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
self._playwright = await async_playwright().start()
|
||||
self._browser = await self._playwright.chromium.launch(
|
||||
headless=_headless(),
|
||||
args=CHROMIUM_ARGS,
|
||||
ignore_default_args=IGNORE_DEFAULT_ARGS,
|
||||
)
|
||||
self._idle_task = asyncio.create_task(self._idle_loop(), name="browser_idle")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._idle_task:
|
||||
self._idle_task.cancel()
|
||||
try:
|
||||
await self._idle_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
for sid in list(self._sessions):
|
||||
await self.destroy_session(sid)
|
||||
if self._browser:
|
||||
await self._browser.close()
|
||||
self._browser = None
|
||||
if self._playwright:
|
||||
await self._playwright.stop()
|
||||
self._playwright = None
|
||||
|
||||
async def _idle_loop(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(60)
|
||||
now = time.monotonic()
|
||||
stale: list[str] = []
|
||||
for sid, sess in self._sessions.items():
|
||||
if sess.ready:
|
||||
if now - sess.last_activity > settings.WORKER_IDLE_TIMEOUT_SEC:
|
||||
stale.append(sid)
|
||||
elif now - sess.reserved_at > settings.WORKER_RESERVE_TIMEOUT_SEC:
|
||||
stale.append(sid)
|
||||
for sid in stale:
|
||||
logger.info("idle timeout session=%s", sid)
|
||||
await self.destroy_session(sid)
|
||||
|
||||
def session_count(self) -> int:
|
||||
return len(self._sessions)
|
||||
|
||||
async def reserve_session(
|
||||
self,
|
||||
session_id: str,
|
||||
viewport_w: int,
|
||||
viewport_h: int,
|
||||
) -> None:
|
||||
async with self._lock:
|
||||
if session_id in self._sessions:
|
||||
raise ValueError("session_exists")
|
||||
if len(self._sessions) >= settings.WORKER_MAX_SESSIONS:
|
||||
raise ValueError("max_sessions")
|
||||
self._sessions[session_id] = BrowserSession(
|
||||
session_id=session_id,
|
||||
viewport_w=viewport_w,
|
||||
viewport_h=viewport_h,
|
||||
send=self._noop_send,
|
||||
)
|
||||
|
||||
async def attach_stream(self, session_id: str, send: SendFn) -> BrowserSession:
|
||||
async with self._lock:
|
||||
session = self._sessions.get(session_id)
|
||||
if not session:
|
||||
raise ValueError("session_not_found")
|
||||
if session.ready:
|
||||
session.send = send
|
||||
return session
|
||||
if not self._browser:
|
||||
raise RuntimeError("browser_not_ready")
|
||||
session.send = send
|
||||
|
||||
context = await self._browser.new_context(
|
||||
viewport={"width": session.viewport_w, "height": session.viewport_h},
|
||||
ignore_https_errors=False,
|
||||
**CONTEXT_OPTIONS,
|
||||
)
|
||||
await context.add_init_script(STEALTH_INIT_SCRIPT)
|
||||
page = await context.new_page()
|
||||
session.context = context
|
||||
session.page = page
|
||||
cdp = await context.new_cdp_session(page)
|
||||
session.cdp = cdp
|
||||
await cdp.send("Page.startScreencast", {
|
||||
"format": "jpeg",
|
||||
"quality": max(60, min(settings.WORKER_SCREENCAST_QUALITY, 100)),
|
||||
"everyNthFrame": 1,
|
||||
})
|
||||
session._screencast_task = asyncio.create_task(
|
||||
self._screencast_loop(session, cdp),
|
||||
name=f"screencast_{session_id}",
|
||||
)
|
||||
session._title_task = asyncio.create_task(
|
||||
self._title_loop(session),
|
||||
name=f"title_{session_id}",
|
||||
)
|
||||
session.ready = True
|
||||
await session.send({
|
||||
"type": "BROWSER_INIT",
|
||||
"session_id": session_id,
|
||||
"viewport_w": session.viewport_w,
|
||||
"viewport_h": session.viewport_h,
|
||||
})
|
||||
return session
|
||||
|
||||
async def _noop_send(self, _msg: dict[str, Any]) -> None:
|
||||
return
|
||||
|
||||
async def destroy_session(self, session_id: str) -> bool:
|
||||
async with self._lock:
|
||||
session = self._sessions.pop(session_id, None)
|
||||
if not session:
|
||||
return False
|
||||
await session.close()
|
||||
return True
|
||||
|
||||
def get_session(self, session_id: str) -> BrowserSession | None:
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
async def _screencast_loop(self, session: BrowserSession, cdp: Any) -> None:
|
||||
"""CDPSession has no wait_for_event — use on() + asyncio.Queue."""
|
||||
frame_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
||||
|
||||
def _on_screencast_frame(params: dict[str, Any]) -> None:
|
||||
try:
|
||||
frame_queue.put_nowait(params)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
cdp.on("Page.screencastFrame", _on_screencast_frame)
|
||||
try:
|
||||
while session.session_id in self._sessions and session.ready:
|
||||
try:
|
||||
msg = await asyncio.wait_for(frame_queue.get(), timeout=30.0)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
await session.touch()
|
||||
await session.send({
|
||||
"type": "SCREENCAST_FRAME",
|
||||
"data_b64": msg.get("data", ""),
|
||||
"metadata": msg.get("metadata") or {},
|
||||
})
|
||||
await cdp.send("Page.screencastFrameAck", {"sessionId": msg["sessionId"]})
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("screencast ended session=%s: %s", session.session_id, exc)
|
||||
|
||||
async def _title_loop(self, session: BrowserSession) -> None:
|
||||
page = session.page
|
||||
if not page:
|
||||
return
|
||||
last_url = ""
|
||||
last_title = ""
|
||||
try:
|
||||
while session.session_id in self._sessions and session.ready:
|
||||
await asyncio.sleep(1)
|
||||
await session.touch()
|
||||
try:
|
||||
url = page.url
|
||||
title = await page.title()
|
||||
except Exception:
|
||||
continue
|
||||
if url != last_url or title != last_title:
|
||||
last_url = url
|
||||
last_title = title
|
||||
await session.send({
|
||||
"type": "PAGE_INFO",
|
||||
"url": url,
|
||||
"title": title,
|
||||
})
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
async def handle_message(self, session_id: str, msg: dict[str, Any]) -> None:
|
||||
session = self.get_session(session_id)
|
||||
if not session or not session.page or not session.ready:
|
||||
return
|
||||
await session.touch()
|
||||
page = session.page
|
||||
mtype = msg.get("type")
|
||||
|
||||
if mtype == "PING":
|
||||
await session.send({"type": "PONG"})
|
||||
return
|
||||
|
||||
if mtype == "NAVIGATE":
|
||||
url = msg.get("url", "")
|
||||
normalized, err = validate_navigation_url(str(url))
|
||||
if err:
|
||||
await session.send({"type": "ERROR", "message": err, "code": "URL_BLOCKED"})
|
||||
return
|
||||
try:
|
||||
await page.goto(normalized, wait_until="domcontentloaded", timeout=30_000)
|
||||
await session.send({
|
||||
"type": "PAGE_INFO",
|
||||
"url": page.url,
|
||||
"title": await page.title(),
|
||||
})
|
||||
except Exception as exc:
|
||||
await session.send({
|
||||
"type": "ERROR",
|
||||
"message": str(exc)[:500],
|
||||
"code": "NAVIGATE_FAILED",
|
||||
})
|
||||
return
|
||||
|
||||
if mtype == "RELOAD":
|
||||
await page.reload(wait_until="domcontentloaded", timeout=30_000)
|
||||
return
|
||||
if mtype == "GO_BACK":
|
||||
await page.go_back(wait_until="domcontentloaded", timeout=30_000)
|
||||
return
|
||||
if mtype == "GO_FORWARD":
|
||||
await page.go_forward(wait_until="domcontentloaded", timeout=30_000)
|
||||
return
|
||||
|
||||
if mtype == "VIEWPORT":
|
||||
w = int(msg.get("width") or session.viewport_w)
|
||||
h = int(msg.get("height") or session.viewport_h)
|
||||
w = max(320, min(w, 1920))
|
||||
h = max(240, min(h, 1080))
|
||||
session.viewport_w = w
|
||||
session.viewport_h = h
|
||||
await page.set_viewport_size({"width": w, "height": h})
|
||||
return
|
||||
|
||||
if mtype == "MOUSE":
|
||||
event = msg.get("event")
|
||||
x = float(msg.get("x", 0))
|
||||
y = float(msg.get("y", 0))
|
||||
button = msg.get("button", "left")
|
||||
steps = int(msg.get("steps") or 1)
|
||||
steps = max(1, min(steps, 25))
|
||||
if event == "move":
|
||||
await page.mouse.move(x, y, steps=steps)
|
||||
elif event == "down":
|
||||
await page.mouse.move(x, y)
|
||||
await page.mouse.down(button=button)
|
||||
elif event == "up":
|
||||
await page.mouse.move(x, y)
|
||||
await page.mouse.up(button=button)
|
||||
elif event == "click":
|
||||
await page.mouse.click(x, y, button=button, delay=50)
|
||||
elif event == "wheel":
|
||||
delta_y = float(msg.get("delta_y", 0))
|
||||
await page.mouse.wheel(0, delta_y)
|
||||
return
|
||||
|
||||
if mtype == "KEY":
|
||||
event = msg.get("event")
|
||||
key = msg.get("key", "")
|
||||
text = msg.get("text", "")
|
||||
if event == "down" and key:
|
||||
await page.keyboard.down(key)
|
||||
elif event == "up" and key:
|
||||
await page.keyboard.up(key)
|
||||
elif event == "type" and text:
|
||||
await page.keyboard.type(text)
|
||||
return
|
||||
|
||||
|
||||
manager = SessionManager()
|
||||
@@ -1657,3 +1657,433 @@
|
||||
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-servers-search-manual-trigger.md 28lines"}
|
||||
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-servers-search-manual-trigger.md"}
|
||||
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-files-chmod-not-effective-fix.md 32lines"}
|
||||
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-files-chmod-not-effective-fix.md"}
|
||||
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-files-upload-www-permissions.md 39lines"}
|
||||
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-files-chmod-not-effective-fix.md"}
|
||||
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-files-upload-www-permissions.md 39lines"}
|
||||
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-files-upload-www-permissions.md"}
|
||||
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-embedded-browser.md 38lines"}
|
||||
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-embedded-browser.md"}
|
||||
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-global-floating-browser.md 41lines"}
|
||||
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"audit","result":"BLOCK","detail":"missing sections"}
|
||||
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"summary","result":"BLOCK","detail":"5/7"}
|
||||
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-global-floating-browser.md 41lines"}
|
||||
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-global-floating-browser.md"}
|
||||
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-global-floating-browser.md 47lines"}
|
||||
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-global-floating-browser.md"}
|
||||
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-global-floating-browser.md 48lines"}
|
||||
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-global-floating-browser.md"}
|
||||
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-global-floating-browser.md 48lines"}
|
||||
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-global-floating-browser.md"}
|
||||
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-rsync-push-sudo-elevation.md 43lines"}
|
||||
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-rsync-push-sudo-elevation.md"}
|
||||
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-push-default-wwwroot.md 41lines"}
|
||||
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-rsync-push-sudo-elevation.md"}
|
||||
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"shell_eol","result":"BLOCK","detail":"CR in tracked sh"}
|
||||
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-push-default-wwwroot.md 41lines"}
|
||||
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-push-default-wwwroot.md"}
|
||||
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"changelog","result":"BLOCK","detail":"file not found"}
|
||||
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"audit","result":"BLOCK","detail":"file not found"}
|
||||
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"no audit file"}
|
||||
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"4/7"}
|
||||
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-onboarding-automation.md 25lines"}
|
||||
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-server-onboarding-automation.md"}
|
||||
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-onboarding-automation.md 25lines"}
|
||||
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-server-onboarding-automation.md"}
|
||||
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-onboarding-automation.md 25lines"}
|
||||
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-server-onboarding-automation.md"}
|
||||
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-onboarding-automation.md 25lines"}
|
||||
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-remove-browser-push-ui.md"}
|
||||
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-terminal-server-rail-search-history.md 34lines"}
|
||||
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-terminal-server-rail-search-history.md"}
|
||||
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-push-remove-name-copy.md 29lines"}
|
||||
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-push-remove-name-copy.md"}
|
||||
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-terminal-search-center-empty-state.md 31lines"}
|
||||
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-push-terminal-ui.md"}
|
||||
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-search-history-isolated-scopes.md 35lines"}
|
||||
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-search-history-isolated-scopes.md"}
|
||||
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-watch-slots.md 54lines"}
|
||||
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-server-watch-slots.md"}
|
||||
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-watch-slots.md 54lines"}
|
||||
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-server-watch-slots.md"}
|
||||
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-watch-slots.md 54lines"}
|
||||
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-server-watch-slots.md"}
|
||||
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-watch-slots.md 54lines"}
|
||||
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-server-watch-slots.md"}
|
||||
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"test","result":"BLOCK","detail":"tests failed"}
|
||||
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"5/7"}
|
||||
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-watch-slots.md 54lines"}
|
||||
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-files-watch-ui-fixes.md"}
|
||||
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-watch-slots.md 54lines"}
|
||||
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-files-watch-ui-fixes.md"}
|
||||
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-agent-action-hint.md 33lines"}
|
||||
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-files-watch-ui-fixes.md"}
|
||||
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"test","result":"BLOCK","detail":"tests failed"}
|
||||
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"5/7"}
|
||||
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-agent-action-hint.md 33lines"}
|
||||
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-files-watch-ui-fixes.md"}
|
||||
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-agent-action-hint.md 33lines"}
|
||||
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-files-watch-ui-fixes.md"}
|
||||
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-watch-ttl-expiry-race-fix.md 35lines"}
|
||||
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-watch-slot-monitoring-deploy.md"}
|
||||
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-watch-install-psutil-ssh.md 40lines"}
|
||||
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-watch-install-psutil-ssh.md"}
|
||||
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-watch-install-psutil-ssh.md 40lines"}
|
||||
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-watch-install-psutil-ssh.md"}
|
||||
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-psutil-install-sudo-fix.md 19lines"}
|
||||
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-psutil-install-sudo-fix.md"}
|
||||
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-audit-log-server-target-name.md 18lines"}
|
||||
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-audit-log-server-target-name.md"}
|
||||
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-watch-slot-hover-border.md 15lines"}
|
||||
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-audit-log-server-target-name.md"}
|
||||
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-watch-slot-hover-border.md 15lines"}
|
||||
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-watch-slot-hover-border.md"}
|
||||
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-watch-slot-ttl-metrics-ui.md 43lines"}
|
||||
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-watch-slot-hover-border.md"}
|
||||
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-browser-top-appbar.md 19lines"}
|
||||
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-audit-log-server-target-name.md"}
|
||||
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"test","result":"BLOCK","detail":"tests failed"}
|
||||
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"security","result":"BLOCK","detail":"3 HIGH findings"}
|
||||
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"shell_eol","result":"BLOCK","detail":"CR in tracked sh"}
|
||||
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"4/7"}
|
||||
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-browser-top-appbar.md 19lines"}
|
||||
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-remote-browser-worker.md"}
|
||||
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"changelog","result":"PASS","detail":"2026-06-13-offline-alert-telegram-split.md 37lines"}
|
||||
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"audit","result":"BLOCK","detail":"file not found"}
|
||||
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"review","result":"BLOCK","detail":"no audit file"}
|
||||
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"summary","result":"BLOCK","detail":"5/7"}
|
||||
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"changelog","result":"PASS","detail":"2026-06-13-offline-alert-telegram-split.md 37lines"}
|
||||
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"audit","result":"PASS","detail":"2026-06-13-offline-quick-cmd-telegram.md"}
|
||||
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# 审计 — 文件管理「新建文件」修复
|
||||
|
||||
**Changelog**: `docs/changelog/2026-06-10-files-new-file-fix.md`
|
||||
|
||||
## 变更文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `frontend/src/composables/files/useFilesActions.ts` | `openNewFileDialog`、等待编辑器就绪 |
|
||||
| `frontend/src/composables/files/useFilesEditor.ts` | 导出 `waitForEditorWorkbench` |
|
||||
| `frontend/src/composables/files/useFilesPage.ts` | 透传 helper |
|
||||
| `frontend/src/components/files/FilesToolbar.vue` | 未选服务器时提示 |
|
||||
| `server/infrastructure/ssh/asyncssh_pool.py` | SFTP 写前确保父目录 |
|
||||
|
||||
## Step 3(规则扫描)
|
||||
|
||||
| 规则 | 结论 | 说明 |
|
||||
|------|------|------|
|
||||
| 鉴权 | PASS | 仍走 `/sync/write-file` + admin JWT |
|
||||
| 路径 | PASS | `validatePathSegment` 不变 |
|
||||
| 静默失败 | PASS | 编辑器未就绪有 warning snackbar |
|
||||
| SSH | PASS | `mkdir -p` 仅父目录,不扩大写范围 |
|
||||
|
||||
## Closure
|
||||
|
||||
| 文件 | 结论 |
|
||||
|------|------|
|
||||
| `useFilesActions.ts` | SAFE — 无 v-html;错误经 snackbar |
|
||||
| `asyncssh_pool.py` | SAFE — 复用已有 `_ensure_remote_parent_dir` |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] changelog + 本审计
|
||||
- [x] frontend type-check
|
||||
- [ ] 生产:文件管理新建文件 → 编辑器打开
|
||||
@@ -0,0 +1,20 @@
|
||||
# 审计 — 审计日志目标列显示服务器名称
|
||||
|
||||
**日期**:2026-06-12
|
||||
**Changelog**: `docs/changelog/2026-06-12-audit-log-server-target-name.md`
|
||||
|
||||
## 范围
|
||||
|
||||
| 文件 |
|
||||
|------|
|
||||
| `server/api/settings.py` |
|
||||
| `frontend/src/pages/AuditPage.vue` |
|
||||
| `frontend/src/utils/auditLabels.ts` |
|
||||
| `frontend/src/utils/auditNormalize.ts` |
|
||||
| `frontend/src/types/api.ts` |
|
||||
| `tests/integration/test_alerts_audit.py` |
|
||||
|
||||
## Step 3 / Closure / DoD
|
||||
|
||||
- PASS — 只读增强,批量 IN 查 name
|
||||
- DoD: integration test passed
|
||||
@@ -0,0 +1,142 @@
|
||||
# 审计 — 远程浏览器(Playwright Worker)
|
||||
|
||||
**Changelog**: `docs/changelog/2026-06-12-remote-browser-worker-deploy.md`
|
||||
**设计 SSOT**: `docs/design/specs/2026-06-11-server-browser-chromium-design.md`
|
||||
**范围**: 2026-06-12 新增/恢复的浏览器 Worker 全链路(未含 `web/app` 构建产物)
|
||||
|
||||
## 范围(文件清单)
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `browser-worker/Dockerfile` | Worker 镜像 |
|
||||
| `browser-worker/docker-compose.yml` | Worker 编排 |
|
||||
| `browser-worker/docker-entrypoint.sh` | headed + Xvfb 入口 |
|
||||
| `browser-worker/requirements.txt` | Worker 依赖 |
|
||||
| `browser-worker/config.py` | Worker 环境配置 |
|
||||
| `browser-worker/main.py` | Worker HTTP/WS API |
|
||||
| `browser-worker/session_manager.py` | Playwright + screencast |
|
||||
| `browser-worker/browser_stealth.py` | 反自动化 / 验证码辅助 |
|
||||
| `browser-worker/browser_url_safe.py` | SSRF(Worker 侧) |
|
||||
| `server/main.py` | 注册 browser 路由 |
|
||||
| `server/config.py` | `BROWSER_*` 与 TLS verify |
|
||||
| `server/api/settings.py` | 设置项默认值(push_complete_sound) |
|
||||
| `server/api/browser_session.py` | 桥接 REST + `/ws/browser` |
|
||||
| `server/api/browser.py` | UI 状态 `/api/browser/state` |
|
||||
| `server/infrastructure/browser/__init__.py` | 包入口 |
|
||||
| `server/infrastructure/browser/worker_client.py` | 连 Worker(可配置 TLS verify) |
|
||||
| `server/infrastructure/browser/session_registry.py` | 内存会话归属 |
|
||||
| `server/utils/browser_url_safe.py` | SSRF(桥接层) |
|
||||
| `server/utils/browser_ui_state.py` | 状态解析 |
|
||||
| `frontend/src/App.vue` | 顶栏 + 固定 GlobalBrowserPanel |
|
||||
| `frontend/src/router/index.ts` | `/browser` 路由 |
|
||||
| `frontend/src/pages/ServersPage.vue` | 服务器页打开浏览器 |
|
||||
| `frontend/src/pages/BrowserPage.vue` | 独立浏览器页(兼容) |
|
||||
| `frontend/src/components/GlobalBrowserPanel.vue` | 固定顶栏下整窗 |
|
||||
| `frontend/src/composables/useGlobalBrowser.ts` | 全局标签/状态 |
|
||||
| `frontend/src/composables/useRemoteBrowserSession.ts` | canvas + WS |
|
||||
| `tests/test_browser_url_safe.py` | SSRF 单测 |
|
||||
| `tests/test_browser_ui_state.py` | UI 状态单测 |
|
||||
| `tests/test_settings_sound_defaults.py` | 设置默认音单测 |
|
||||
|
||||
## API / WS 入口表
|
||||
|
||||
| 方法 | 路径 | 鉴权 |
|
||||
|------|------|------|
|
||||
| GET | `/api/browser/status` | JWT `get_current_admin` |
|
||||
| POST | `/api/browser/sessions` | JWT |
|
||||
| DELETE | `/api/browser/sessions/{id}` | JWT + 会话归属 |
|
||||
| GET/PUT | `/api/browser/state` | JWT + `admin_ui_preferences` |
|
||||
| WS | `/ws/browser/{session_id}?token=` | JWT + 会话归属 |
|
||||
| GET | Worker `/health` | **无** |
|
||||
| POST/DELETE | Worker `/v1/sessions*` | `X-Nexus-Worker-Key` |
|
||||
| WS | Worker `/v1/sessions/{id}/stream` | Worker Key(Header) |
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| ID | 规则 | 结论 |
|
||||
|----|------|------|
|
||||
| H1 | SSRF / 私网访问 | **RISK** — 见 F1、F2 |
|
||||
| H2 | 鉴权 / IDOR | PASS — WS 校验 admin_id |
|
||||
| H3 | 密钥不落库/不入 git | PASS — `.env` / 持久卷 |
|
||||
| H4 | 无静默吞错 | **RISK** — 见 F4 |
|
||||
| H5 | Worker 暴露面 | **RISK** — 见 F3 |
|
||||
| H6 | 多 worker 一致性 | **RISK** — 见 F5 |
|
||||
| H7 | UI 状态 URL 白名单 | PASS — `browser_ui_state` |
|
||||
| H8 | 设计符合度(redirect) | **GAP** — 见 F1 |
|
||||
|
||||
## Closure(全表)
|
||||
|
||||
| ID | 判定 | 严重度 | 位置 | 说明 |
|
||||
|----|------|--------|------|------|
|
||||
| H1 | FINDING | **P1** | `browser-worker/session_manager.py` NAVIGATE 后 | **F1** 页内点击/重定向未逐跳 SSRF 校验;设计文档要求 redirect 后重验 |
|
||||
| H1 | FINDING | **P1** | Worker 部署 ufw | **F3** 装机时额外 `ufw allow 8443/tcp`(全网),削弱「仅主站 IP」隔离 |
|
||||
| H2 | SAFE | — | `browser_session.py:125-127` | `session_id` 必须属于当前 `admin.id` |
|
||||
| H2 | SAFE | — | `browser_session.py:95-104` | 桥接层 NAVIGATE 二次校验 |
|
||||
| H3 | SAFE | — | `worker_client.py`, Worker `.env` | `WORKER_SECRET` 仅环境变量 |
|
||||
| H4 | FINDING | P2 | `worker_client.py:67-68` | **F4** `delete_worker_session` 网络失败仅 warning,可能残留 Worker 会话 |
|
||||
| H5 | FINDING | P2 | `browser-worker/main.py:45-51` | `/health` 无认证;若 8443 对公网可见会泄露会话数 |
|
||||
| H6 | FINDING | P2 | `session_registry.py` | **F5** 内存注册表不跨 uvicorn worker;多进程下 WS 可能 4403 |
|
||||
| H7 | SAFE | — | `browser_ui_state.py` | http(s) only,无凭据 URL |
|
||||
| H8 | SAFE | — | screencast 热修 | `cdp.on("Page.screencastFrame")` 已替代错误 API |
|
||||
| — | SAFE | — | `browser_session.py:61-64` | 创建前清理陈旧会话,避免 409 |
|
||||
| — | SAFE | — | 前端 | 不再 iframe;canvas 远程渲染 |
|
||||
| — | SAFE | — | CUD 审计 | 首版未记 session 审计,与设计「与 RDP 一致」一致 |
|
||||
|
||||
### F1 — 重定向 / 页内导航 SSRF 缺口(P1)
|
||||
|
||||
`validate_navigation_url` 仅在显式 `NAVIGATE` 消息时调用。用户在远程 Chromium 内**点击链接**或 **302 跳转**到 `http://10.x` / metadata 时,Playwright 会照常请求,**不经过**校验。
|
||||
|
||||
**建议**:`page.on("framenavigated")` / `response` 监听,每跳对 `page.url` 调用 `validate_navigation_url`;失败则 `page.close()` 或 `about:blank` 并 WS `ERROR`。
|
||||
|
||||
### F3 — Worker 防火墙过宽(P1)
|
||||
|
||||
部署日志显示除 `from 20.24.218.235` 外还有 `ufw allow 8443/tcp`(Anywhere)。攻击者若猜到 `WORKER_SECRET`(或暴力)可直连 Worker。
|
||||
|
||||
**建议**:删除全网 8443 规则,仅保留主站源 IP;`/health` 改内网或加 Key。
|
||||
|
||||
### F4 — Worker 删除失败(P2)
|
||||
|
||||
`delete_worker_session` 异常时静默;依赖 Worker 空闲超时清理。
|
||||
|
||||
### F5 — 多 uvicorn worker(P2)
|
||||
|
||||
`session_registry` 进程内字典;生产若 `--workers>1` 可能 POST 在 worker A、WS 落到 worker B。当前 Docker 单进程可接受;扩 worker 时需 Redis 注册表。
|
||||
|
||||
## 外部输入 → Sink
|
||||
|
||||
| 输入 | Sink | 缓解 |
|
||||
|------|------|------|
|
||||
| `NAVIGATE.url` | Playwright `goto` | 双端 `validate_navigation_url` |
|
||||
| 页内链接点击 | Playwright 导航 | **未缓解(F1)** |
|
||||
| WS `MOUSE`/`KEY` | Chromium | 仅已认证管理员 |
|
||||
| `browser/state` tabs URL | MySQL JSON | `_is_safe_url` 过滤 |
|
||||
| Worker Key | Worker API | 常量时间比较(`!=`) |
|
||||
|
||||
## DoD
|
||||
|
||||
| 项 | 状态 |
|
||||
|----|------|
|
||||
| `pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py` | 7 passed(生产机);本地沙箱 DNS 失败属环境限制 |
|
||||
| `npm run type-check` | 已通过(实现日) |
|
||||
| 生产 `GET /api/browser/status` | `enabled:true`, `worker_ok:true` |
|
||||
| 生产 screencast 冒烟 | `SCREENCAST_OK` ~4KB JPEG |
|
||||
| changelog | `docs/changelog/2026-06-12-remote-browser-worker-deploy.md` |
|
||||
|
||||
## 总结
|
||||
|
||||
| 级别 | 数量 | 处置建议 |
|
||||
|------|------|----------|
|
||||
| P0 | 0 | — |
|
||||
| P1 | 2 | F1 redirect/页内 SSRF;F3 收紧 ufw |
|
||||
| P2 | 3 | F4/F5 可排期;/health 加固 |
|
||||
|
||||
**合并结论**:功能链路可用,鉴权与显式 NAVIGATE SSRF 达标;**未达设计文档「每跳 redirect 校验」**,且 Worker **8443 应对公网收口**。建议先修 F3(运维),再实现 F1(代码)后复审计。
|
||||
|
||||
## 验证命令
|
||||
|
||||
```bash
|
||||
venv/bin/pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py -q
|
||||
cd frontend && npm run type-check
|
||||
# 生产
|
||||
curl -H "Authorization: Bearer $TOKEN" https://api.synaglobal.vip/api/browser/status
|
||||
```
|
||||
@@ -0,0 +1,15 @@
|
||||
# 审计 — 监测槽 hover 外框高亮
|
||||
|
||||
**日期**:2026-06-12
|
||||
**Changelog**: `docs/changelog/2026-06-12-watch-slot-hover-border.md`
|
||||
|
||||
## 范围
|
||||
|
||||
| 文件 |
|
||||
|------|
|
||||
| `frontend/src/components/watch/WatchSlotCard.vue` |
|
||||
|
||||
## Step 3 / Closure / DoD
|
||||
|
||||
- PASS — 纯 CSS,无逻辑/安全风险
|
||||
- DoD: 浏览器悬停仅边框变色
|
||||
@@ -0,0 +1,50 @@
|
||||
# 审计 — 快捷命令回车修复 + 离线告警 Telegram 分流
|
||||
|
||||
**Changelog**:
|
||||
- `docs/changelog/2026-06-13-terminal-quick-cmd-enter-fix.md`
|
||||
- `docs/changelog/2026-06-13-offline-alert-telegram-split.md`
|
||||
|
||||
**设计/计划**:
|
||||
- `docs/design/specs/2026-06-13-offline-alert-telegram-split-design.md`
|
||||
- `docs/design/plans/2026-06-13-offline-alert-telegram-split.md`
|
||||
|
||||
## 审计范围
|
||||
|
||||
| 文件 | 变更 | 状态 |
|
||||
|------|------|------|
|
||||
| `frontend/src/composables/useTerminalQuickCommands.ts` | `formatQuickCmdForSend` | ☑ |
|
||||
| `frontend/src/composables/terminal/useTerminalSessions.ts` | `execQuickCmd` | ☑ |
|
||||
| `frontend/src/components/TerminalQuickCommandsSettings.vue` | 保存不再 append `\r` | ☑ |
|
||||
| `frontend/src/pages/SettingsPage.vue` | 离线专用 Bot UI | ☑ |
|
||||
| `frontend/src/types/api.ts` | 离线开关文案 | ☑ |
|
||||
| `frontend/src/utils/auditLabels.ts` | reveal 审计标签 | ☑ |
|
||||
| `server/infrastructure/telegram/__init__.py` | 离线路由 | ☑ |
|
||||
| `server/api/settings.py` | offline API + `_telegram_fetch_chats` | ☑ |
|
||||
| `server/config.py` | `TELEGRAM_OFFLINE_*` | ☑ |
|
||||
| `tests/test_telegram_offline_channel.py` | 新 | ☑ |
|
||||
| `tests/test_terminal_quick_commands.py` | 新 | ☑ |
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| H | 规则 | 结论 |
|
||||
|---|------|------|
|
||||
| H1 | 离线 Token 敏感掩码 | SAFE — `SENSITIVE_KEYS` |
|
||||
| H2 | reveal 审计 | SAFE — `reveal_offline_telegram_token` |
|
||||
| H3 | 半配离线回退默认 Bot | SAFE — `resolve_offline_telegram_credentials` |
|
||||
| H4 | 资源告警不走离线 Bot | SAFE — `send_telegram_alert` 未改 |
|
||||
| H5 | 快捷命令 `\r` 与 `trim()` | SAFE — 发送时 `formatQuickCmdForSend` |
|
||||
| H6 | Layer3 不同步离线 Bot | SAFE — 设计 intentional |
|
||||
|
||||
## Closure
|
||||
|
||||
| H | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H6 | SAFE | 代码 + pytest 10/10 + `test_ops_patrol_sync` 未破坏 |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] `pytest tests/test_telegram_offline_channel.py tests/test_terminal_quick_commands.py` passed
|
||||
- [x] `bash scripts/local_verify.sh` 26/26
|
||||
- [x] changelog / design / audit
|
||||
- [x] 无 SECRET_KEY/API_KEY/ENCRYPTION_KEY/DATABASE_URL 变更
|
||||
- [x] 前端 `vite build` 待部署脚本执行
|
||||
@@ -0,0 +1,33 @@
|
||||
# 审计 — Telegram 群聊 Chat ID 检测
|
||||
|
||||
**Changelog**: `docs/changelog/2026-06-13-telegram-chat-id-group-detect.md`
|
||||
|
||||
## 审计范围
|
||||
|
||||
| 文件 | 变更 | 状态 |
|
||||
|------|------|------|
|
||||
| `server/api/settings.py` | `_extract_chats_from_telegram_updates`、群聊更新类型 | ☑ |
|
||||
| `frontend/src/pages/SettingsPage.vue` | 群聊检测说明与类型标签 | ☑ |
|
||||
| `tests/test_settings_telegram.py` | my_chat_member / callback 单测 | ☑ |
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| H | 规则 | 结论 |
|
||||
|---|------|------|
|
||||
| H1 | Token 仍经既有 settings 鉴权 | SAFE — 未改 auth |
|
||||
| H2 | getUpdates 仍先清 webhook | SAFE — `_telegram_clear_webhook_if_set` |
|
||||
| H3 | 不泄露其他用户 chat | SAFE — 仅返回去重 chat 元数据 |
|
||||
| H4 | 群聊隐私模式说明 | SAFE — UI 提示 `/start@Bot` |
|
||||
|
||||
## Closure
|
||||
|
||||
| H | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H4 | SAFE | `pytest tests/test_settings_telegram.py` 8/8 |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] `pytest tests/test_settings_telegram.py` passed
|
||||
- [x] changelog / audit
|
||||
- [x] 无密钥类 settings 变更
|
||||
- [x] 前端 build 由部署脚本执行
|
||||
@@ -0,0 +1,25 @@
|
||||
# 2026-06-10 — 文件管理「新建文件」修复
|
||||
|
||||
## 摘要
|
||||
|
||||
修复文件页「新建文件」点击无反馈或创建后编辑器不弹出的问题。
|
||||
|
||||
## 动机
|
||||
|
||||
- 未选服务器时按钮被禁用,用户以为功能坏了
|
||||
- 创建成功后未等待 `FileEditorWorkbench` 就绪即 `openFile`,编辑器静默不打开
|
||||
- SFTP 写入前未确保父目录存在(部分路径下 write-file 失败)
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/composables/files/useFilesActions.ts` — `openNewFileDialog`、`waitForEditorWorkbench`
|
||||
- `frontend/src/composables/files/useFilesEditor.ts` — 导出 `waitForEditorWorkbench`
|
||||
- `frontend/src/components/files/FilesToolbar.vue` — 未选服务器时提示而非禁用
|
||||
- `server/infrastructure/ssh/asyncssh_pool.py` — SFTP 写前 `mkdir -p` 父目录
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
cd frontend && npm run type-check
|
||||
# 文件管理 → 选服务器 → 新建文件 → 应创建并打开编辑器
|
||||
```
|
||||
@@ -46,3 +46,7 @@ cd frontend && npm run type-check
|
||||
## 说明
|
||||
|
||||
iframe 在用户当前的 Chrome/Firefox 等引擎中渲染,非独立安装 Firefox;禁止嵌入的站点请用「新标签打开」。
|
||||
|
||||
## 待办(已记录需求)
|
||||
|
||||
- **网站导航 / 收藏**:用户可自建固定站点列表,见 [设计说明](../design/specs/2026-06-11-browser-nav-bookmarks-design.md) · [实施计划](../design/plans/2026-06-11-browser-nav-bookmarks.md)(后端 `nav_links` 已预留,前端 UI 待做)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Changelog — 审计日志目标列显示服务器名称
|
||||
|
||||
**日期**:2026-06-12
|
||||
|
||||
## 摘要
|
||||
|
||||
审计页「目标」列对 `target_type=server` 显示 **服务器名称**(如 `服务器 web-01`),不再仅显示 `#372`。
|
||||
|
||||
## 变更
|
||||
|
||||
- API `/api/audit/` 列表项增加 `target_name`(批量查 `servers.name`)
|
||||
- 前端 `auditTargetText()` + `AuditPage` 目标列
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/integration/test_alerts_audit.py::test_audit_logs_server_target_name -q
|
||||
```
|
||||
@@ -0,0 +1,48 @@
|
||||
# 2026-06-12 远程浏览器 — 验证码 / 反自动化优化
|
||||
|
||||
## 摘要
|
||||
|
||||
针对阿里云等站点验证码失败:Worker 改为 headed + Xvfb + 反自动化指纹;前端滑块拖动支持窗口级鼠标跟踪与平滑步进。
|
||||
|
||||
## 动机
|
||||
|
||||
用户反馈阿里云验证码过不去。根因非 Nexus URL 拦截,而是 headless Chromium 被风控识别,且拖滑块时鼠标移出 canvas 会丢失 move 事件。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 区域 | 文件 |
|
||||
|------|------|
|
||||
| Worker | `browser_stealth.py`, `session_manager.py`, `config.py`, `Dockerfile`, `docker-entrypoint.sh`, `docker-compose.yml` |
|
||||
| 前端 | `useRemoteBrowserSession.ts`, `GlobalBrowserPanel.vue` |
|
||||
|
||||
## 变更要点
|
||||
|
||||
- 去掉 `--enable-automation`,`disable-blink-features=AutomationControlled`,init script 隐藏 `navigator.webdriver`
|
||||
- 默认 `WORKER_HEADLESS=false`,容器内 Xvfb `:99`
|
||||
- 中文 locale / Asia/Shanghai / 常见 Chrome UA
|
||||
- Screencast JPEG quality 90
|
||||
- MOUSE:`down/up` 先 move 到位;`move` 支持 `steps`;新增 `click`
|
||||
- 前端:mousedown 后 `window` 级 mousemove/mouseup,滑块拖出画面仍有效
|
||||
|
||||
## 部署
|
||||
|
||||
```bash
|
||||
# Worker
|
||||
cd /opt/nexus-browser-worker && docker compose up -d --build
|
||||
|
||||
# 前端
|
||||
cd frontend && npx vite build
|
||||
# sync_webapp_to_container.sh
|
||||
```
|
||||
|
||||
Worker `.env` 可选:`WORKER_HEADLESS=false`(默认)、`WORKER_SCREENCAST_QUALITY=90`
|
||||
|
||||
## 验证
|
||||
|
||||
- 打开 `https://account.aliyun.com` 或控制台登录页,滑块可拖到底
|
||||
- 仍失败时:Worker IP 为机房段,阿里云可能加强风控 — 可本机 Chrome 登录后仅浏览公网页,或换住宅 IP Worker
|
||||
|
||||
## 残余限制
|
||||
|
||||
- 无法保证 100% 通过所有风控(拼图、短信、设备指纹)
|
||||
- Cookie 在 Worker Profile,与本机浏览器不共享
|
||||
@@ -0,0 +1,47 @@
|
||||
# 2026-06-12 远程浏览器 P0/P1 修复与生产同步
|
||||
|
||||
## 摘要
|
||||
|
||||
修复 Worker 僵尸 reservation 占槽;前端 `vite build` 同步生产;WS 连接失败时主动 DELETE 会话。
|
||||
|
||||
## 动机
|
||||
|
||||
Bug 巡查 #1–#3:生产无浏览器 SPA(用户连不上);Worker `sessions:1` 因 `ready=false` 不进 idle 清理。
|
||||
|
||||
## 变更
|
||||
|
||||
| 区域 | 内容 |
|
||||
|------|------|
|
||||
| `browser-worker/config.py` | `WORKER_RESERVE_TIMEOUT_SEC=120` |
|
||||
| `browser-worker/session_manager.py` | idle 清理未 attach 的 reservation |
|
||||
| `browser-worker/main.py` | `attach_stream` RuntimeError 时 `destroy_session` |
|
||||
| `frontend/.../useRemoteBrowserSession.ts` | WS/未登录失败时 `DELETE /browser/sessions/{id}` |
|
||||
| 生产 `web/app` | `index-BimRgA6s.js` + BrowserPage 等已 `sync_webapp_to_container.sh` |
|
||||
| Worker `66.154.115.131` | `docker compose up -d --build`,`/health` → `sessions:0` |
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 无 DB 迁移
|
||||
- Worker 已重建;主站仅同步 `web/app`(未重建 API 镜像)
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py -q
|
||||
cd frontend && npm run type-check && npx vite build
|
||||
curl https://api.synaglobal.vip/app/ # index-BimRgA6s.js
|
||||
ssh nexus 'sudo docker exec nexus-prod-nexus-1 grep -rl ws/browser /app/web/app/assets/ | wc -l' # ≥1
|
||||
curl http://66.154.115.131:8443/health # sessions:0
|
||||
```
|
||||
|
||||
浏览器终验:登录 → 左下角地球 → `https://example.com` 有 canvas 画面。
|
||||
|
||||
## 未在本批修复
|
||||
|
||||
- F1 页内导航 SSRF(`framenavigated`)
|
||||
- F3 Worker ufw 公网 8443
|
||||
- P2 前进/后退 stack 与 Chromium 历史不同步
|
||||
|
||||
## 回滚
|
||||
|
||||
恢复上一版 `web/app` tar;Worker `git checkout` + `docker compose up -d --build`(或保留 idle 修复)。
|
||||
@@ -0,0 +1,19 @@
|
||||
# 2026-06-12 浏览器固定顶栏(原悬浮窗)
|
||||
|
||||
## 摘要
|
||||
|
||||
保留 **原 GlobalBrowserPanel 完整 UI**(标签、地址栏、canvas),固定在 App Bar 正下方全宽展示;移除地球图标、拖动、最小化、缩放角。账号仍在 App Bar 最右侧,**不**把地址栏拆进 toolbar。
|
||||
|
||||
## 动机
|
||||
|
||||
用户要的是原悬浮窗形态固定置顶,而非顶栏内嵌地址栏。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/App.vue` — 顶栏仅账号;`appChromeVars` 为浏览器预留 420px
|
||||
- `frontend/src/components/GlobalBrowserPanel.vue` — 固定 `top: 64px` 全宽,无拖动/地球图标
|
||||
- 删除误加的 `GlobalBrowserAppBar.vue`、`GlobalBrowserDock.vue`、`GlobalBrowserChromeHost.vue`、`useGlobalBrowserChrome.ts`
|
||||
|
||||
## 验证
|
||||
|
||||
`cd frontend && npm run type-check && npx vite build`;登录后 App Bar 下见完整浏览器窗(标签+地址栏+画面);账号在顶栏最右。
|
||||
@@ -0,0 +1,24 @@
|
||||
# 文件下载修复 + 文件/终端列表紧凑样式
|
||||
|
||||
**日期**: 2026-06-12
|
||||
|
||||
## 变更摘要
|
||||
|
||||
- **下载**:修复 SFTP 会话在流式响应开始前关闭导致下载失败;下载时显示 loading
|
||||
- **文件列表**:表格 `compact` + 操作按钮与服务器页一致的 `text/x-small`
|
||||
- **终端右侧列表**:行高与字号收紧,与服务器列表密度接近
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/api/sync_v2.py`
|
||||
- `frontend/src/components/files/FilesList.vue`
|
||||
- `frontend/src/composables/files/useFilesActions.ts`
|
||||
- `frontend/src/components/terminal/TerminalServerPickerList.vue`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 需 API 重启(下载逻辑在后端)
|
||||
|
||||
## 验证
|
||||
|
||||
`pytest` 相关 sync 测试;文件页点击下载应保存文件;终端/文件列表行高与服务器页接近。
|
||||
@@ -0,0 +1,22 @@
|
||||
# 文件管理移除下载入口
|
||||
|
||||
**日期**: 2026-06-12
|
||||
|
||||
## 变更摘要
|
||||
|
||||
移除文件管理页行内「下载」按钮与右键菜单项;大文件提示改为仅建议使用终端。
|
||||
|
||||
## 动机
|
||||
|
||||
产品决策:文件管理不提供浏览器下载,避免大文件与 SFTP 流式链路问题。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/components/files/FilesList.vue`
|
||||
- `frontend/src/composables/files/useFilesActions.ts`
|
||||
- `frontend/src/composables/files/useFilesPage.ts`
|
||||
- `frontend/src/composables/files/useFilesEditor.ts`
|
||||
|
||||
## 验证
|
||||
|
||||
`npm run type-check`;文件页无下载按钮;右键菜单无下载项。
|
||||
@@ -0,0 +1,69 @@
|
||||
# 2026-06-12 远程浏览器(Playwright Worker)上线
|
||||
|
||||
## 摘要
|
||||
|
||||
在 **66.154.115.131** 部署 `browser-worker`(Playwright Chromium),Nexus 主站桥接 WebSocket + canvas 浮动面板,替代已移除的 iframe 方案。
|
||||
|
||||
## 动机
|
||||
|
||||
运维需在面板内完整浏览公网站点(不受 X-Frame-Options 限制);Chromium 运行在独立 Worker,隔离 SSRF 与内存压力。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 区域 | 文件 |
|
||||
|------|------|
|
||||
| Worker | `browser-worker/*` |
|
||||
| 后端 | `server/api/browser.py`, `browser_session.py`, `server/infrastructure/browser/*`, `server/utils/browser_url_safe.py`, `server/utils/browser_ui_state.py`, `server/config.py`, `server/main.py` |
|
||||
| 前端 | `GlobalBrowserPanel.vue`, `useGlobalBrowser.ts`, `useRemoteBrowserSession.ts`, `BrowserPage.vue`, `App.vue`, `router`, `ServersPage.vue` |
|
||||
| 测试 | `tests/test_browser_url_safe.py`, `tests/test_browser_ui_state.py` |
|
||||
|
||||
## 部署拓扑
|
||||
|
||||
```
|
||||
管理员 SPA → Nexus (20.24.218.235) → Worker (66.154.115.131:8443) → 公网
|
||||
```
|
||||
|
||||
## 生产配置(勿提交 git)
|
||||
|
||||
主站 `/var/lib/nexus/.env` 与 `docker/.env.prod` 均需:
|
||||
|
||||
```env
|
||||
NEXUS_BROWSER_ENABLED=1
|
||||
NEXUS_BROWSER_WORKER_URL=http://66.154.115.131:8443
|
||||
NEXUS_BROWSER_WORKER_SECRET=<与 Worker .env WORKER_SECRET 相同>
|
||||
NEXUS_BROWSER_MAX_SESSIONS=2
|
||||
NEXUS_BROWSER_IDLE_TIMEOUT_SEC=1800
|
||||
```
|
||||
|
||||
Worker:`/opt/nexus-browser-worker/.env`(`WORKER_SECRET` 同上)
|
||||
|
||||
防火墙:Worker `8443` 已对 Nexus 主站 IP `20.24.218.235` 放行。
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 无 DB 迁移(复用 `admin_ui_preferences` / `embedded_browser`)
|
||||
- Worker:`docker compose up -d --build`
|
||||
- 主站:重建/重启 `nexus-prod-nexus-1` + 同步 `web/app`
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
venv/bin/pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py -q
|
||||
cd frontend && npm run type-check && npx vite build
|
||||
curl -H "Authorization: Bearer $TOKEN" https://api.synaglobal.vip/api/browser/status
|
||||
# → enabled:true, worker_ok:true
|
||||
```
|
||||
|
||||
浏览器:左下角地球按钮 / 侧栏「浏览器」;输入 `https://example.com` 应出现 canvas 画面。
|
||||
|
||||
## 回滚
|
||||
|
||||
`NEXUS_BROWSER_ENABLED=0` 重启主站;Worker `docker compose down` 不影响 Nexus 其他功能。
|
||||
|
||||
## 2026-06-12 热修 — 画面不显示 / 连不上
|
||||
|
||||
**根因**:Worker `CDPSession` 误用 `wait_for_event`(不存在),screencast 立即崩溃;断线后会话残留导致 `409`。
|
||||
|
||||
**修复**:`browser-worker/session_manager.py` 改为 `cdp.on("Page.screencastFrame")` + 队列;`POST /api/browser/sessions` 创建前清理该管理员陈旧会话。
|
||||
|
||||
**验证**:主站容器内 WS 测试 `SCREENCAST_OK`(example.com JPEG ~4KB)。
|
||||
@@ -0,0 +1,33 @@
|
||||
# 2026-06-12 修复 push_complete_sound GET 404
|
||||
|
||||
## 摘要
|
||||
|
||||
`GET /api/settings/push_complete_sound` 在 MySQL 尚无该行时返回 404;现对已有默认值的设置项返回默认 JSON。
|
||||
|
||||
## 动机
|
||||
|
||||
登录后 `App.vue` 调用 `syncPushSoundFromServer` 触发 404;`script_exec_complete_sound` 同理。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/api/settings.py` — `SETTING_DEFAULTS` + `get_setting` 回退
|
||||
- `tests/test_settings_sound_defaults.py`
|
||||
|
||||
## 默认
|
||||
|
||||
| key | 默认 |
|
||||
|-----|------|
|
||||
| `push_complete_sound` | `beep_double` |
|
||||
| `script_exec_complete_sound` | `beep_double` |
|
||||
| `theme` | `dark` |
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
venv/bin/pytest tests/test_settings_sound_defaults.py -q
|
||||
# 生产(JWT)GET /api/settings/push_complete_sound → 200 {"value":"beep_double",...}
|
||||
```
|
||||
|
||||
## 迁移
|
||||
|
||||
无;首次 PUT 仍会 upsert 入库。
|
||||
@@ -0,0 +1,16 @@
|
||||
# 终端页右侧服务器列表搜索
|
||||
|
||||
**日期**: 2026-06-12
|
||||
|
||||
## 变更摘要
|
||||
|
||||
终端页右侧服务器栏增加与空态/弹窗一致的搜索框,支持按名称、域名筛选及搜索历史。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/components/terminal/TerminalServerPicker.vue`
|
||||
- `frontend/src/pages/TerminalPage.vue`
|
||||
|
||||
## 验证
|
||||
|
||||
`npm run type-check`;终端页右侧输入关键词,列表实时过滤。
|
||||
@@ -0,0 +1,18 @@
|
||||
# 监测历史:4 槽同时展示趋势
|
||||
|
||||
**日期**: 2026-06-12
|
||||
|
||||
## 变更摘要
|
||||
|
||||
- 资源趋势 Tab 改为 **2×2 四槽同屏**,每槽独立曲线;共用时间范围与指标选择
|
||||
- 探针记录 Tab 默认展示 **全部槽位** 记录(不再单选服务器)
|
||||
- 探针表头 MEM/DISK 改为「内存」「硬盘」
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/pages/WatchMetricsPage.vue`
|
||||
- `frontend/src/components/watch/WatchProbeRecordsTable.vue`
|
||||
|
||||
## 验证
|
||||
|
||||
`npm run type-check`;打开 `#/watch-metrics`,4 槽图表同屏;探针记录含多机数据。
|
||||
@@ -0,0 +1,19 @@
|
||||
# Changelog — 监测槽卡片 hover 外框高亮
|
||||
|
||||
**日期**:2026-06-12
|
||||
|
||||
## 摘要
|
||||
|
||||
监测槽卡片(空槽/满槽)hover 时改为**外框主题色**,不再整卡铺灰底。
|
||||
|
||||
## 补充(2026-06-12 晚)
|
||||
|
||||
- `v-card` 设 `:link="false"` `:ripple="false"`,并隐藏 `.v-card__overlay`,消除 Vuetify 默认可点击卡片的 hover 灰遮罩。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/components/watch/WatchSlotCard.vue`
|
||||
|
||||
## 验证
|
||||
|
||||
浏览器:服务器页 → 实时监测 → 鼠标悬停槽位,仅边框变色。
|
||||
@@ -0,0 +1,20 @@
|
||||
# 监测槽:等待探针回传占位 UI
|
||||
|
||||
**日期**: 2026-06-12
|
||||
|
||||
## 变更摘要
|
||||
|
||||
开启实时监测后、首次探针尚未返回时,槽卡显示「等待服务器回传信息…」与加载动画;收到 `probe_status` 后自动切换为 CPU/内存/硬盘等指标。
|
||||
|
||||
## 动机
|
||||
|
||||
避免开关打开后空白进度条与「—」,明确告知用户正在轮询。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/components/watch/WatchSlotCard.vue`
|
||||
- `frontend/src/utils/watchFormat.ts` — `isWatchMetricsPending`
|
||||
|
||||
## 验证
|
||||
|
||||
`cd frontend && npm run type-check`;浏览器开监测见等待态,约 5–15s 后自动出现指标。
|
||||
@@ -0,0 +1,47 @@
|
||||
# 监测槽:时长分钟档 + 指标进度条 + 负载
|
||||
|
||||
**日期**: 2026-06-12
|
||||
|
||||
## 变更摘要
|
||||
|
||||
- 监测槽时长改为分钟粒度:30分 / 1时 / 2时 / 8时 / 24时,**默认 30 分钟**
|
||||
- 槽卡 UI:chip 选择时长;CPU / 内存 / 硬盘 百分比进度条;负载展示 1 分钟平均(与宝塔「负载」同源)
|
||||
- 后端 `watch_pins.ttl_minutes` 列;API 字段 `ttl_minutes`;兼容旧 `ttl_hours` 迁移
|
||||
|
||||
## 动机
|
||||
|
||||
用户需要更细粒度默认监测窗口,并在槽位卡片上直观看到资源占用与系统负载。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/utils/watch_metrics.py` — TTL 分钟常量与归一化
|
||||
- `server/domain/models/__init__.py` — `ttl_minutes` 列
|
||||
- `server/infrastructure/database/migrations.py` — 迁移与回填
|
||||
- `server/infrastructure/database/watch_repo.py`
|
||||
- `server/application/services/watch_service.py`
|
||||
- `server/api/watch.py`
|
||||
- `frontend/src/constants/watchTtl.ts`
|
||||
- `frontend/src/components/watch/WatchMetricBar.vue`
|
||||
- `frontend/src/components/watch/WatchSlotCard.vue`
|
||||
- `frontend/src/utils/watchFormat.ts`
|
||||
- `frontend/src/composables/useWatchPins.ts`
|
||||
- `tests/test_watch_pins.py`, `tests/test_watch_metrics.py`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 需 API 重启以执行 DB 迁移(`ttl_minutes` 列)
|
||||
- 前端需 `vite build` 后部署
|
||||
|
||||
## 验证方式
|
||||
|
||||
```bash
|
||||
pytest tests/test_watch_*.py -q
|
||||
cd frontend && npm run type-check
|
||||
bash scripts/local_verify.sh
|
||||
```
|
||||
|
||||
浏览器:监测槽选 30 分默认、切换时长、进度条与负载 chip 显示正常。
|
||||
|
||||
## 补充(进度条阈值)
|
||||
|
||||
- CPU/内存/硬盘进度条:≥90% 红、70–89% 黄、<70% 绿(`WatchMetricBar.vue`)。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 2026-06-13 子机离线告警独立 Telegram 通道
|
||||
|
||||
## 摘要
|
||||
|
||||
设置页「告警通知项目」下新增可选的离线专用 Bot:子机离线告警可发往另一 Telegram 机器人/群组,其它告警仍走默认 Bot。
|
||||
|
||||
## 动机
|
||||
|
||||
离线告警量大或受众不同,需与 CPU/内存等资源告警分流,避免主告警群被淹没。
|
||||
|
||||
## 变更
|
||||
|
||||
- 新 settings:`telegram_offline_bot_token`、`telegram_offline_chat_id`(二者均配置时离线告警专用;否则回退默认 Bot)。
|
||||
- `resolve_offline_telegram_credentials()` + `send_telegram_offline_alert` 路由。
|
||||
- API:`POST /settings/telegram/offline/test`、`GET /settings/telegram/offline/chats`、`POST /settings/telegram/offline/reveal-token`。
|
||||
- 设置页:「子机离线告警专用 Bot(可选)」配置区。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/config.py`
|
||||
- `server/infrastructure/telegram/__init__.py`
|
||||
- `server/api/settings.py`
|
||||
- `frontend/src/pages/SettingsPage.vue`
|
||||
- `frontend/src/types/api.ts`
|
||||
- `tests/test_telegram_offline_channel.py`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 后端:拉取后 `supervisorctl restart nexus`(新 API 端点)。
|
||||
- 前端:重新 `vite build` 部署 `web/app/`。
|
||||
- 无 DB 迁移;首次保存写入 `settings` 表。
|
||||
|
||||
## 验证
|
||||
|
||||
1. 设置页配置离线专用 Token + Chat ID →「测试离线通道」收到消息。
|
||||
2. 仅配置默认 Bot、不填离线专用 → 子机离线仍走默认群。
|
||||
3. `pytest tests/test_telegram_offline_channel.py` 通过。
|
||||
@@ -0,0 +1,29 @@
|
||||
# Telegram Chat ID 群聊检测修复
|
||||
|
||||
**日期**:2026-06-13
|
||||
|
||||
## 变更摘要
|
||||
|
||||
修复设置页「检测 Chat ID」无法识别群聊的问题:扩展 `getUpdates` 解析,覆盖 Bot 被拉入群时的 `my_chat_member` 等更新类型;更新前端操作说明。
|
||||
|
||||
## 动机
|
||||
|
||||
用户反馈群聊 Chat ID 检测不到。原实现仅从 `message` / `channel_post` 取 `chat`,而群聊常见首条可见更新为 `my_chat_member`(Bot 入群),导致列表为空。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/api/settings.py` — `_telegram_chat_entry`、`_extract_chats_from_telegram_updates`、`_telegram_fetch_chats`(limit 100,最多返回 10 条,群组优先)
|
||||
- `frontend/src/pages/SettingsPage.vue` — 群聊检测说明、空状态文案、类型中文标签
|
||||
- `tests/test_settings_telegram.py` — 群聊/编辑消息/callback 解析单测
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
无需数据库迁移;部署后端 + 前端后生效。
|
||||
|
||||
## 验证方式
|
||||
|
||||
```bash
|
||||
pytest tests/test_settings_telegram.py -q
|
||||
```
|
||||
|
||||
生产:将 Bot 拉入测试群 → 群内发送 `/start@Bot用户名` → 设置页点击「检测 Chat ID」应出现群组项(负 ID)。
|
||||
@@ -0,0 +1,31 @@
|
||||
# 2026-06-13 终端快捷命令发送时自动追加回车修复
|
||||
|
||||
## 摘要
|
||||
|
||||
修复自定义快捷命令点击执行后未自动回车、命令悬停在输入行的问题。
|
||||
|
||||
## 动机
|
||||
|
||||
设置页文案为「命令内容(发送时自动追加回车)」,但保存时在 `cmd` 末尾追加 `\r` 后,`useTerminalQuickCommands.create/update` 的 `cmd.trim()` 会把 `\r` 当作空白剥掉,导致入库命令无回车;终端 `execQuickCmd` 又原样发送,表现为快捷命令不执行。
|
||||
|
||||
## 变更
|
||||
|
||||
- `formatQuickCmdForSend()`:执行时统一追加 `\r`(与命令栏 `sendCmd` 一致),对已含 `\r`/`\n` 的命令幂等。
|
||||
- `execQuickCmd`:发送前经 `formatQuickCmdForSend` 处理。
|
||||
- 设置页保存:仅存 trimmed 命令正文,不再在保存阶段追加 `\r`。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/composables/useTerminalQuickCommands.ts`
|
||||
- `frontend/src/composables/terminal/useTerminalSessions.ts`
|
||||
- `frontend/src/components/TerminalQuickCommandsSettings.vue`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 仅前端:重新 `vite build` 并部署 `web/app/`。
|
||||
- 已有 DB 中带 `\r` 的内置/历史命令不受影响(发送时去尾后再追加一次 `\r`)。
|
||||
|
||||
## 验证
|
||||
|
||||
1. 设置 → 终端快捷命令:添加 `echo hello`,保存后在终端页点击执行,应出现 `hello` 输出并回到新提示符。
|
||||
2. 内置命令(如「磁盘使用」)仍可一键执行。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 实施说明 — 浏览器网站导航(收藏)
|
||||
|
||||
> 设计:`docs/design/specs/2026-06-11-browser-nav-bookmarks-design.md`
|
||||
|
||||
## 涉及文件(计划)
|
||||
|
||||
| 文件 | 动作 |
|
||||
|------|------|
|
||||
| `server/utils/browser_ui_state.py` | ✅ `nav_links` 解析与上限 |
|
||||
| `server/api/browser.py` | ✅ `BrowserNavLinkState` / PUT 字段 |
|
||||
| `tests/test_browser_ui_state.py` | ✅ `test_parse_nav_links` |
|
||||
| `frontend/src/composables/useGlobalBrowser.ts` | `navLinks` + add/update/remove + persist |
|
||||
| `frontend/src/components/GlobalBrowserPanel.vue` | 侧栏 Tab、列表、添加/编辑对话框、空白页快捷入口 |
|
||||
| `docs/changelog/2026-06-11-browser-nav-bookmarks.md` | 完成后记录 |
|
||||
|
||||
## 前端步骤
|
||||
|
||||
1. `BrowserNavLink` 类型与 `applyServerState` / `persistState` 带上 `nav_links`
|
||||
2. `addNavLink(title, url)` · `updateNavLink(id, …)` · `removeNavLink(id)`
|
||||
3. `addCurrentPageToNav()` — 取 `activeTab.url` 或 `addressDraft`
|
||||
4. 侧栏:`sidebarMode: 'nav' | 'history' | null`
|
||||
5. 对话框:`v-dialog` 名称 + URL,`normalizeBrowserUrl` 校验
|
||||
6. 空白页:`v-chip` / 列表展示 `navLinks`,点击 `openUrl`
|
||||
|
||||
## 测试要点
|
||||
|
||||
```bash
|
||||
pytest tests/test_browser_ui_state.py -q
|
||||
cd frontend && npm run type-check
|
||||
```
|
||||
|
||||
- 添加 3 条导航 → PUT → GET 往返一致
|
||||
- 超 64 条截断;非法 URL 过滤
|
||||
- UI:编辑后刷新仍显示
|
||||
|
||||
## 回滚
|
||||
|
||||
- 前端未发版:仅后端多返回空 `nav_links`,兼容
|
||||
- 回滚前端:导航 UI 消失,DB 中 JSON 保留无害
|
||||
|
||||
## 依赖
|
||||
|
||||
- 全局浮动浏览器(`GlobalBrowserPanel` + `/api/browser/state`)已上线
|
||||
@@ -0,0 +1,253 @@
|
||||
# 浏览器 Worker 独立服务器 — 采购与部署计划
|
||||
|
||||
> **目标**:在**单独一台 VPS** 上运行 Playwright Chromium,Nexus 主站(`api.synaglobal.vip`)通过内网 API 桥接,管理员在浮动面板 canvas 操作。
|
||||
> **设计**:[`2026-06-11-server-browser-chromium-design.md`](../specs/2026-06-11-server-browser-chromium-design.md)
|
||||
> **状态**:待购机 → 装机 → Nexus 代码联调 → 生产接入
|
||||
|
||||
---
|
||||
|
||||
## 1. 为什么要单独买一台
|
||||
|
||||
| 项 | 全部塞进 Nexus 主站 | **独立 Worker(本计划)** |
|
||||
|----|---------------------|---------------------------|
|
||||
| 主站内存 | 每会话 +200~500MB | 主站几乎不增 |
|
||||
| Docker 镜像 | +300~500MB | 主镜像不变 |
|
||||
| SSRF 风险面 | 与 API/DB 同机 | 隔离在 Worker |
|
||||
| 扩缩容 | 和 API 绑死 | 可单独升配 Worker |
|
||||
|
||||
你已确认:**任意公网 https + Chromium**;**不要 iframe**。Worker 分离是推荐部署形态。
|
||||
|
||||
---
|
||||
|
||||
## 2. 购机规格(下单前对照)
|
||||
|
||||
### 2.1 最低可用(单人运维、≤2 并发会话)
|
||||
|
||||
| 项 | 建议 |
|
||||
|----|------|
|
||||
| **CPU** | 2 vCPU(x86_64) |
|
||||
| **内存** | **4 GB**(Chromium 单会话约 300~800MB,留系统与缓冲) |
|
||||
| **磁盘** | 40 GB SSD(系统 + Chromium 缓存) |
|
||||
| **系统** | **Ubuntu 22.04 LTS** 或 **24.04 LTS**(x86_64) |
|
||||
| **带宽** | ≥ 5 Mbps 出站(画面 JPEG 流;10 Mbps 更稳) |
|
||||
| **地域** | 与 Nexus 主站**同区域或近区**(降延迟) |
|
||||
|
||||
### 2.2 推荐配置(留余量、偶尔双标签)
|
||||
|
||||
| 项 | 建议 |
|
||||
|----|------|
|
||||
| CPU | 4 vCPU |
|
||||
| 内存 | **8 GB** |
|
||||
| 磁盘 | 60 GB SSD |
|
||||
|
||||
### 2.3 不建议
|
||||
|
||||
- **1 GB / 2 GB 内存** — Chromium 易 OOM,会话被 kill
|
||||
- **ARM 除非验证 Playwright** — 优先 x86_64,减少兼容问题
|
||||
- **与 Nexus 主站共用同一台** — 违背隔离目的(若预算极紧可临时同机,不推荐)
|
||||
|
||||
### 2.4 厂商与形态
|
||||
|
||||
任意支持 **Ubuntu + 公网 IP** 的 VPS 即可(Azure、阿里云、腾讯云、Vultr 等)。
|
||||
**不必**买 Windows;Worker 只跑 Linux + Docker。
|
||||
|
||||
---
|
||||
|
||||
## 3. 网络与安全(购机时一并规划)
|
||||
|
||||
### 3.1 拓扑
|
||||
|
||||
```
|
||||
[ 管理员浏览器 ] ──HTTPS──► [ Nexus 主站 20.24.218.235 :443 ]
|
||||
│
|
||||
内网/VPN/专线(推荐)
|
||||
▼
|
||||
[ Browser Worker :8443 ]
|
||||
│
|
||||
└──► 任意公网站点
|
||||
```
|
||||
|
||||
### 3.2 防火墙(Worker 上)
|
||||
|
||||
| 方向 | 规则 |
|
||||
|------|------|
|
||||
| 入站 | **仅允许 Nexus 主站 IP** 访问 Worker 服务端口(默认 `8443/tcp`) |
|
||||
| 入站 | SSH `22/tcp` 仅你的运维 IP(或跳板机) |
|
||||
| 入站 | **禁止** 0.0.0.0/0 访问 8443 |
|
||||
| 出站 | 允许 `80/443` 访问公网(浏览器上网) |
|
||||
| 出站 | 禁止 Worker 访问 Nexus 内网/MySQL/Redis(Worker 不需要) |
|
||||
|
||||
> 若 Nexus 与 Worker **同云厂商同 VPC**,用**内网 IP** 通信,Worker **不要绑公网 8443**。
|
||||
|
||||
### 3.3 密钥
|
||||
|
||||
购机后生成一对:
|
||||
|
||||
- `NEXUS_BROWSER_WORKER_SECRET` — 随机 ≥ 32 字节,Worker 与 Nexus 主站 `.env` **各存一份相同值**
|
||||
- Worker **不**存 Nexus `SECRET_KEY` / `DATABASE_URL`
|
||||
|
||||
---
|
||||
|
||||
## 4. 购机后你要收集的信息(交给 Agent 联调)
|
||||
|
||||
买好后请记录(可写在本地 `.env` 片段,**勿提交 git**):
|
||||
|
||||
```bash
|
||||
# Browser Worker(新机器)
|
||||
BROWSER_WORKER_PUBLIC_IP= # 若无内网则用
|
||||
BROWSER_WORKER_PRIVATE_IP= # 与 Nexus 同 VPC 时优先
|
||||
BROWSER_WORKER_SSH=user@ip
|
||||
BROWSER_WORKER_SSH_KEY=/path/to/key.pem
|
||||
|
||||
# Nexus 主站侧(deploy/nexus-1panel.secrets.sh 或生产 .env)
|
||||
NEXUS_BROWSER_ENABLED=1
|
||||
NEXUS_BROWSER_WORKER_URL=https://<worker-ip-or-internal>:8443
|
||||
NEXUS_BROWSER_WORKER_SECRET=<与 Worker 相同>
|
||||
NEXUS_BROWSER_MAX_SESSIONS=2
|
||||
NEXUS_BROWSER_IDLE_TIMEOUT_SEC=1800
|
||||
```
|
||||
|
||||
并确认:
|
||||
|
||||
- [ ] 从 **Nexus 主站** `curl -k https://<worker>:8443/health` 能通(部署 Worker 后)
|
||||
- [ ] 从 **公网随机 IP** 访问 Worker 8443 **应失败**(防火墙生效)
|
||||
|
||||
---
|
||||
|
||||
## 5. Worker 机上安装步骤(购机后执行)
|
||||
|
||||
> 代码侧将提供 `browser-worker/` 目录与 `docker-compose.worker.yml`;以下为计划步骤,**Agent 实现后按 changelog 为准**。
|
||||
|
||||
### Phase A — 基础环境(约 15 分钟)
|
||||
|
||||
```bash
|
||||
# 1. SSH 登录新机器
|
||||
ssh -i key.pem ubuntu@<WORKER_IP>
|
||||
|
||||
# 2. 系统更新 + Docker
|
||||
sudo apt-get update && sudo apt-get upgrade -y
|
||||
sudo apt-get install -y ca-certificates curl
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
sudo usermod -aG docker $USER
|
||||
# 重新登录使 docker 组生效
|
||||
|
||||
# 3. 防火墙(示例 ufw)
|
||||
sudo ufw default deny incoming
|
||||
sudo ufw allow from <NEXUS_MAIN_IP> to any port 8443 proto tcp
|
||||
sudo ufw allow from <YOUR_OPS_IP> to any port 22 proto tcp
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
### Phase B — 部署 Browser Worker 容器(Agent 交付后)
|
||||
|
||||
```bash
|
||||
# 在 Worker 上
|
||||
git clone http://66.154.115.8:3000/admin/Nexus.git /opt/nexus-worker
|
||||
cd /opt/nexus-worker/browser-worker # 待实现路径
|
||||
cp .env.example .env
|
||||
# 编辑 WORKER_SECRET、BIND、日志级别
|
||||
docker compose up -d --build
|
||||
docker compose logs -f # 确认 Chromium 就绪
|
||||
curl -s http://127.0.0.1:8443/health # 应返回 ok
|
||||
```
|
||||
|
||||
### Phase C — Nexus 主站接入(Agent 联调)
|
||||
|
||||
```bash
|
||||
# 在 Nexus 主站 .env 增加 NEXUS_BROWSER_*
|
||||
# 重启 API:supervisorctl restart nexus 或 docker compose restart nexus
|
||||
bash scripts/local_verify.sh # 开发机
|
||||
# 生产:bash deploy/pre_deploy_check.sh && bash deploy/deploy-production.sh
|
||||
```
|
||||
|
||||
### Phase D — 验收
|
||||
|
||||
| # | 检查项 | 通过标准 |
|
||||
|---|--------|----------|
|
||||
| 1 | Worker health | `/health` → ok |
|
||||
| 2 | 公网站点 | 面板打开 `https://example.com` 有画面 |
|
||||
| 3 | iframe 曾白屏的站 | 能显示(抽 1~2 个已知站) |
|
||||
| 4 | SSRF | 面板输入 `http://127.0.0.1` → 明确错误,Worker 日志无成功请求 |
|
||||
| 5 | 隔离 | 未授权 IP 无法连 Worker 8443 |
|
||||
| 6 | 持久化 | 刷新 Nexus 后标签/收藏仍在(MySQL) |
|
||||
| 7 | 重启 Worker | Nexus 会话断开可重建,不拖垮主站 |
|
||||
|
||||
---
|
||||
|
||||
## 6. Nexus 代码实施顺序(与购机并行)
|
||||
|
||||
你可**先买服务器**;Agent 在仓库内并行开发,**不阻塞购机**。
|
||||
|
||||
| 阶段 | 内容 | 依赖 Worker 机器 |
|
||||
|------|------|------------------|
|
||||
| **W1** | `browser-worker/` 独立服务 + Docker + health | 否(本地 Docker 可测) |
|
||||
| **W2** | SSRF 校验、Playwright screencast、Worker WS 协议 | 否 |
|
||||
| **W3** | Nexus 主站 `browser_bridge` 桥接 + `/ws/browser` | 否(mock Worker) |
|
||||
| **W4** | 前端 canvas 替换 iframe | 否 |
|
||||
| **W5** | 你在 Worker VPS 部署 + 主站 `.env` 联调 | **是** |
|
||||
| **W6** | changelog、audit、门控、生产部署 | 是 |
|
||||
|
||||
**购机前可完成 W1~W4**;**W5 需要你的 Worker IP + SSH**。
|
||||
|
||||
---
|
||||
|
||||
## 7. Worker 服务接口(预定,实现以代码为准)
|
||||
|
||||
Nexus 主站 → Worker(内网,Header `X-Nexus-Worker-Key`):
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/health` | 就绪探测 |
|
||||
| POST | `/v1/sessions` | 创建 `{ session_id, viewport }` |
|
||||
| DELETE | `/v1/sessions/{id}` | 销毁 |
|
||||
| WS | `/v1/sessions/{id}/stream` | 双向:帧 + navigate/键鼠 |
|
||||
|
||||
管理员浏览器 **只连 Nexus**;不直连 Worker(Worker 不对管理员暴露)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 预算参考(仅供参考)
|
||||
|
||||
| 配置 | 大致月费(国内/海外 VPS) |
|
||||
|------|---------------------------|
|
||||
| 2C4G | ¥50~150 / $5~20 |
|
||||
| 4C8G | ¥100~300 / $15~40 |
|
||||
|
||||
另计:出站流量(画面流持续使用时略高于纯 API)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 购机 Checklist(打印勾选)
|
||||
|
||||
**下单前**
|
||||
|
||||
- [ ] 2 vCPU + **4 GB RAM** 及以上
|
||||
- [ ] Ubuntu 22.04/24.04 x86_64
|
||||
- [ ] 与 Nexus 主站同区域或近区
|
||||
- [ ] 可配置安全组 / ufw
|
||||
- [ ] 独立公网 IP 或与 Nexus **同 VPC 内网**
|
||||
|
||||
**到手后**
|
||||
|
||||
- [ ] SSH 可登录
|
||||
- [ ] 记录公网 IP / 内网 IP
|
||||
- [ ] ufw:8443 仅 Nexus 主站 IP
|
||||
- [ ] 生成 `NEXUS_BROWSER_WORKER_SECRET`
|
||||
- [ ] 把 §4 信息发给 Agent / 写入本地 secrets
|
||||
- [ ] 等 `browser-worker/` 合并后执行 Phase B~D
|
||||
|
||||
**回滚**
|
||||
|
||||
- [ ] 主站 `NEXUS_BROWSER_ENABLED=0` 即关闭,Worker 可关机不影响 Nexus
|
||||
|
||||
---
|
||||
|
||||
## 10. 与现有文档关系
|
||||
|
||||
| 文档 | 关系 |
|
||||
|------|------|
|
||||
| `server-browser-chromium-design.md` | 功能与安全 SSOT |
|
||||
| `server-browser-chromium.md` | Nexus 主站 + 前端实现清单 |
|
||||
| **本文** | **购机、网络、Worker 装机、联调验收** |
|
||||
|
||||
购机完成后,在新会话说:「Worker 已就绪,IP 是 …」并提供 SSH(secrets 本地),Agent 从 **W5 联调** 继续。
|
||||
@@ -0,0 +1,102 @@
|
||||
# 实施说明 — 服务端 Playwright Chromium 远程浏览器
|
||||
|
||||
> 设计:`docs/design/specs/2026-06-11-server-browser-chromium-design.md`
|
||||
> **Worker 购机/装机**:[`2026-06-11-browser-worker-server-procurement.md`](./2026-06-11-browser-worker-server-procurement.md) ← **先买服务器**
|
||||
|
||||
## 部署形态(2026-06-11 更新)
|
||||
|
||||
**选定独立 Worker**,不在 Nexus 主站 `Dockerfile.prod` 安装 Chromium。
|
||||
|
||||
| 仓库路径 | 运行位置 |
|
||||
|----------|----------|
|
||||
| `browser-worker/` | Worker VPS |
|
||||
| `server/.../browser_bridge.py` 等 | Nexus 主站 |
|
||||
|
||||
## 涉及文件(计划)
|
||||
|
||||
| 文件 | 动作 | 位置 |
|
||||
|------|------|------|
|
||||
| `browser-worker/Dockerfile` | **新建** | Worker |
|
||||
| `browser-worker/docker-compose.yml` | **新建** | Worker |
|
||||
| `browser-worker/main.py` | Worker FastAPI + WS + Playwright | Worker |
|
||||
| `browser-worker/browser_url_safe.py` | SSRF(与主站共享逻辑或复制) | Worker |
|
||||
| `server/utils/browser_url_safe.py` | SSRF(桥接层二次校验) | Nexus |
|
||||
| `server/infrastructure/browser/worker_client.py` | HTTP/WS 连 Worker | Nexus |
|
||||
| `server/api/browser_session.py` | REST + 管理员 WS 桥接 | Nexus |
|
||||
| `server/main.py` | 注册 router;`NEXUS_BROWSER_*` | Nexus |
|
||||
| `server/config.py` | `browser_enabled`, `browser_worker_url`, `worker_secret` | Nexus |
|
||||
| `tests/test_browser_url_safe.py` | SSRF 单测 | Nexus |
|
||||
| `tests/test_browser_worker_client.py` | mock Worker | Nexus |
|
||||
| `frontend/src/composables/useRemoteBrowserSession.ts` | canvas + WS | 前端 |
|
||||
| `frontend/src/components/GlobalBrowserPanel.vue` | iframe → canvas | 前端 |
|
||||
| `frontend/src/composables/useGlobalBrowser.ts` | 对接 remote session | 前端 |
|
||||
|
||||
**不变**:~~`server/api/browser.py`~~ **已删除**(2026-06-11);Worker 方案将新建 `browser_session` API,不复用 iframe 状态接口。
|
||||
**不修改**:`Dockerfile.prod`(主站)
|
||||
|
||||
## 实现步骤
|
||||
|
||||
### Phase W0 — 你购机(并行)
|
||||
|
||||
按 procurement 文档:**2C4G+、Ubuntu 22.04/24.04、防火墙 8443 仅 Nexus IP**。
|
||||
|
||||
### Phase W1 — Browser Worker 服务
|
||||
|
||||
1. `browser-worker/` 独立 FastAPI:`GET /health`、`POST /v1/sessions`、`DELETE`、`WS /v1/sessions/{id}/stream`
|
||||
2. Header `X-Nexus-Worker-Key` 校验
|
||||
3. Playwright Chromium + CDP screencast + 键鼠
|
||||
4. SSRF 在 Worker 侧强制执行
|
||||
|
||||
### Phase W2 — Nexus 桥接
|
||||
|
||||
1. `worker_client.py`:创建/销毁会话、WS 双向转发
|
||||
2. `browser_session.py`:管理员 JWT WS ↔ Worker WS
|
||||
3. `NEXUS_BROWSER_ENABLED=0` → 503
|
||||
|
||||
### Phase W3 — 前端
|
||||
|
||||
1. `useRemoteBrowserSession.ts` + canvas
|
||||
2. `GlobalBrowserPanel` 替换 iframe
|
||||
3. 503 友好提示
|
||||
|
||||
### Phase W4 — 联调(需 Worker IP)
|
||||
|
||||
1. Worker 部署 + ufw
|
||||
2. Nexus `.env` 配 `NEXUS_BROWSER_WORKER_*`
|
||||
3. 验收表(procurement §5 Phase D)
|
||||
4. changelog + audit → 门控 → 部署
|
||||
|
||||
## 测试要点
|
||||
|
||||
```bash
|
||||
pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py -q
|
||||
cd frontend && npm run type-check
|
||||
# Worker 目录(实现后)
|
||||
cd browser-worker && pytest -q
|
||||
```
|
||||
|
||||
## 依赖与配置
|
||||
|
||||
**Nexus 主站**
|
||||
|
||||
```env
|
||||
NEXUS_BROWSER_ENABLED=1
|
||||
NEXUS_BROWSER_WORKER_URL=https://10.x.x.x:8443
|
||||
NEXUS_BROWSER_WORKER_SECRET=<shared>
|
||||
NEXUS_BROWSER_MAX_SESSIONS=2
|
||||
NEXUS_BROWSER_IDLE_TIMEOUT_SEC=1800
|
||||
```
|
||||
|
||||
**Worker `.env`**
|
||||
|
||||
```env
|
||||
WORKER_SECRET=<same-as-above>
|
||||
WORKER_BIND=0.0.0.0:8443
|
||||
WORKER_MAX_SESSIONS=2
|
||||
WORKER_IDLE_TIMEOUT_SEC=1800
|
||||
```
|
||||
|
||||
## 回滚
|
||||
|
||||
1. `NEXUS_BROWSER_ENABLED=0` + 重启 Nexus
|
||||
2. Worker 容器 `docker compose down`(主站不受影响)
|
||||
@@ -0,0 +1,27 @@
|
||||
# 子机离线告警独立 Telegram — 实施说明
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/config.py` — 新 settings 属性与 DB_OVERRIDE_MAP
|
||||
- `server/infrastructure/telegram/__init__.py` — 路由与 `send_telegram` 参数化
|
||||
- `server/api/settings.py` — MUTABLE_KEYS、离线 test/chats/reveal
|
||||
- `frontend/src/pages/SettingsPage.vue` — 离线专用 Bot UI
|
||||
- `frontend/src/types/api.ts` — 离线开关文案
|
||||
- `frontend/src/utils/auditLabels.ts`
|
||||
- `tests/test_telegram_offline_channel.py`
|
||||
|
||||
## 步骤
|
||||
|
||||
1. 后端配置与发送路由
|
||||
2. 设置 API 与白名单
|
||||
3. 前端设置页「子机离线专用 Bot」区块
|
||||
4. pytest + local_verify
|
||||
|
||||
## 回滚
|
||||
|
||||
清空 `telegram_offline_*` 设置或删除 DB 行;离线告警自动回退默认 Bot。
|
||||
|
||||
## 测试要点
|
||||
|
||||
- `resolve_offline_telegram_credentials` 优先离线、回退主 Bot
|
||||
- `send_telegram_offline_alert` 传入正确 token/chat(mock httpx)
|
||||
@@ -0,0 +1,112 @@
|
||||
# 内置浏览器 — 网站导航(收藏)设计说明
|
||||
|
||||
> 关联:[2026-06-11-embedded-browser-design.md](./2026-06-11-embedded-browser-design.md) · [2026-06-11-global-floating-browser 变更](../../changelog/2026-06-11-global-floating-browser.md)
|
||||
|
||||
## 背景与目标
|
||||
|
||||
用户希望在 Nexus **全局浮动浏览器**内维护一份**自定义网站导航**(类似浏览器收藏夹),便于反复打开常用运维站点(监控、面板、客户站点等),而不依赖浏览器自带书签。
|
||||
|
||||
### 用户原话(2026-06-11)
|
||||
|
||||
> 浏览器内置网站导航,我自己可以增加,类似收藏功能。
|
||||
|
||||
### 与现有能力的关系
|
||||
|
||||
| 已有 | 导航(收藏) |
|
||||
|------|----------------|
|
||||
| **浏览记录** `visits` | 被动、按访问时间倒序,不可编辑名称 |
|
||||
| **URL 历史** `url_history` | 去重 URL 列表,无自定义标题 |
|
||||
| **网站导航** `nav_links` | 用户主动添加/编辑/删除,固定名称 + URL,长期入口 |
|
||||
|
||||
三者并存:记录 ≠ 收藏;收藏用于「我关心的固定站点」。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不做浏览器引擎替换(首版 iframe;**2026-06-11 改为服务端 Chromium**,见 [server-browser-chromium-design.md](./2026-06-11-server-browser-chromium-design.md))
|
||||
- 不做 HTTP 反向代理
|
||||
- 不做跨管理员共享导航(按账号隔离,与 `admin_ui_preferences` 一致)
|
||||
- 首版不做文件夹/分组(可后续 `group` 字段扩展)
|
||||
|
||||
## 数据模型
|
||||
|
||||
存储:`admin_ui_preferences`,`context = embedded_browser`,字段 `nav_links`(JSON 数组)。
|
||||
|
||||
```json
|
||||
{
|
||||
"nav_links": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "Grafana",
|
||||
"url": "https://grafana.example.com",
|
||||
"sort_order": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
约束(后端 `browser_ui_state.py`):
|
||||
|
||||
| 字段 | 规则 |
|
||||
|------|------|
|
||||
| `id` | 非空字符串,≤64 |
|
||||
| `title` | 非空,≤80 |
|
||||
| `url` | 仅 `http`/`https`,无凭据,与现有 URL 白名单一致 |
|
||||
| 数量上限 | 64 条 |
|
||||
| `sort_order` | 整数,用于排序 |
|
||||
|
||||
API:沿用 `GET/PUT /api/browser/state`,`nav_links` 随整体 state 读写(与 tabs、visits 相同)。
|
||||
|
||||
## 交互设计
|
||||
|
||||
### 入口
|
||||
|
||||
1. 浮动浏览器工具栏:**「导航」**按钮(`mdi-star-outline`),与「浏览记录」并列
|
||||
2. 侧栏 Tab:**网站导航** | **浏览记录**(同一侧栏区域切换,避免双栏占宽)
|
||||
3. 空白页:展示导航卡片网格,点击即打开
|
||||
|
||||
### 用户操作
|
||||
|
||||
| 操作 | 行为 |
|
||||
|------|------|
|
||||
| 添加 | 对话框:名称 + URL;可预填当前地址栏 / 当前页 URL |
|
||||
| 编辑 | 同对话框,改名称或 URL |
|
||||
| 删除 | 列表项菜单或删除图标,需确认(可选) |
|
||||
| 打开 | 点击条目 → 当前标签导航到该 URL |
|
||||
| 从当前页收藏 | 地址栏旁「星标」一键添加(已存在则提示或进入编辑) |
|
||||
|
||||
### 持久化
|
||||
|
||||
- 保存至 MySQL,换设备/清 localStorage 不丢
|
||||
- 与浏览记录、标签、窗口位置同一 debounce 写入策略
|
||||
|
||||
## 安全
|
||||
|
||||
- URL 校验复用 `_is_safe_url`,拒绝 `javascript:`、`data:` 等
|
||||
- 无服务端 fetch,无 SSRF
|
||||
- 按 `admin_id` 隔离
|
||||
|
||||
## 内核与风控说明(用户关切)
|
||||
|
||||
内置浏览器 **不是** 独立 Firefox/Chrome 安装包,而是 **iframe + 用户当前浏览器引擎**。
|
||||
|
||||
- User-Agent 与普通浏览器一致,**不会因 Nexus 被单独判为机器人**
|
||||
- 部分站点禁止 iframe 或要求顶层窗口 → 仍用「新标签打开」
|
||||
- 收藏导航仅保存 URL,不改变加载方式
|
||||
|
||||
## 实现状态(2026-06-11)
|
||||
|
||||
| 层 | 状态 |
|
||||
|----|------|
|
||||
| 后端 `nav_links` 解析 / API 字段 | ✅ 已入代码(待前端联调) |
|
||||
| 前端侧栏 + 增删改 UI | ⏳ 待实现 |
|
||||
| 单测 `nav_links` 解析 | ✅ `tests/test_browser_ui_state.py::test_parse_nav_links` |
|
||||
| 部署 | ⏳ 前端完成后一并门控 |
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 可添加、编辑、删除导航项,刷新后仍在
|
||||
- [ ] 点击导航项在当前标签打开对应 https 站点
|
||||
- [ ] 可从当前页一键加入收藏
|
||||
- [ ] 非法 URL 拒绝并提示
|
||||
- [ ] 不同管理员账号导航互不可见
|
||||
- [ ] 与浏览记录、标签、最小化状态可同时正常工作
|
||||
@@ -12,7 +12,11 @@
|
||||
| HTTP 反向代理 | 可绕过 frame 限制 | SSRF 风险、需维护代理与 Cookie |
|
||||
| 仅外链新标签 | 零风险 | 非「内置」 |
|
||||
|
||||
**选定**:iframe + 地址栏 + 新标签兜底;首版不做 HTTP 代理。
|
||||
**选定(2026-06-11 前)**:iframe + 地址栏 + 新标签兜底;首版不做 HTTP 代理。
|
||||
|
||||
> **2026-06-11 变更**:用户拒绝 iframe 方案,改为 **服务端 Playwright Chromium + WebSocket 画面流**。
|
||||
> 见 [2026-06-11-server-browser-chromium-design.md](./2026-06-11-server-browser-chromium-design.md)。
|
||||
> 本文档仍有效部分:路由入口、URL 白名单原则、UI 状态 API;**iframe 渲染已废弃**。
|
||||
|
||||
## 接口与路由
|
||||
|
||||
@@ -28,7 +32,9 @@
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 侧栏「浏览器」进入全屏 iframe 页
|
||||
- [x] 全局浮动面板、最小化可拖、不可关闭(见 global-floating-browser changelog)
|
||||
- [ ] 侧栏「浏览器」或左下角入口打开浮动窗
|
||||
- [ ] 地址栏输入 URL 可导航;刷新 / 新标签打开
|
||||
- [ ] 服务器列表「站点」跳转并加载域名
|
||||
- [ ] 非法 URL 拒绝并提示
|
||||
- [ ] **网站导航(收藏)** — 见 [browser-nav-bookmarks-design.md](./2026-06-11-browser-nav-bookmarks-design.md)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
# 服务端远程浏览器(Playwright Chromium)— 设计说明
|
||||
|
||||
> **取代** iframe 渲染方案([2026-06-11-embedded-browser-design.md](./2026-06-11-embedded-browser-design.md) 中的页面加载方式)。
|
||||
> UI 状态(标签、收藏 `nav_links`、窗口位置)仍走 `/api/browser/state`,不变。
|
||||
|
||||
## 背景与目标
|
||||
|
||||
运维需要在 Nexus 浮动面板内**完整浏览任意公网站点**,不受 `X-Frame-Options` / CSP `frame-ancestors` 限制,且使用**服务端独立浏览器内核**,而非用户本机标签页内的 iframe。
|
||||
|
||||
### 用户确认(2026-06-11)
|
||||
|
||||
| 决策项 | 选择 |
|
||||
|--------|------|
|
||||
| 可访问范围 | **任意公网** `http` / `https` |
|
||||
| 内核 | **Chromium**(Playwright),不要求 Firefox |
|
||||
| 交互 | 在 Nexus 面板内看画面 + 键鼠操作(非「新标签打开」为主) |
|
||||
|
||||
### 与 iframe 方案对比
|
||||
|
||||
| | iframe(现状) | 服务端 Chromium(目标) |
|
||||
|--|----------------|---------------------------|
|
||||
| 渲染位置 | 用户浏览器 | Nexus 服务器 |
|
||||
| 站点嵌入限制 | 常被拒绝 | 无(顶层窗口) |
|
||||
| SSRF 风险 | 无 | **有,必须缓解** |
|
||||
| 资源 | 几乎无 | 每会话 ~200–500MB RAM |
|
||||
| Cookie / 登录 | 用户本机 | **服务器浏览器 Profile** |
|
||||
|
||||
## 方案概述
|
||||
|
||||
类比 RDP(guacd → WebSocket → 前端画布),但 Nexus **拥有**浏览器进程与协议:
|
||||
|
||||
```
|
||||
管理员浏览器 (Vue SPA)
|
||||
│ JWT WebSocket + REST
|
||||
▼
|
||||
Nexus API (FastAPI) — 桥接,不跑 Chromium
|
||||
│ Worker Key + 内网 HTTPS
|
||||
▼
|
||||
Browser Worker VPS — Playwright Chromium(headless + CDP screencast)
|
||||
▼
|
||||
公网目标站点
|
||||
```
|
||||
|
||||
| 组件 | 职责 |
|
||||
|------|------|
|
||||
| **Playwright Chromium** | 运行在 **Browser Worker**;Nexus 桥接会话 |
|
||||
| **URL 安全网关** | 导航前 DNS 解析 + IP 段校验,阻断私网/元数据 SSRF |
|
||||
| **`/ws/browser/{session_id}`** | 推送 JPEG 帧;接收 navigate / 键鼠 / 滚动 |
|
||||
| **`POST/DELETE /api/browser/sessions`** | 创建、销毁会话 |
|
||||
| **`GlobalBrowserPanel`** | `<canvas>` 替代 `<iframe>`;保留地址栏、标签、收藏、最小化 |
|
||||
| **`/api/browser/state`** | 不变 — tabs、visits、nav_links、rect |
|
||||
|
||||
**刻意不做(首版)**
|
||||
|
||||
- 不做 HTTP 反向代理(HTML 改写 / Cookie 注入)
|
||||
- 不做浏览内容审计或页面录制落库
|
||||
- 不做多管理员并发压测级扩展(单人运维,全局并发 ≤2)
|
||||
- 不做本机 Cookie 与服务器 Profile 双向同步
|
||||
|
||||
## WebSocket 协议(草案)
|
||||
|
||||
鉴权:与终端/RDP 一致,query `?token=` = 常规 access JWT(`sub` = admin_id),且 `session` 归属该校验通过的管理员。
|
||||
|
||||
### 服务端 → 客户端
|
||||
|
||||
| type | 字段 | 说明 |
|
||||
|------|------|------|
|
||||
| `BROWSER_INIT` | `session_id`, `viewport_w`, `viewport_h` | 连接就绪 |
|
||||
| `SCREENCAST_FRAME` | `data_b64`, `metadata` | CDP screencast JPEG |
|
||||
| `PAGE_INFO` | `url`, `title` | 导航完成或标题变化 |
|
||||
| `ERROR` | `message`, `code` | URL 拒绝、超时、Playwright 错误 |
|
||||
| `PONG` | — | 心跳 |
|
||||
|
||||
### 客户端 → 服务端
|
||||
|
||||
| type | 字段 | 说明 |
|
||||
|------|------|------|
|
||||
| `NAVIGATE` | `url` | 经 SSRF 校验后 `page.goto` |
|
||||
| `RELOAD` / `GO_BACK` / `GO_FORWARD` | — | 历史导航 |
|
||||
| `MOUSE` | `event`, `x`, `y`, `button` | move / down / up / wheel |
|
||||
| `KEY` | `event`, `key`, `code`, `text` | down / up / type |
|
||||
| `VIEWPORT` | `width`, `height` | 面板尺寸变化 |
|
||||
| `PING` | — | 心跳 |
|
||||
|
||||
坐标:`x/y` 为相对 viewport 的 CSS 像素,与 screencast 帧尺寸一致。
|
||||
|
||||
## SSRF 防护(任意公网)
|
||||
|
||||
导航与每次 redirect 后 **重新校验** 最终 URL:
|
||||
|
||||
1. 仅 `http:` / `https:`;拒绝凭据、`javascript:`、`data:`、`file:`
|
||||
2. 解析 hostname → `getaddrinfo` → **所有**解析 IP 必须不在阻断段:
|
||||
- `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`
|
||||
- `169.254.0.0/16`, `0.0.0.0/8`, `100.64.0.0/10`
|
||||
- IPv6: `::1`, `fc00::/7`, `fe80::/10`
|
||||
3. 阻断常见 metadata 主机名(如 `169.254.169.254`、`*metadata*` 字面)
|
||||
4. 导航超时 30s;单页资源不限制由 Chromium 自身处理
|
||||
5. 失败时 WS 返回 `ERROR`,**不**静默降级
|
||||
|
||||
> 单人运维下 residual risk:DNS rebinding 需 TTL 短重查或 redirect 链逐跳校验;首版采用 **redirect 监听 + 每跳校验**。
|
||||
|
||||
## 会话与资源
|
||||
|
||||
| 策略 | 值 |
|
||||
|------|-----|
|
||||
| 每管理员活跃会话 | 1 |
|
||||
| 全局活跃会话 | ≤2 |
|
||||
| 空闲超时 | 30 min 无 WS 活动 → 关闭 browser context |
|
||||
| 视口默认 | 1280×720(可随面板 resize) |
|
||||
| Screencast | CDP `Page.startScreencast`, JPEG quality ~80 |
|
||||
|
||||
进程模型:单例 Playwright `Browser` 进程 + 每会话独立 `BrowserContext`(隔离 Cookie)。
|
||||
|
||||
## 部署(选定:独立 Worker 服务器)
|
||||
|
||||
> **购机与装机计划**:[`2026-06-11-browser-worker-server-procurement.md`](../plans/2026-06-11-browser-worker-server-procurement.md)
|
||||
|
||||
Playwright Chromium **不装入 Nexus 主站镜像**,而在**单独 VPS(Browser Worker)**运行;Nexus 主站仅桥接 WebSocket。
|
||||
|
||||
```
|
||||
管理员浏览器 → Nexus API(JWT)→ Browser Worker(Worker Key)→ 公网
|
||||
```
|
||||
|
||||
| 组件 | 部署位置 |
|
||||
|------|----------|
|
||||
| `browser-worker/` 服务 + Chromium | **Worker VPS**(2C4G 起) |
|
||||
| `/api/browser/sessions`、`/ws/browser/*` 桥接 | **Nexus 主站** |
|
||||
| MySQL UI 状态 | Nexus 主站(不变) |
|
||||
|
||||
**Nexus 主站 `.env`**
|
||||
|
||||
```env
|
||||
NEXUS_BROWSER_ENABLED=1
|
||||
NEXUS_BROWSER_WORKER_URL=https://<worker-internal-or-vpn>:8443
|
||||
NEXUS_BROWSER_WORKER_SECRET=<shared-secret>
|
||||
NEXUS_BROWSER_MAX_SESSIONS=2
|
||||
NEXUS_BROWSER_IDLE_TIMEOUT_SEC=1800
|
||||
```
|
||||
|
||||
**Worker 防火墙**:8443 **仅** Nexus 主站 IP;不对公网开放 Worker API。
|
||||
|
||||
**降级**:`NEXUS_BROWSER_ENABLED=0` 或 Worker 不可达 → `POST /api/browser/sessions` **503**;前端提示「服务端浏览器未启用」。
|
||||
|
||||
~~`Dockerfile.prod` 增加 Chromium~~ — **Worker 模式不修改主站镜像体积**。
|
||||
|
||||
## 前端变更
|
||||
|
||||
- `GlobalBrowserPanel.vue`:`<iframe>` → `<canvas>` + `useRemoteBrowserSession.ts`
|
||||
- 保留:浮动/最小化/不可关闭/重启、标签、历史、收藏(nav_links 待做 UI)
|
||||
- 移除或隐藏:「新标签打开」可保留作 **导出 URL** 辅助,非主路径
|
||||
- `useGlobalBrowser.ts`:`navigate` 改为 WS `NAVIGATE`;`frameKey` 不再需要
|
||||
|
||||
## 安全与隐私
|
||||
|
||||
- 页面内容经 JPEG 帧过 Nexus,**不持久化**截图
|
||||
- 服务器 Chromium 内 Cookie 存于容器 ephemeral context(重启会话丢失)
|
||||
- 仅 `get_current_admin` 可创建/连接会话
|
||||
- CUD:创建/销毁会话可选写 audit(首版 **不做**,与 RDP 无连接审计一致)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 打开 `https://example.com` 等公网站,面板 canvas 有画面且可点击链接
|
||||
- [ ] 曾 iframe 白屏的站点(如带 `X-Frame-Options: DENY` 的站)可正常显示
|
||||
- [ ] 访问 `http://127.0.0.1` / `http://10.x` / metadata → 明确 ERROR,不发起请求
|
||||
- [ ] 刷新页面后会话内 Cookie 保留;重启浏览器会话 Cookie 清空
|
||||
- [ ] 标签、收藏、窗口位置仍持久化到 `/api/browser/state`
|
||||
- [ ] `NEXUS_BROWSER_ENABLED=0` 时有可读降级提示
|
||||
- [ ] Worker VPS 部署完成,防火墙仅 Nexus 主站可连
|
||||
|
||||
## 回滚
|
||||
|
||||
- 设 `NEXUS_BROWSER_ENABLED=0` → 503,前端可回退 iframe 分支(保留 feature flag 一段过渡期)
|
||||
- 卸载 Playwright 依赖仅当确认不再使用
|
||||
@@ -0,0 +1,41 @@
|
||||
# 子机离线告警独立 Telegram 通道 — 设计
|
||||
|
||||
**日期**: 2026-06-13
|
||||
**状态**: 已实现
|
||||
|
||||
## 背景与目标
|
||||
|
||||
运维希望将「子机离线」告警与 CPU/内存等资源告警分流:离线通知可发到专用群/专用 Bot,避免主告警群被大量离线消息淹没。
|
||||
|
||||
## 方案对比
|
||||
|
||||
| 方案 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| A. 新建独立 settings 键 + 发送时路由 | 改动小;与现有 Telegram 配置一致 | 需多配一组 Token/Chat |
|
||||
| B. 按服务器标签/项目路由 | 更细粒度 | 复杂度高,当前无项目维度 |
|
||||
| C. 多 Bot 列表 + 规则引擎 | 可扩展 | 过度设计 |
|
||||
|
||||
**选定 A**:`telegram_offline_bot_token` + `telegram_offline_chat_id`;二者均配置时离线告警走专用通道,否则回退默认 Bot。
|
||||
|
||||
## 数据与接口
|
||||
|
||||
| settings 键 | Settings 属性 | 说明 |
|
||||
|-------------|---------------|------|
|
||||
| `telegram_offline_bot_token` | `TELEGRAM_OFFLINE_BOT_TOKEN` | 敏感,GET 掩码 |
|
||||
| `telegram_offline_chat_id` | `TELEGRAM_OFFLINE_CHAT_ID` | 明文 |
|
||||
|
||||
- `send_telegram_offline_alert` → `resolve_offline_telegram_credentials()` → `send_telegram(..., bot_token, chat_id)`
|
||||
- API:`PUT /settings/{key}`、`POST /settings/telegram/offline/test`、`GET /settings/telegram/offline/chats`、`POST /settings/telegram/offline/reveal-token`
|
||||
- Layer 3 巡检仍仅用默认 `telegram_bot_token`(不同步离线 Bot)
|
||||
|
||||
## 安全
|
||||
|
||||
- Token 入 `SENSITIVE_KEYS`;reveal 写审计
|
||||
- 不改变 `notify_alert_offline` 开关语义
|
||||
|
||||
## 验收
|
||||
|
||||
1. 配置离线专用 Bot+Chat 后,子机离线 Telegram 发到专用群
|
||||
2. 未配置离线专用时,离线告警仍走默认 Bot
|
||||
3. CPU/内存等资源告警始终走默认 Bot
|
||||
4. 设置页可保存、检测 Chat ID、发送测试消息
|
||||
@@ -65,3 +65,32 @@ bash scripts/local_verify.sh # Docker + API + smoke + test_api + ruff
|
||||
```
|
||||
|
||||
文档:`docs/project/local-integration-test.md`(已取代 WSL 脚本)
|
||||
|
||||
---
|
||||
|
||||
## 待办 — 服务端远程浏览器(Worker 分离)
|
||||
|
||||
**用户确认**:任意公网;Chromium;**独立 Worker 服务器**(用户去购机)。
|
||||
|
||||
| 文档 | 路径 |
|
||||
|------|------|
|
||||
| 设计 | `docs/design/specs/2026-06-11-server-browser-chromium-design.md` |
|
||||
| 代码实施 | `docs/design/plans/2026-06-11-server-browser-chromium.md` |
|
||||
| **购机/装机/联调** | **`docs/design/plans/2026-06-11-browser-worker-server-procurement.md`** |
|
||||
|
||||
**进度**:iframe 内置浏览器 **已移除**(changelog `2026-06-11-remove-embedded-browser`);待购 Worker 后按 W1~W4 重建 canvas 浏览器。
|
||||
|
||||
**购机规格速记**:2C4G 起(推荐 4C8G)· Ubuntu 22.04/24.04 · Worker 8443 仅允许 Nexus 主站 `20.24.218.235` 访问。
|
||||
|
||||
---
|
||||
|
||||
## 待办 — 内置浏览器网站导航(收藏)
|
||||
|
||||
**用户原话**:浏览器内置网站导航,我自己可以增加,类似收藏功能。
|
||||
|
||||
| 文档 | 路径 |
|
||||
|------|------|
|
||||
| 设计 | `docs/design/specs/2026-06-11-browser-nav-bookmarks-design.md` |
|
||||
| 实施计划 | `docs/design/plans/2026-06-11-browser-nav-bookmarks.md` |
|
||||
|
||||
**进度**:随 iframe 浏览器一并移除;Worker 方案上线时重做。
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# 文件管理移除下载 — 生产验证(2026-06-12)
|
||||
|
||||
## 变更
|
||||
|
||||
- Commit `2b0413c`:前端移除行内/右键「下载」入口;后端 `/sync/download` 保留。
|
||||
|
||||
## 验证
|
||||
|
||||
| 项 | 结果 |
|
||||
|----|------|
|
||||
| 门控 | `pre_deploy_check.sh` 7/7 PASS(含 browser 合并提交后) |
|
||||
| 部署 | `NEXUS_DEPLOY_PATH=/opt/nexus deploy-production.sh` 成功 |
|
||||
| `/health` | `ok` |
|
||||
| 主 bundle | `/app/assets/index-aYJp0WL5.js` HTTP 200 |
|
||||
| FilesPage 最新 chunk | `FilesPage-BUUVZpbu.js`:`下载` / `mdi-download` 出现次数 **0** |
|
||||
|
||||
## 说明
|
||||
|
||||
容器内仍保留历史 hash 的 `FilesPage-*.js`(prune 未全删);当前入口引用的最新 chunk 已无下载文案与图标。
|
||||
|
||||
## 用户终验
|
||||
|
||||
1. 强刷 `https://api.synaglobal.vip/app/#/files`
|
||||
2. 确认列表行与右键菜单无「下载」
|
||||
@@ -0,0 +1,107 @@
|
||||
# Bug 巡查 — 远程浏览器
|
||||
|
||||
**日期**:2026-06-12
|
||||
**范围**:`browser-worker/`、桥接 API、前端 canvas 客户端、生产 `66.154.115.131:8443`
|
||||
**方法**:代码走读 + 单测 + 生产探针复验
|
||||
**关联**:`docs/audit/2026-06-12-remote-browser-worker.md`、`docs/reports/2026-06-12-remote-browser-patrol.md`
|
||||
|
||||
## 发现与处理
|
||||
|
||||
| # | 严重度 | 问题 | 根因 | 状态 |
|
||||
|---|--------|------|------|------|
|
||||
| 1 | **P0** | 生产无浏览器 UI,用户「连不上」 | 后端 `BROWSER_ENABLED=1`,但 `web/app` **无** `ws/browser` bundle | **已修** — `index-BimRgA6s.js` 已同步,容器 grep `ws/browser` ≥1 |
|
||||
| 2 | **P1** | Worker `sessions:1` 长期占槽 | `ready=false` 不进 idle 清理 | **已修** — `WORKER_RESERVE_TIMEOUT_SEC=120` + 重建 Worker,`sessions:0` |
|
||||
| 3 | **P1** | attach 失败留下僵尸 reservation | `RuntimeError` 未 `destroy_session` | **已修** — `main.py` 失败路径销毁 |
|
||||
| 4 | **P1** | 页内点击/重定向 SSRF | 仅 `NAVIGATE` 校验;`RELOAD`/`GO_BACK`/`页内导航` 无复验 | **未修**(审计 F1) |
|
||||
| 5 | **P1** | Worker 8443 公网暴露 `/health` | ufw 过宽 | **未修**(审计 F3) |
|
||||
| 6 | **P2** | 前进/后退与远程历史不同步 | UI `stack` 仅本地维护;`updateActiveFromRemote` **不**更新 stack;canvas 内点击只改 `url` | **未修** |
|
||||
| 7 | **P2** | `connect()` 失败后无显式 DELETE | 同上 | **已修** — WS/未登录失败时 `DELETE /browser/sessions/{id}` |
|
||||
| 8 | **P2** | `attach_stream` 并发竞态 | `ready=false` 时锁在 L140 释放,双 WS 可能各建 `BrowserContext` | 低概率(单桥接) |
|
||||
| 9 | **P2** | `delete_worker_session` 失败静默 | 日志 warning,Worker 会话残留 | **未修**(审计 F4) |
|
||||
| 10 | P3 | 切标签每次新建 Worker 会话 | `watch(activeTabId)` → `disconnect()` → 新 `POST` | 设计可接受,略耗资源 |
|
||||
| 11 | P3 | `persistState` catch 空实现 | 离线时本地状态与 MySQL 可能短暂不一致 | 可接受 |
|
||||
|
||||
## P0 — 前端未部署(用户可见)
|
||||
|
||||
```
|
||||
生产容器: BROWSER_ENABLED=1
|
||||
web/app/assets: grep ws/browser → 0 文件
|
||||
Worker /health: sessions=1, max_sessions=2
|
||||
```
|
||||
|
||||
管理员打开面板**没有任何**地球按钮 / canvas 客户端代码,与「连不上」完全一致。后端与 Worker 已就绪,**阻塞在 SPA 同步**。
|
||||
|
||||
## P1 — Worker 僵尸会话(占槽根因)
|
||||
|
||||
### 空闲清理漏掉 `ready=false`
|
||||
|
||||
```100:103:browser-worker/session_manager.py
|
||||
stale = [
|
||||
sid for sid, sess in self._sessions.items()
|
||||
if sess.ready and now - sess.last_activity > settings.WORKER_IDLE_TIMEOUT_SEC
|
||||
]
|
||||
```
|
||||
|
||||
`POST /v1/sessions` 只 `reserve_session`;Chromium 在 WS `attach_stream` 后才 `ready=True`。
|
||||
任一路径「POST 成功但 stream 未建立」都会永久占 `session_count`,直至 `max_sessions` 打满。
|
||||
|
||||
典型路径:
|
||||
|
||||
- 前端未部署时 API 测试 / 桥接探测只 POST 不挂 WS
|
||||
- Nexus 桥接连 Worker WS 失败(`WORKER_UNREACHABLE`)
|
||||
- Worker `attach_stream` 抛 `RuntimeError` 后:
|
||||
|
||||
```110:112:browser-worker/main.py
|
||||
except RuntimeError:
|
||||
await websocket.close(code=4503, reason="Browser not ready")
|
||||
return
|
||||
```
|
||||
|
||||
此处 **未** `destroy_session`,reservation 泄漏。
|
||||
|
||||
**建议**:idle 对 `not ready` 加 `created_at` 超时(如 120s);`RuntimeError`/`attach` 失败路径 `destroy_session`;运维可 `docker restart nexus-browser-worker` 清槽。
|
||||
|
||||
## P1 — SSRF / 暴露面(审计复查)
|
||||
|
||||
与 `docs/audit/2026-06-12-remote-browser-worker.md` 一致,代码层**无新增修复**。
|
||||
|
||||
## P2 — 前进/后退逻辑 Bug
|
||||
|
||||
`GlobalBrowserPanel.onGoBack`:
|
||||
|
||||
```350:353:frontend/src/components/GlobalBrowserPanel.vue
|
||||
function onGoBack() {
|
||||
goBack() // 改本地 stack + addressDraft
|
||||
if (connected.value) remoteGoBack() // Playwright page.go_back()
|
||||
}
|
||||
```
|
||||
|
||||
- 用户在 canvas 内点击链接 → `PAGE_INFO` → `updateActiveFromRemote` 只更新 `tab.url`,**不 push stack**
|
||||
- `canGoBack` 仍看本地 stack → 按钮状态错误
|
||||
- 点后退时本地 stack 与 Chromium 历史**可能指向不同 URL**
|
||||
|
||||
**建议**:以远程为准时去掉本地 stack,或 `PAGE_INFO` 时同步 push stack;后退只发 `GO_BACK` 不回写本地 stack 直到 `PAGE_INFO`。
|
||||
|
||||
## 本地验证
|
||||
|
||||
```text
|
||||
pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py → 7 passed
|
||||
npm run type-check → PASS
|
||||
curl https://api.synaglobal.vip/health → ok
|
||||
```
|
||||
|
||||
## 建议修复顺序
|
||||
|
||||
1. **P0** — `vite build` + `sync_webapp_to_container.sh`(立刻恢复可用性)
|
||||
2. **P1** — Worker 重启清槽 + 修 reservation 泄漏(idle 未 ready + RuntimeError 清理)
|
||||
3. **P1** — ufw 收口 8443
|
||||
4. **P1** — `framenavigated` SSRF
|
||||
5. **P2** — 历史栈与 `GO_BACK` 统一
|
||||
|
||||
## 复巡命令
|
||||
|
||||
```bash
|
||||
ssh nexus 'sudo docker exec nexus-prod-nexus-1 grep -rl ws/browser /app/web/app/assets/ | wc -l'
|
||||
curl -s http://66.154.115.131:8443/health
|
||||
pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py -q
|
||||
```
|
||||
@@ -0,0 +1,90 @@
|
||||
# 巡查 — 远程浏览器 + 生产基线
|
||||
|
||||
**日期**:2026-06-12
|
||||
**范围**:生产 `api.synaglobal.vip`、Worker `66.154.115.131:8443`、工作区浏览器改动、审计 F1/F3 闭环
|
||||
**关联审计**:`docs/audit/2026-06-12-remote-browser-worker.md`
|
||||
|
||||
## 进度条
|
||||
|
||||
| 步骤 | 状态 |
|
||||
|------|------|
|
||||
| 生产 `/health` + prod_probe | ✅ 全通过 |
|
||||
| 浏览器后端配置 | ✅ `BROWSER_ENABLED=1`,Worker URL 已配 |
|
||||
| 浏览器前端 bundle | ❌ **未部署** |
|
||||
| Worker 可达 / 暴露面 | ⚠️ 公网可访问 `/health` |
|
||||
| 审计 P1 闭环 | ❌ F1 SSRF、F3 防火墙未修 |
|
||||
| 工作区 git | ⚠️ 浏览器源码 largely 未 commit |
|
||||
| 本地单测 | ✅ browser 7 + watch_pins 9 = 16 passed |
|
||||
|
||||
## 生产探针(`scripts/prod_health_check.sh`)
|
||||
|
||||
```text
|
||||
✓ external /health → ok
|
||||
✓ external /app/ → HTTP 200
|
||||
✓ login / health/detail / alert-history / schedules / cron / main bundle
|
||||
=== All production probe checks passed ===
|
||||
```
|
||||
|
||||
容器:`nexus-prod-nexus-1` **Up ~4min (healthy)**(巡查时刚重启不久)。
|
||||
|
||||
## 远程浏览器链路
|
||||
|
||||
| 检查项 | 结果 | 说明 |
|
||||
|--------|------|------|
|
||||
| 主站 `/health` | `ok` | 公网 + 容器内均 OK |
|
||||
| 容器 `settings.BROWSER_ENABLED` | `1` | Worker URL `http://66.154.115.131:8443` |
|
||||
| 主站 → Worker `/health` | `ok` | 容器内 curl 成功 |
|
||||
| 公网 → Worker `/health` | **可达** | `{"status":"ok","sessions":1,"max_sessions":2}` |
|
||||
| 无 Key `POST /v1/sessions` | `422` | 非 401,但无密钥无法建会话 |
|
||||
| 生产 `web/app` 含 `GlobalBrowser` / `ws/browser` / screencast | **无** | 146 个 JS,grep 零命中 |
|
||||
| 侧栏「浏览器」/ 地球按钮 UI | **未上线** | 需 `vite build` + `sync_webapp_to_container.sh` |
|
||||
|
||||
**结论**:后端与 Worker **已启用**,但 **SPA 未同步**,管理员面板仍无远程浏览器入口;与「连不上」用户反馈一致(旧 bundle 无 canvas 客户端)。
|
||||
|
||||
## 审计项复查(未闭环)
|
||||
|
||||
| ID | 审计结论 | 巡查结果 |
|
||||
|----|----------|----------|
|
||||
| **F1** | 页内点击/重定向无 SSRF 复验 | **未修** — `session_manager.py` 仅 `NAVIGATE` 调 `validate_navigation_url` |
|
||||
| **F3** | Worker `ufw` 过宽 | **未确认修** — 公网 curl `/health` 成功,8443 仍暴露;Worker SSH 本机无凭据 |
|
||||
| F4 | delete 失败静默 | 未改 |
|
||||
| F5 | 内存 session_registry | 可接受(单进程 Docker) |
|
||||
|
||||
Worker 当前 **`sessions:1`**:可能存在陈旧会话占槽(`max_sessions=2`),建议 Worker 侧重启或调 API 清理。
|
||||
|
||||
## 工作区状态
|
||||
|
||||
浏览器相关文件多为 **未跟踪 / 未提交**(`browser-worker/`、`GlobalBrowserPanel.vue`、`browser_session.py` 等)。生产后端已跑新镜像,但 **前端构建产物未进容器**,形成 **前后端版本错位**。
|
||||
|
||||
本地:
|
||||
|
||||
```bash
|
||||
pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py tests/test_watch_pins.py -q
|
||||
→ 16 passed
|
||||
```
|
||||
|
||||
## 发现汇总
|
||||
|
||||
| # | 严重度 | 问题 | 建议 |
|
||||
|---|--------|------|------|
|
||||
| 1 | **P0 体验** | 前端未部署,用户无法使用浏览器 | `cd frontend && npx vite build` → `deploy/sync_webapp_to_container.sh` |
|
||||
| 2 | **P1 安全** | Worker 8443 + `/health` 公网可见 | 收紧 ufw 仅 `20.24.218.235`;`/health` 加 Key 或禁公网 |
|
||||
| 3 | **P1 安全** | F1 页内导航 SSRF 缺口 | `framenavigated` 逐跳校验 |
|
||||
| 4 | P2 | Worker `sessions:1` 占槽 | 清理会话或等待 idle 超时 |
|
||||
| 5 | P2 | 源码未 commit | 部署稳定后 `git add` 源码+文档(不含 `web/app/assets` 洪流) |
|
||||
|
||||
## 建议执行顺序
|
||||
|
||||
1. **同步前端**(解除 P0)
|
||||
2. **收紧 Worker 防火墙**(F3)
|
||||
3. **实现 F1** 后复审计
|
||||
4. **commit** 源码与 changelog/audit/report
|
||||
|
||||
## 验证命令(复巡)
|
||||
|
||||
```bash
|
||||
bash scripts/prod_health_check.sh
|
||||
ssh nexus 'sudo docker exec nexus-prod-nexus-1 python3 -c "from server.config import settings; print(settings.BROWSER_ENABLED)"'
|
||||
ssh nexus 'sudo docker exec nexus-prod-nexus-1 grep -rl ws/browser /app/web/app/assets/ | head -1'
|
||||
curl -s http://66.154.115.131:8443/health # 修 F3 后应仅主站可达
|
||||
```
|
||||
@@ -0,0 +1,30 @@
|
||||
# 远程浏览器 Worker 部署验证 — 2026-06-12
|
||||
|
||||
## 环境
|
||||
|
||||
| 角色 | 地址 |
|
||||
|------|------|
|
||||
| Nexus 主站 | `20.24.218.235` / `api.synaglobal.vip` |
|
||||
| Browser Worker | `66.154.115.131:8443`(主机名 mqtele,约 8GB RAM) |
|
||||
|
||||
## Worker
|
||||
|
||||
| 检查 | 结果 |
|
||||
|------|------|
|
||||
| `curl http://66.154.115.131:8443/health`(从主站) | `{"status":"ok","sessions":0,"max_sessions":2}` |
|
||||
| 容器 | `nexus-browser-worker` running |
|
||||
| ufw | `8443` 已对 `20.24.218.235` 放行 |
|
||||
|
||||
## Nexus API
|
||||
|
||||
| 检查 | 结果 |
|
||||
|------|------|
|
||||
| `GET /api/browser/status` | `enabled: true`, `worker_ok: true` |
|
||||
| `POST /api/browser/sessions` | 返回 `session_id`(已测后删除) |
|
||||
| 前端 `web/app` 同步 | `sync_webapp_to_container.sh` 入口 bundle HTTP 200 |
|
||||
|
||||
## 待用户终验
|
||||
|
||||
- [ ] 登录后台 → 左下角浏览器 → 打开 `https://example.com` 有画面且可点击
|
||||
- [ ] 服务器列表「站点」打开浮动窗(非新标签)
|
||||
- [ ] 输入 `http://127.0.0.1` 显示明确错误
|
||||
@@ -0,0 +1,42 @@
|
||||
# 生产验证 — 监测槽分钟时长 + 指标进度条 + 负载
|
||||
|
||||
**日期**:2026-06-12
|
||||
**Commit**:`647ebcb` — feat(watch): 监测槽分钟时长档、指标进度条与负载显示
|
||||
**环境**:https://api.synaglobal.vip
|
||||
|
||||
## 部署
|
||||
|
||||
| 项 | 结果 |
|
||||
|----|------|
|
||||
| push | `4c38bc0..647ebcb main → main` |
|
||||
| deploy-production | ✓ Docker 重建 + `ServersPage-CfEy_-BY.js` 同步 |
|
||||
| `/health` | ok |
|
||||
| `/app/` | 200 |
|
||||
|
||||
## API 终验(生产 JWT)
|
||||
|
||||
| 场景 | 结果 |
|
||||
|------|------|
|
||||
| GET `/api/watch/pins` | `ttl_minutes: 30` 字段存在 |
|
||||
| PATCH ttl 60 | `ttl_minutes: 60` |
|
||||
| PATCH ttl 30 | `ttl_minutes: 30` |
|
||||
| PATCH ttl 99 | 422 校验拒绝 |
|
||||
|
||||
## 前端 bundle
|
||||
|
||||
`ServersPage-CfEy_-BY.js` 含:`30分` `1时` `2时` `8时` `24时` `默认 30 分` `内存` `硬盘` `负载` `watch-ttl` `WatchMetricBar`
|
||||
|
||||
## 浏览器 E2E(Playwright)
|
||||
|
||||
`e2e/pages/watch-slot-ttl.spec.mjs` — **1 passed (7.3s)**
|
||||
|
||||
- 服务器页可见「默认 30 分」与五档时长 chip
|
||||
- 开启监测后可见「内存」「硬盘」「负载」与进度条 `.watch-metric-bar`
|
||||
|
||||
## 负载说明
|
||||
|
||||
展示 `load_1`(1 分钟平均负载),与宝塔面板「负载」同源;悬停 tooltip 说明非 CPU 百分比。
|
||||
|
||||
## 结论
|
||||
|
||||
**部署成功**;时长默认 30 分钟、进度条与中文指标、负载 chip 均已上线。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 子机离线告警独立 Telegram — 验证报告
|
||||
|
||||
**日期**: 2026-06-13
|
||||
|
||||
## 验证结果
|
||||
|
||||
| 项 | 方式 | 结果 |
|
||||
|----|------|------|
|
||||
| 通道路由单测 | `pytest tests/test_telegram_offline_channel.py` | **PASS** 4/4 |
|
||||
| 配置与白名单 | Python import `MUTABLE_KEYS` / `resolve_offline_*` | **PASS** |
|
||||
| API 路由注册 | FastAPI `telegram/offline/*` | **PASS** |
|
||||
| 本地门控 | `bash scripts/local_verify.sh` | **PASS** 26/26 + ruff |
|
||||
| 前端构建 | `npm run build` | **PASS**(含 `SettingsPage-*.js`) |
|
||||
| 构建产物 API 路径 | `telegram/offline/test` 等 | **PASS** |
|
||||
| 真实 Telegram 发送 | 未执行 | **SKIP**(需生产/本地 Bot 凭据) |
|
||||
| 子机离线边沿 E2E | 未执行 | **SKIP**(需可 SSH 子机 + Agent) |
|
||||
|
||||
## 路由清单(已注册)
|
||||
|
||||
- `POST /api/settings/telegram/offline/test`
|
||||
- `GET /api/settings/telegram/offline/chats`
|
||||
- `POST /api/settings/telegram/offline/reveal-token`
|
||||
- `PUT /api/settings/telegram_offline_bot_token`
|
||||
- `PUT /api/settings/telegram_offline_chat_id`
|
||||
|
||||
## 路由逻辑(单测覆盖)
|
||||
|
||||
1. 专用 Token+Chat 均配置 → 离线告警走专用 Bot
|
||||
2. 未配置专用 → 回退默认 `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID`
|
||||
3. 仅配置一半专用 → 回退默认(避免半配发不出去)
|
||||
|
||||
## 手动终验(部署后)
|
||||
|
||||
1. **设置** → 配置离线专用 Bot + Chat ID →「测试离线通道」收到消息。
|
||||
2. 停一台子机 Agent → 离线通知进**专用群**(非默认群)。
|
||||
3. 清空离线专用配置 → 离线通知回默认群。
|
||||
@@ -0,0 +1,115 @@
|
||||
# 审计报告 — 快捷命令回车修复 + 离线告警 Telegram 分流
|
||||
|
||||
**日期**: 2026-06-13
|
||||
**范围**: 本会话两项功能(不含工作区其它未提交变更如 remote-browser 删除)
|
||||
**方法**: 定向 diff 走读 + 安全/契约检查 + 已有 pytest 证据
|
||||
**审计员**: Agent(定向审计,非全仓 Phase 2 走读)
|
||||
|
||||
---
|
||||
|
||||
## 1. 变更摘要
|
||||
|
||||
| 功能 | 核心改动 | 风险面 |
|
||||
|------|----------|--------|
|
||||
| 终端快捷命令回车 | `formatQuickCmdForSend` + `execQuickCmd` 发送时补 `\r` | 前端 WebSSH 数据流 |
|
||||
| 离线告警 Telegram 分流 | `TELEGRAM_OFFLINE_*` + `resolve_offline_telegram_credentials` + 设置页/API | 凭据存储、出站 Telegram |
|
||||
|
||||
---
|
||||
|
||||
## 2. 审计文件(5 个)
|
||||
|
||||
| # | 文件 | 行数 | 状态 |
|
||||
|---|------|------|------|
|
||||
| 1 | `server/infrastructure/telegram/__init__.py` | 401 | 逐行完成 ✓ |
|
||||
| 2 | `server/api/settings.py`(offline 段 ~1188–1342) | 段 | 逐行完成 ✓ |
|
||||
| 3 | `frontend/src/composables/useTerminalQuickCommands.ts` | 80 | 逐行完成 ✓ |
|
||||
| 4 | `frontend/src/composables/terminal/useTerminalSessions.ts`(execQuickCmd) | 段 | 逐行完成 ✓ |
|
||||
| 5 | `frontend/src/pages/SettingsPage.vue`(离线 Bot 段) | 段 | 逐行完成 ✓ |
|
||||
|
||||
辅助:`TerminalQuickCommandsSettings.vue`(-2 行)、`server/config.py`(+4 映射)、测试 2 文件。
|
||||
|
||||
---
|
||||
|
||||
## 3. API 入口表(新增)
|
||||
|
||||
| 方法 | 路径 | 鉴权 |
|
||||
|------|------|------|
|
||||
| PUT | `/api/settings/telegram_offline_bot_token` | JWT `get_current_admin` |
|
||||
| PUT | `/api/settings/telegram_offline_chat_id` | JWT |
|
||||
| POST | `/api/settings/telegram/offline/test` | JWT |
|
||||
| GET | `/api/settings/telegram/offline/chats` | JWT |
|
||||
| POST | `/api/settings/telegram/offline/reveal-token` | JWT + 审计 |
|
||||
|
||||
既有 `broadcast_offline_alert` → `send_telegram_offline_alert` 无新 HTTP 面。
|
||||
|
||||
---
|
||||
|
||||
## 4. Closure 表
|
||||
|
||||
| ID | 规则/检查点 | 文件:行 | 结论 | 说明 |
|
||||
|----|-------------|---------|------|------|
|
||||
| H1 | 敏感凭据不入 GET 明文 | settings.py SENSITIVE_KEYS | **SAFE** | `telegram_offline_bot_token` 已加入掩码集 |
|
||||
| H2 | reveal 写 audit_logs | settings.py:1324–1331 | **SAFE** | `reveal_offline_telegram_token` |
|
||||
| H3 | 出站消息 HTML 转义 | telegram/__init__.py:189–195 | **SAFE** | `html.escape` 用于名称/心跳 |
|
||||
| H4 | 离线专用半配不误发 | telegram/__init__.py:96–107 | **SAFE** | 仅 token 或仅 chat 时整组回退默认 Bot |
|
||||
| H5 | 资源告警仍走默认 Bot | telegram/__init__.py:163 | **SAFE** | `send_telegram_alert` 未改路由 |
|
||||
| H6 | `send_telegram` 参数化不破坏默认调用 | telegram/__init__.py:110–118 | **SAFE** | 默认 `bot_token/chat_id=None` 行为不变 |
|
||||
| H7 | MUTABLE_KEYS 白名单 | settings.py:47–48 | **SAFE** | 两新键已列入 |
|
||||
| H8 | 快捷命令 `\r` 与 `trim()` 冲突 | useTerminalQuickCommands.ts:43–49 | **SAFE** | 发送时 `formatQuickCmdForSend` 根治 |
|
||||
| H9 | 内置命令 DB 带 `\r` 幂等 | useTerminalQuickCommands.ts:21–23 | **SAFE** | 先去尾再追加 |
|
||||
| H10 | 多行快捷命令语义 | useTerminalQuickCommands.ts:21–23 | **SAFE** | 与命令栏一致:整段一条命令 + 单次 `\r` |
|
||||
| H11 | 测试覆盖通道路由 | test_telegram_offline_channel.py | **SAFE** | 4 case 含 dedicated/fallback/partial |
|
||||
| H12 | reveal 加载态共用 | SettingsPage.vue:886–911 vs 离线 toggle | **FINDING P3** | 离线与默认共用 `revealingTelegramToken`,并发点击可能互斥显示 |
|
||||
| H13 | test 端点重复 httpx | settings.py:1268–1309 | **FINDING P3** | 可复用 `send_telegram(..., bot_token, chat_id)` 减重复 |
|
||||
| H14 | 新 API 错误文案 i18n | settings.py:1253,1264 | **FINDING P3** | `http_errors_zh` 未收录 `telegram_offline_*` 键名错误 |
|
||||
| H15 | 子机恢复在线 Telegram | server_offline_monitor.py | **INFO** | 仅 offline 边沿推送,无「恢复在线」通知(既有设计,非回归) |
|
||||
| H16 | Layer3 巡检不同步离线 Bot | settings.py:416–422 | **SAFE** | 仅默认 token 写 .env,符合设计文档 |
|
||||
| H17 | 无静默吞错 | telegram/__init__.py:136–138 | **SAFE** | 失败打 error 日志 |
|
||||
| H18 | CUD 审计 | settings.py set_setting | **SAFE** | PUT 走既有 `update_setting` 审计 |
|
||||
|
||||
`len(H)=18`,Closure=18。
|
||||
|
||||
---
|
||||
|
||||
## 5. 严重度汇总
|
||||
|
||||
| 级别 | 数量 | 项 |
|
||||
|------|------|-----|
|
||||
| **P0** | 0 | — |
|
||||
| **P1** | 0 | — |
|
||||
| **P2** | 0 | — |
|
||||
| **P3** | 3 | H12 加载态共用;H13 test 重复代码;H14 错误文案 i18n |
|
||||
| **INFO** | 1 | H15 无离线恢复 Telegram |
|
||||
|
||||
**合并结论**: **可合并 / 可部署**。无安全阻断项;P3 可在后续小 PR 打磨。
|
||||
|
||||
---
|
||||
|
||||
## 6. 测试证据
|
||||
|
||||
```
|
||||
pytest tests/test_telegram_offline_channel.py → 4 passed
|
||||
pytest tests/test_terminal_quick_commands.py → 1 passed
|
||||
bash scripts/local_verify.sh → 26/26 + ruff
|
||||
frontend npm run build → OK
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 建议(可选跟进)
|
||||
|
||||
1. **H12**: 为离线 Token 增加独立 `revealingOfflineTelegramToken`,避免与默认 Bot 眼睛按钮抢 loading。
|
||||
2. **H13**: `telegram_offline_test_send` 改为 `await send_telegram(message, bot_token=token, chat_id=chat_id)`。
|
||||
3. **H14**: 在 `server/utils/http_errors_zh.py` 补充 `telegram_offline_bot_token 未配置` 等映射。
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收对照(设计文档)
|
||||
|
||||
| 验收项 | 结果 |
|
||||
|--------|------|
|
||||
| 专用 Bot+Chat 配置后离线走专用通道 | ✓ 代码 + 单测 |
|
||||
| 未配置专用时回退默认 | ✓ 单测 |
|
||||
| CPU/内存等仍走默认 | ✓ 未改 `send_telegram_alert` |
|
||||
| 设置页保存/检测/测试 | ✓ UI + API 已实现 |
|
||||
| 快捷命令点击执行带回车 | ✓ `formatQuickCmdForSend` + 构建验证 |
|
||||
@@ -0,0 +1,108 @@
|
||||
# 巡检 — 快捷命令回车 + 离线 Telegram 分流 + Layer3 巡检
|
||||
|
||||
**日期**:2026-06-13
|
||||
**范围**:本会话两项功能 + 部署门控 + Layer3 宿主机巡检状态
|
||||
**生产**:https://api.synaglobal.vip(本机外网 curl 不可达,见下)
|
||||
|
||||
## 进度条
|
||||
|
||||
| 步骤 | 状态 |
|
||||
|------|------|
|
||||
| 实现 | ✅ 工作区完成 |
|
||||
| 本地 L2b | ✅ 26/26 + ruff |
|
||||
| 功能单测 | ✅ 10/10(offline 4 + quick-cmd 1 + offline_monitor 5) |
|
||||
| Layer3 单测 | ✅ `test_ops_patrol_sync.py` 7/7 |
|
||||
| 前端 build | ✅(此前已构建) |
|
||||
| 门控 7/7 | ❌ **5/7**(Gate 2/7 审计路径、Gate 7 无审计文件) |
|
||||
| 生产健康 | ⚠️ 外网 `curl` 连接重置;SSH `nexus` 主机名不可解析 |
|
||||
| 本地 API | ✅ `GET /health` → `ok` |
|
||||
| 浏览器终验 | ❌ 待部署 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 功能变更巡检
|
||||
|
||||
### 1.1 终端快捷命令回车
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|--------|------|
|
||||
| `formatQuickCmdForSend` 发送时补 `\r` | ✅ |
|
||||
| 设置页不再 `trim` 掉 `\r` 后入库 | ✅ |
|
||||
| 内置命令 DB 带 `\r` 幂等 | ✅ 单测逻辑 |
|
||||
| `pytest test_terminal_quick_commands` | ✅ 1 passed |
|
||||
|
||||
### 1.2 子机离线告警独立 Telegram
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|--------|------|
|
||||
| `resolve_offline_telegram_credentials` 专用优先 / 半配回退 | ✅ 3 case |
|
||||
| `send_telegram_offline_alert` 传入专用 token/chat | ✅ mock 单测 |
|
||||
| 资源告警仍走默认 Bot | ✅ 未改 `send_telegram_alert` |
|
||||
| 设置页离线 Bot UI + 3 API | ✅ 代码 + 构建产物 |
|
||||
| Layer3 `health_monitor.sh` 使用离线 Bot | ✅ **否**(设计:仅默认 `NEXUS_TELEGRAM_*`) |
|
||||
| `ops_patrol_sync` 同步离线 Token | ✅ **否**(符合设计) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Layer 3 宿主机巡检
|
||||
|
||||
| 项 | 本地状态 |
|
||||
|----|----------|
|
||||
| `deploy/health_monitor.sh` 语法 | ✅ `bash -n` 通过 |
|
||||
| `test_ops_patrol_sync.py` | ✅ 7/7 |
|
||||
| `.env` 内 `NEXUS_TELEGRAM_*` | ❌ 未写入(`env_synced: false`) |
|
||||
| DB `TELEGRAM_BOT_TOKEN/CHAT_ID` | ❌ 本地未配置 |
|
||||
| 离线专用 `TELEGRAM_OFFLINE_*` | ❌ 未配置(预期:不影响 L3) |
|
||||
| 生产 cron / `nexus_health.log` | ⚠️ 无法 SSH 核查(`nexus` 主机名不可解析) |
|
||||
|
||||
**说明**:Layer3 巡检 Telegram 依赖设置页保存后「同步巡检 Telegram」或 `health_monitor.sh` 的 DB 回退;与本次**离线专用 Bot**无关。
|
||||
|
||||
---
|
||||
|
||||
## 3. 部署门控 `pre_deploy_check.sh`
|
||||
|
||||
| Gate | 结果 |
|
||||
|------|------|
|
||||
| 1 Changelog | ✅ `2026-06-13-offline-alert-telegram-split.md` |
|
||||
| 2 Audit | ❌ 缺少 `docs/audit/2026-06-13-*.md`(审计写在 `docs/reports/`) |
|
||||
| 3 Test | ✅ |
|
||||
| 4 Lint | ✅ |
|
||||
| 5 Import | ✅ |
|
||||
| 6 Security | ✅ bandit 0 HIGH |
|
||||
| 7 Review | ❌ 无 audit 文件对照 diff |
|
||||
|
||||
**部署结论**:**BLOCKED**,需将门控格式审计写入 `docs/audit/2026-06-13-*.md`(含 Step 3 / Closure / DoD 段落)后方可 `deploy-production.sh`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 生产可达性(本环境)
|
||||
|
||||
```
|
||||
curl https://api.synaglobal.vip/health → Recv failure: 连接被对方重置
|
||||
ssh nexus → Could not resolve hostname
|
||||
curl http://127.0.0.1:8600/health → ok
|
||||
```
|
||||
|
||||
外网/SSH 失败可能为本机网络或 DNS 限制,**不能据此判定生产宕机**;请在可访问生产的环境复验。
|
||||
|
||||
---
|
||||
|
||||
## 5. 风险与待办
|
||||
|
||||
| 优先级 | 项 | 建议 |
|
||||
|--------|-----|------|
|
||||
| P0 部署 | 门控审计路径 | 补 `docs/audit/2026-06-13-offline-quick-cmd-telegram.md` |
|
||||
| P1 上线 | 前端 `web/app/` | `vite build` 后部署 |
|
||||
| P1 上线 | 后端重启 | 新 `telegram/offline/*` 端点 |
|
||||
| P2 运维 | 离线专用 Bot | 设置页配置 + 测试离线通道 |
|
||||
| P2 运维 | Layer3 | 保存默认 Telegram 后点「同步巡检 Telegram」 |
|
||||
| P3 | 审计 P3 项 | 见 `docs/reports/2026-06-13-session-features-audit.md` |
|
||||
|
||||
---
|
||||
|
||||
## 6. 建议部署后终验清单
|
||||
|
||||
1. 设置 → 离线专用 Bot → **测试离线通道**
|
||||
2. 终端 → 自定义快捷命令 → 点击执行有回车
|
||||
3. 设置 → Layer3 状态芯片「巡检 Telegram 就绪」+ `.env 已同步`(若使用 L3)
|
||||
4. `https://api.synaglobal.vip/health` → `ok`
|
||||
@@ -0,0 +1,33 @@
|
||||
# 终端快捷命令回车修复 — 验证报告
|
||||
|
||||
**日期**: 2026-06-13
|
||||
**变更**: `formatQuickCmdForSend` + `execQuickCmd` 发送时追加 `\r`
|
||||
|
||||
## 验证结果
|
||||
|
||||
| 项 | 命令/方式 | 结果 |
|
||||
|----|-----------|------|
|
||||
| 前端构建 | `cd frontend && npm run build` | PASS(5.37s,含 `TerminalPage-*.js`) |
|
||||
| 本地门控 | `bash scripts/local_verify.sh` | PASS(26/26 API smoke + ruff) |
|
||||
| 发送逻辑单测 | Node/Python 镜像 `formatQuickCmdForSend` | PASS(含 `trim()` 剥 `\r` 后仍能补回) |
|
||||
| API 集成 | `pytest tests/test_terminal_quick_commands.py` | PASS |
|
||||
| 浏览器 E2E(WebSSH 点击执行) | 未执行 | SKIP — 需已登录 + 可 SSH 子机 |
|
||||
|
||||
## 根因复现(已确认)
|
||||
|
||||
```javascript
|
||||
'ls\r'.trim() // => 'ls' (\r 被当作空白剥掉)
|
||||
```
|
||||
|
||||
修复后:`formatQuickCmdForSend('ls')` => `'ls\r'`,与命令栏 `sendCmd` 行为一致。
|
||||
|
||||
## 手动终验(部署后)
|
||||
|
||||
1. 设置 → 终端快捷命令 → 添加 `echo hello`,保存。
|
||||
2. 终端页连接子机 → 点击该快捷命令。
|
||||
3. 预期:输出 `hello` 并回到新提示符(非悬停在输入行)。
|
||||
|
||||
## 备注
|
||||
|
||||
- 内置命令 DB 中可能仍带 `\r`;`formatQuickCmdForSend` 先去尾再追加,幂等安全。
|
||||
- 仅前端变更;部署 `web/app/` 即可,无需重启后端。
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Production smoke — watch slot TTL chips + metric bars */
|
||||
import { test, expect } from '../fixtures.mjs'
|
||||
import { loginWithAccessToken, nav } from '../helpers.mjs'
|
||||
|
||||
test('watch slot TTL chips and metric labels', async ({ page }) => {
|
||||
const token = process.env.NEXUS_E2E_ACCESS_TOKEN || ''
|
||||
test.skip(!token, 'NEXUS_E2E_ACCESS_TOKEN required')
|
||||
await loginWithAccessToken(page, token)
|
||||
await nav(page, '/servers')
|
||||
await expect(page.getByText('默认 30 分')).toBeVisible({ timeout: 20000 })
|
||||
await expect(page.getByText('30分').first()).toBeVisible()
|
||||
await expect(page.getByText('1时').first()).toBeVisible()
|
||||
const card = page.locator('.watch-slot-card--filled').first()
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card.getByText('时长')).toBeVisible()
|
||||
const switchEl = card.locator('.watch-slot-switch input[type="checkbox"]')
|
||||
if (!(await switchEl.isChecked())) {
|
||||
await card.locator('.watch-slot-switch').click()
|
||||
await page.waitForTimeout(1500)
|
||||
}
|
||||
await expect(card.getByText('内存').first()).toBeVisible({ timeout: 15000 })
|
||||
await expect(card.getByText('硬盘').first()).toBeVisible()
|
||||
await expect(card.getByText('负载').first()).toBeVisible()
|
||||
await expect(card.locator('.watch-metric-bar').first()).toBeVisible()
|
||||
})
|
||||
+23
-8
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<v-app :style="appChromeVars">
|
||||
<!-- ── 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,10 @@
|
||||
</v-alert>
|
||||
</div>
|
||||
|
||||
<GlobalBrowserPanel v-if="auth.isLoggedIn" />
|
||||
|
||||
<!-- ── Main Content ── -->
|
||||
<v-main :class="mainLayoutClass" :style="scriptExecBarOffset">
|
||||
<v-main :class="mainLayoutClass" :style="mainContentOffset">
|
||||
<router-view v-slot="{ Component, route: activeRoute }">
|
||||
<keep-alive :include="cachedPageNames" :max="12">
|
||||
<component :is="Component" :key="activeRoute.name ?? activeRoute.path" />
|
||||
@@ -249,6 +251,7 @@ 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]
|
||||
|
||||
@@ -330,12 +333,24 @@ const mainLayoutClass = computed(() => ({
|
||||
'nexus-page-main': route.path !== '/terminal' && route.path !== '/login',
|
||||
}))
|
||||
|
||||
const scriptExecBarOffset = computed(() => {
|
||||
const n = runningItems.value.length
|
||||
if (!n || !auth.isLoggedIn) return undefined
|
||||
const extra = n * 52
|
||||
const BROWSER_PANEL_HEIGHT = 420
|
||||
|
||||
const appChromeVars = computed(() => {
|
||||
const dock = auth.isLoggedIn ? `${BROWSER_PANEL_HEIGHT}px` : '0px'
|
||||
return {
|
||||
paddingTop: `calc(var(--v-layout-top, 64px) + ${extra}px)`,
|
||||
'--nexus-browser-dock-height': dock,
|
||||
'--nexus-browser-dock-offset': dock,
|
||||
}
|
||||
})
|
||||
|
||||
const mainContentOffset = computed(() => {
|
||||
if (!auth.isLoggedIn) return undefined
|
||||
const execRows = runningItems.value.length
|
||||
const execExtra = execRows ? execRows * 52 : 0
|
||||
const browserExtra = BROWSER_PANEL_HEIGHT
|
||||
const total = execExtra + browserExtra
|
||||
return {
|
||||
paddingTop: `calc(var(--v-layout-top, 64px) + ${total}px)`,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -437,7 +452,7 @@ window.$snackbar = (text: string, color = 'success') => {
|
||||
|
||||
.nexus-script-exec-bar {
|
||||
position: fixed;
|
||||
top: var(--v-layout-top, 64px);
|
||||
top: calc(var(--v-layout-top, 64px) + var(--nexus-browser-dock-offset, 0px));
|
||||
left: var(--v-layout-left, 0);
|
||||
right: 0;
|
||||
z-index: 1005;
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="tabs.length"
|
||||
class="global-browser-fixed"
|
||||
:class="{ 'global-browser-fixed--maximized': maximized }"
|
||||
>
|
||||
<v-card border rounded="0" class="global-browser-panel d-flex flex-column h-100">
|
||||
<div class="global-browser-toolbar d-flex align-center flex-shrink-0">
|
||||
<v-tabs
|
||||
:model-value="activeTabId ?? undefined"
|
||||
density="compact"
|
||||
show-arrows
|
||||
class="global-browser-tabs flex-grow-1 min-w-0"
|
||||
@update:model-value="onTabSelected"
|
||||
>
|
||||
<v-tab v-for="tab in tabs" :key="tab.id" :value="tab.id" class="text-none px-2">
|
||||
<span class="text-truncate" style="max-width: 120px">{{ tabLabel(tab) }}</span>
|
||||
<v-btn
|
||||
v-if="tabs.length > 1"
|
||||
icon="mdi-close"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="ml-1 opacity-60"
|
||||
density="compact"
|
||||
@click.stop="closeTab(tab.id)"
|
||||
/>
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-btn size="small" variant="text" icon="mdi-plus" title="新标签" density="compact" @click="newEmptyTab" />
|
||||
<v-divider vertical class="mx-1" style="height: 20px; align-self: center" />
|
||||
<v-btn size="small" variant="text" icon="mdi-history" title="浏览记录" density="compact" @click="showHistory = !showHistory" />
|
||||
<v-btn size="small" variant="text" icon="mdi-restart" title="重启浏览器(保留历史记录)" density="compact" @click="onRestart" />
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
:icon="maximized ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'"
|
||||
density="compact"
|
||||
title="全屏"
|
||||
class="mr-1"
|
||||
@click="maximized = !maximized"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="global-browser-nav d-flex align-center px-2 py-1 flex-shrink-0 border-b">
|
||||
<v-btn icon="mdi-arrow-left" variant="text" size="small" :disabled="!canGoBack" @click="onGoBack" />
|
||||
<v-btn icon="mdi-arrow-right" variant="text" size="small" :disabled="!canGoForward" @click="onGoForward" />
|
||||
<v-btn icon="mdi-refresh" variant="text" size="small" :disabled="!activeTab?.url" @click="onRefresh" />
|
||||
<v-text-field
|
||||
v-model="addressDraft"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
placeholder="https://example.com"
|
||||
prepend-inner-icon="mdi-web"
|
||||
class="global-browser-url mx-2"
|
||||
@keydown.enter.prevent="onGo"
|
||||
/>
|
||||
<v-btn color="primary" variant="flat" size="small" class="text-none" @click="onGo">打开</v-btn>
|
||||
<v-btn icon="mdi-open-in-new" variant="text" size="small" :disabled="!activeTab?.url" @click="openExternal" />
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="displayError"
|
||||
type="warning"
|
||||
density="compact"
|
||||
variant="tonal"
|
||||
class="ma-2 mb-0 flex-shrink-0"
|
||||
closable
|
||||
@click:close="clearErrors"
|
||||
>
|
||||
{{ displayError }}
|
||||
</v-alert>
|
||||
|
||||
<div class="global-browser-main d-flex flex-grow-1 min-h-0">
|
||||
<aside v-if="showHistory" class="global-browser-history flex-shrink-0 border-e">
|
||||
<div class="text-caption font-weight-medium px-3 py-2">浏览记录(已同步账号)</div>
|
||||
<v-list density="compact" nav class="py-0 global-browser-history-list">
|
||||
<v-list-item
|
||||
v-for="(visit, idx) in visits"
|
||||
:key="`${visit.at}-${idx}`"
|
||||
:title="visit.title"
|
||||
:subtitle="visit.url"
|
||||
@click="openFromHistory(visit.url)"
|
||||
/>
|
||||
<v-list-item v-if="!visits.length" title="暂无记录" disabled />
|
||||
</v-list>
|
||||
</aside>
|
||||
|
||||
<div class="global-browser-content flex-grow-1 d-flex flex-column min-w-0 min-h-0">
|
||||
<div
|
||||
v-if="!activeTab?.url"
|
||||
class="flex-grow-1 d-flex flex-column align-center justify-center text-medium-emphasis pa-4"
|
||||
>
|
||||
<v-icon icon="mdi-web" size="48" class="mb-3 opacity-50" />
|
||||
<p class="text-body-2 text-center">输入地址后按回车或点「打开」</p>
|
||||
<p class="text-caption mt-2 text-center">
|
||||
由 Worker 上的 Chromium 远程渲染,不受 iframe 嵌入限制。
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="global-browser-canvas-wrap flex-grow-1 d-flex flex-column min-h-0">
|
||||
<div v-if="connecting" class="d-flex align-center justify-center flex-grow-1">
|
||||
<v-progress-circular indeterminate color="primary" />
|
||||
<span class="ml-3 text-body-2">正在连接远程浏览器…</span>
|
||||
</div>
|
||||
<canvas
|
||||
v-show="connected"
|
||||
ref="canvasRef"
|
||||
class="global-browser-canvas flex-grow-1"
|
||||
tabindex="0"
|
||||
@mousemove="onCanvasMouseMove"
|
||||
@mousedown="onCanvasMouseDown"
|
||||
@mouseup="onCanvasMouseUp"
|
||||
@wheel.prevent="onCanvasWheel"
|
||||
@keydown="onCanvasKeyDown"
|
||||
@contextmenu.prevent
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<v-dialog v-model="showRestartConfirm" max-width="400">
|
||||
<v-card border>
|
||||
<v-card-title>重启浏览器?</v-card-title>
|
||||
<v-card-text>将关闭所有标签并打开空白页,浏览历史记录会保留。</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showRestartConfirm = false">取消</v-btn>
|
||||
<v-btn color="primary" variant="flat" @click="confirmRestart">重启</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
|
||||
import { useRemoteBrowserSession } from '@/composables/useRemoteBrowserSession'
|
||||
|
||||
const showHistory = ref(false)
|
||||
const showRestartConfirm = ref(false)
|
||||
const maximized = ref(false)
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
const {
|
||||
lastError,
|
||||
addressDraft,
|
||||
visits,
|
||||
tabs,
|
||||
activeTabId,
|
||||
activeTab,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
tabLabel,
|
||||
openPanel,
|
||||
restartBrowser,
|
||||
navigate,
|
||||
updateActiveFromRemote,
|
||||
goBack,
|
||||
goForward,
|
||||
openExternal,
|
||||
switchTab,
|
||||
closeTab,
|
||||
newEmptyTab,
|
||||
openFromHistory,
|
||||
bootstrap,
|
||||
} = useGlobalBrowser()
|
||||
|
||||
const {
|
||||
connected,
|
||||
connecting,
|
||||
remoteError,
|
||||
setCanvas,
|
||||
setPageInfoHandler,
|
||||
connect,
|
||||
disconnect,
|
||||
navigate: remoteNavigate,
|
||||
reload,
|
||||
goBack: remoteGoBack,
|
||||
goForward: remoteGoForward,
|
||||
onCanvasMouseMove,
|
||||
onCanvasMouseDown,
|
||||
onCanvasMouseUp,
|
||||
onCanvasWheel,
|
||||
onCanvasKeyDown,
|
||||
} = useRemoteBrowserSession()
|
||||
|
||||
const displayError = computed(() => remoteError.value || lastError.value)
|
||||
|
||||
function clearErrors() {
|
||||
lastError.value = null
|
||||
if (remoteError.value) remoteError.value = null
|
||||
}
|
||||
|
||||
setPageInfoHandler((url, title) => updateActiveFromRemote(url, title))
|
||||
|
||||
async function ensureRemoteSession() {
|
||||
if (!activeTab.value?.url || connecting.value || connected.value) return
|
||||
await nextTick()
|
||||
if (canvasRef.value) setCanvas(canvasRef.value)
|
||||
const w = Math.max(640, Math.floor(canvasRef.value?.clientWidth || 1280))
|
||||
const h = Math.max(480, Math.floor(canvasRef.value?.clientHeight || 360))
|
||||
await connect(w, h)
|
||||
if (connected.value && activeTab.value?.url) {
|
||||
remoteNavigate(activeTab.value.url)
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeTab, () => {
|
||||
if (activeTab.value?.url) {
|
||||
openPanel()
|
||||
void ensureRemoteSession()
|
||||
}
|
||||
}, { flush: 'post' })
|
||||
|
||||
watch(activeTabId, () => {
|
||||
disconnect()
|
||||
if (activeTab.value?.url) {
|
||||
void ensureRemoteSession()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await bootstrap()
|
||||
if (!tabs.value.length) {
|
||||
newEmptyTab()
|
||||
}
|
||||
openPanel()
|
||||
if (activeTab.value?.url) {
|
||||
void ensureRemoteSession()
|
||||
}
|
||||
})
|
||||
|
||||
function onTabSelected(id: unknown) {
|
||||
if (typeof id === 'string') switchTab(id)
|
||||
}
|
||||
|
||||
function onGo() {
|
||||
if (navigate(addressDraft.value)) {
|
||||
void ensureRemoteSession().then(() => {
|
||||
if (activeTab.value?.url) remoteNavigate(activeTab.value.url)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onRestart() {
|
||||
showRestartConfirm.value = true
|
||||
}
|
||||
|
||||
function confirmRestart() {
|
||||
showRestartConfirm.value = false
|
||||
disconnect()
|
||||
restartBrowser()
|
||||
}
|
||||
|
||||
function onRefresh() {
|
||||
if (connected.value) reload()
|
||||
}
|
||||
|
||||
function onGoBack() {
|
||||
goBack()
|
||||
if (connected.value) remoteGoBack()
|
||||
}
|
||||
|
||||
function onGoForward() {
|
||||
goForward()
|
||||
if (connected.value) remoteGoForward()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.global-browser-fixed {
|
||||
position: fixed;
|
||||
top: var(--v-layout-top, 64px);
|
||||
left: var(--v-layout-left, 0);
|
||||
right: 0;
|
||||
z-index: 1004;
|
||||
height: var(--nexus-browser-dock-height, 420px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.global-browser-fixed--maximized {
|
||||
height: calc(100dvh - var(--v-layout-top, 64px)) !important;
|
||||
}
|
||||
.global-browser-panel {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
.global-browser-toolbar {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
.global-browser-nav {
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
.global-browser-url {
|
||||
flex: 1 1 auto;
|
||||
min-width: 100px;
|
||||
}
|
||||
.global-browser-main {
|
||||
min-height: 0;
|
||||
}
|
||||
.global-browser-history {
|
||||
width: 280px;
|
||||
max-width: 40%;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
.global-browser-history-list {
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.global-browser-canvas-wrap {
|
||||
min-height: 0;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
.global-browser-canvas {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
cursor: default;
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
@@ -138,8 +138,7 @@ async function save() {
|
||||
snackbar('名称和命令不能为空', 'warning')
|
||||
return
|
||||
}
|
||||
let cmdText = formCmd.value.trim()
|
||||
if (!cmdText.endsWith('\r')) cmdText += '\r'
|
||||
const cmdText = formCmd.value.trim()
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) await update(editingId.value, formName.value, cmdText)
|
||||
|
||||
@@ -37,13 +37,13 @@ const p = useFilesPageContext()
|
||||
show-select
|
||||
return-object
|
||||
hover
|
||||
density="comfortable"
|
||||
density="compact"
|
||||
item-value="name"
|
||||
:row-props="p.fileRowProps"
|
||||
>
|
||||
<template #item.name="{ item }">
|
||||
<div class="d-flex align-center ga-2 min-w-0">
|
||||
<v-icon :color="p.entryIconColor(item)" size="20">
|
||||
<v-icon :color="p.entryIconColor(item)" size="18">
|
||||
{{ p.entryIcon(item) }}
|
||||
</v-icon>
|
||||
<v-icon
|
||||
@@ -96,41 +96,28 @@ const p = useFilesPageContext()
|
||||
<div class="files-row-actions">
|
||||
<v-btn
|
||||
v-if="p.isRegularFile(item)"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
variant="text"
|
||||
size="x-small"
|
||||
color="primary"
|
||||
class="files-action-btn"
|
||||
prepend-icon="mdi-pencil-outline"
|
||||
density="compact"
|
||||
@click.stop="p.editFile(item)"
|
||||
>
|
||||
编辑
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="p.isRegularFile(item)"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
class="files-action-btn"
|
||||
prepend-icon="mdi-eye-outline"
|
||||
variant="text"
|
||||
size="x-small"
|
||||
density="compact"
|
||||
@click.stop="p.previewFile(item)"
|
||||
>
|
||||
查看
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="p.isRegularFile(item)"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
class="files-action-btn"
|
||||
prepend-icon="mdi-download-outline"
|
||||
@click.stop="p.downloadFile(item)"
|
||||
>
|
||||
下载
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="small"
|
||||
variant="text"
|
||||
size="x-small"
|
||||
color="error"
|
||||
class="files-action-btn"
|
||||
prepend-icon="mdi-delete-outline"
|
||||
density="compact"
|
||||
@click.stop="p.confirmDeleteFile(item)"
|
||||
>
|
||||
删除
|
||||
@@ -176,13 +163,6 @@ const p = useFilesPageContext()
|
||||
>
|
||||
<v-list-item-title>查看</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="p.contextFile && p.isRegularFile(p.contextFile)"
|
||||
prepend-icon="mdi-download"
|
||||
@click="p.contextAction('download')"
|
||||
>
|
||||
<v-list-item-title>下载</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="p.contextFile"
|
||||
prepend-icon="mdi-content-copy"
|
||||
@@ -260,26 +240,11 @@ const p = useFilesPageContext()
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* 行内操作:独立小按钮、字号略大、横向可换行 */
|
||||
.files-row-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.files-action-btn {
|
||||
text-transform: none;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
min-height: 32px;
|
||||
padding-inline: 10px 12px;
|
||||
}
|
||||
|
||||
.files-action-btn :deep(.v-btn__prepend) {
|
||||
margin-inline-end: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -97,8 +97,7 @@ const p = useFilesPageContext()
|
||||
size="small"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-file-plus"
|
||||
:disabled="!p.selectedServer"
|
||||
@click="p.showNewFile = true"
|
||||
@click="p.openNewFileDialog()"
|
||||
>
|
||||
新建文件
|
||||
</v-btn>
|
||||
|
||||
@@ -4,24 +4,17 @@
|
||||
服务器
|
||||
<span class="text-caption text-medium-emphasis ml-1">({{ servers.length }})</span>
|
||||
</div>
|
||||
<v-skeleton-loader v-if="loading" type="list-item@8" class="px-2 pt-2" />
|
||||
<v-list v-else density="compact" nav class="terminal-server-rail__list flex-grow-1 py-0">
|
||||
<v-list-item
|
||||
v-for="s in servers"
|
||||
:key="s.id"
|
||||
rounded="lg"
|
||||
class="mb-1"
|
||||
@click="$emit('select', s.id)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-body-2 text-truncate" :title="s.name">{{ s.name }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-truncate" :title="s.domain">{{ s.domain }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="servers.length === 0" class="text-medium-emphasis">
|
||||
暂无匹配的服务器
|
||||
</v-list-item>
|
||||
<div class="terminal-server-rail__search px-2 pt-2 pb-1">
|
||||
<PickerSearch
|
||||
:search="search"
|
||||
:search-history="searchHistory"
|
||||
@update:search="$emit('update:search', $event)"
|
||||
@search-commit="$emit('search-commit')"
|
||||
/>
|
||||
</div>
|
||||
<v-skeleton-loader v-if="loading" type="list-item@8" class="px-2 pt-1" />
|
||||
<v-list v-else density="compact" nav class="terminal-server-rail__list flex-grow-1 py-0 px-2">
|
||||
<PickerListItems :servers="servers" @select="$emit('select', $event)" />
|
||||
</v-list>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -2,16 +2,20 @@
|
||||
<v-list-item
|
||||
v-for="s in servers"
|
||||
:key="s.id"
|
||||
:title="s.name"
|
||||
:subtitle="s.domain"
|
||||
rounded="lg"
|
||||
density="compact"
|
||||
class="terminal-server-item"
|
||||
@click="$emit('select', s.id)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-body-2 text-truncate" :title="s.name">{{ s.name }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-caption text-truncate text-medium-emphasis" :title="s.domain">
|
||||
{{ s.domain }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="servers.length === 0" class="text-medium-emphasis">
|
||||
<v-list-item v-if="servers.length === 0" density="compact" class="text-medium-emphasis terminal-server-item">
|
||||
暂无匹配的服务器
|
||||
</v-list-item>
|
||||
</template>
|
||||
@@ -27,3 +31,13 @@ defineEmits<{
|
||||
select: [serverId: number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.terminal-server-item {
|
||||
min-height: 40px !important;
|
||||
padding-block: 2px;
|
||||
}
|
||||
.terminal-server-item :deep(.v-list-item__prepend) {
|
||||
align-self: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
value: number | null | undefined
|
||||
}>()
|
||||
|
||||
function barColor(v: number): string {
|
||||
if (v >= 90) return 'error'
|
||||
if (v >= 70) return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
function displayValue() {
|
||||
return props.value != null ? `${props.value}%` : '—'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="watch-metric-bar mb-1">
|
||||
<div class="d-flex justify-space-between text-caption mb-0">
|
||||
<span class="text-medium-emphasis">{{ label }}</span>
|
||||
<span class="font-weight-medium">{{ displayValue() }}</span>
|
||||
</div>
|
||||
<v-progress-linear
|
||||
:model-value="value ?? 0"
|
||||
:color="value != null ? barColor(value) : 'grey'"
|
||||
bg-opacity="0.2"
|
||||
height="6"
|
||||
rounded
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -37,8 +37,8 @@ const headers = [
|
||||
{ title: '状态', key: 'probe_status', width: 100 },
|
||||
{ title: '来源', key: 'source', width: 72 },
|
||||
{ title: 'CPU', key: 'cpu_pct', width: 64 },
|
||||
{ title: 'MEM', key: 'mem_pct', width: 64 },
|
||||
{ title: 'DISK', key: 'disk_pct', width: 64 },
|
||||
{ title: '内存', key: 'mem_pct', width: 64 },
|
||||
{ title: '硬盘', key: 'disk_pct', width: 64 },
|
||||
{ title: '↑', key: 'net_up_bps', width: 88 },
|
||||
{ title: '↓', key: 'net_down_bps', width: 88 },
|
||||
{ title: '耗时', key: 'duration_ms', width: 72 },
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { WatchSlot } from '@/composables/useWatchPins'
|
||||
import { formatBytesPerSec, formatRemaining, probeStatusColor, probeStatusLabel, formatProbeError, isPsutilMissingError } from '@/utils/watchFormat'
|
||||
import {
|
||||
WATCH_TTL_OPTIONS,
|
||||
normalizeWatchTtlMinutes,
|
||||
watchTtlLabel,
|
||||
watchTtlShortLabel,
|
||||
type WatchTtlMinutes,
|
||||
} from '@/constants/watchTtl'
|
||||
import { formatBytesPerSec, formatRemaining, probeStatusColor, probeStatusLabel, formatProbeError, isPsutilMissingError, isWatchMetricsPending, loadAvgColor, formatLoadAvg } from '@/utils/watchFormat'
|
||||
import WatchSparkline from '@/components/watch/WatchSparkline.vue'
|
||||
import WatchMetricBar from '@/components/watch/WatchMetricBar.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
slot: WatchSlot
|
||||
@@ -15,21 +23,22 @@ const emit = defineEmits<{
|
||||
history: [slot: WatchSlot]
|
||||
pick: [slotIndex: number]
|
||||
monitoring: [slotIndex: number, enabled: boolean]
|
||||
ttl: [slotIndex: number, hours: 8 | 24]
|
||||
ttl: [slotIndex: number, minutes: WatchTtlMinutes]
|
||||
installPsutil: [slotIndex: number]
|
||||
}>()
|
||||
|
||||
const monitoringOn = computed(() => props.slot.monitoring_enabled !== false)
|
||||
const slotTtl = computed(() => (props.slot.ttl_hours === 24 ? 24 : 8) as 8 | 24)
|
||||
const slotTtlMinutes = computed(() =>
|
||||
normalizeWatchTtlMinutes(props.slot.ttl_minutes ?? (props.slot.ttl_hours === 24 ? 1440 : props.slot.ttl_hours === 8 ? 480 : undefined)),
|
||||
)
|
||||
const showPsutilInstall = computed(() =>
|
||||
monitoringOn.value && isPsutilMissingError(props.slot.metrics?.error, props.slot.metrics?.probe_status),
|
||||
)
|
||||
|
||||
const chartMetric = ref<'cpu_pct' | 'mem_pct' | 'disk_pct'>('cpu_pct')
|
||||
|
||||
function pct(v: number | null | undefined) {
|
||||
return v != null ? `${v}%` : '—'
|
||||
}
|
||||
const load1 = computed(() => props.slot.metrics?.load_1)
|
||||
const metricsPending = computed(() => monitoringOn.value && isWatchMetricsPending(props.slot.metrics))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -38,6 +47,8 @@ function pct(v: number | null | undefined) {
|
||||
elevation="0"
|
||||
border
|
||||
rounded="lg"
|
||||
:link="false"
|
||||
:ripple="false"
|
||||
class="watch-slot-card pa-3 watch-slot-card--filled"
|
||||
:class="{
|
||||
'watch-slot-card--error': monitoringOn && slot.metrics?.probe_status && slot.metrics.probe_status !== 'ok',
|
||||
@@ -67,19 +78,22 @@ function pct(v: number | null | undefined) {
|
||||
<v-btn icon="mdi-close" size="x-small" variant="text" @click="emit('remove', slot.slot_index)" />
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center mb-2 ga-1" @click.stop>
|
||||
<span class="text-caption text-medium-emphasis mr-1">时长</span>
|
||||
<v-btn-toggle
|
||||
:model-value="slotTtl"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
divided
|
||||
mandatory
|
||||
@update:model-value="emit('ttl', slot.slot_index, ($event ?? 8) as 8 | 24)"
|
||||
>
|
||||
<v-btn :value="8" size="x-small">8h</v-btn>
|
||||
<v-btn :value="24" size="x-small">24h</v-btn>
|
||||
</v-btn-toggle>
|
||||
<div class="watch-ttl-row mb-2" @click.stop>
|
||||
<span class="text-caption text-medium-emphasis watch-ttl-label">时长</span>
|
||||
<div class="watch-ttl-chips d-flex flex-wrap ga-1">
|
||||
<v-chip
|
||||
v-for="opt in WATCH_TTL_OPTIONS"
|
||||
:key="opt.minutes"
|
||||
:color="slotTtlMinutes === opt.minutes ? 'primary' : undefined"
|
||||
:variant="slotTtlMinutes === opt.minutes ? 'flat' : 'outlined'"
|
||||
size="x-small"
|
||||
label
|
||||
class="watch-ttl-chip"
|
||||
@click="emit('ttl', slot.slot_index, opt.minutes)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!monitoringOn" class="text-caption text-medium-emphasis mb-2" @click.stop>
|
||||
@@ -88,6 +102,18 @@ function pct(v: number | null | undefined) {
|
||||
|
||||
<template v-if="monitoringOn">
|
||||
<div @click.stop>
|
||||
<div
|
||||
v-if="metricsPending"
|
||||
class="watch-slot-waiting d-flex flex-column align-center justify-center text-center py-6"
|
||||
>
|
||||
<v-progress-circular indeterminate color="primary" size="36" width="3" />
|
||||
<div class="text-body-2 mt-3">等待服务器回传信息…</div>
|
||||
<div class="text-caption text-medium-emphasis mt-1 px-2">
|
||||
探针约每 5 秒轮询,首次通常 5–15 秒
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<v-chip
|
||||
v-if="slot.metrics?.probe_status"
|
||||
size="x-small"
|
||||
@@ -99,10 +125,25 @@ function pct(v: number | null | undefined) {
|
||||
{{ probeStatusLabel(slot.metrics.probe_status) }}
|
||||
</v-chip>
|
||||
|
||||
<div class="d-flex justify-space-between text-caption mb-1">
|
||||
<span>CPU {{ pct(slot.metrics?.cpu_pct) }}</span>
|
||||
<span>MEM {{ pct(slot.metrics?.mem_pct) }}</span>
|
||||
<span>DISK {{ pct(slot.metrics?.disk_pct) }}</span>
|
||||
<WatchMetricBar label="CPU" :value="slot.metrics?.cpu_pct" />
|
||||
<WatchMetricBar label="内存" :value="slot.metrics?.mem_pct" />
|
||||
<WatchMetricBar label="硬盘" :value="slot.metrics?.disk_pct" />
|
||||
|
||||
<div class="d-flex align-center justify-space-between text-caption mb-2 mt-1">
|
||||
<span class="text-medium-emphasis">负载</span>
|
||||
<v-tooltip location="top" text="1 分钟平均负载,与宝塔面板「负载」相同(非 CPU 百分比)">
|
||||
<template #activator="{ props: tipProps }">
|
||||
<v-chip
|
||||
v-bind="tipProps"
|
||||
size="x-small"
|
||||
:color="loadAvgColor(load1)"
|
||||
variant="tonal"
|
||||
label
|
||||
>
|
||||
{{ formatLoadAvg(load1) }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="text-caption text-medium-emphasis mb-1">
|
||||
@@ -116,8 +157,8 @@ function pct(v: number | null | undefined) {
|
||||
|
||||
<v-btn-toggle v-model="chartMetric" density="compact" variant="outlined" divided class="mb-1">
|
||||
<v-btn value="cpu_pct" size="x-small">CPU</v-btn>
|
||||
<v-btn value="mem_pct" size="x-small">MEM</v-btn>
|
||||
<v-btn value="disk_pct" size="x-small">DISK</v-btn>
|
||||
<v-btn value="mem_pct" size="x-small">内存</v-btn>
|
||||
<v-btn value="disk_pct" size="x-small">硬盘</v-btn>
|
||||
</v-btn-toggle>
|
||||
|
||||
<WatchSparkline :points="slot.sparkline || []" :metric="chartMetric" />
|
||||
@@ -145,18 +186,26 @@ function pct(v: number | null | undefined) {
|
||||
|
||||
<div class="d-flex align-center justify-space-between mt-2">
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
剩余 {{ formatRemaining(slot.remaining_sec) }} · {{ slotTtl }}h
|
||||
剩余 {{ formatRemaining(slot.remaining_sec) }} · {{ watchTtlShortLabel(slotTtlMinutes) }}
|
||||
</span>
|
||||
<div class="d-flex ga-1">
|
||||
<v-btn size="x-small" variant="text" @click.stop="emit('processes', slot)">进程</v-btn>
|
||||
<v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="metricsPending" class="d-flex align-center justify-space-between mt-2">
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
剩余 {{ formatRemaining(slot.remaining_sec) }} · {{ watchTtlShortLabel(slotTtlMinutes) }}
|
||||
</span>
|
||||
<v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="d-flex align-center justify-space-between mt-2" @click.stop>
|
||||
<span class="text-caption text-medium-emphasis">已暂停 · {{ slotTtl }}h</span>
|
||||
<span class="text-caption text-medium-emphasis">已暂停 · {{ watchTtlLabel(slotTtlMinutes) }}</span>
|
||||
<div class="d-flex ga-1">
|
||||
<v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn>
|
||||
</div>
|
||||
@@ -168,6 +217,8 @@ function pct(v: number | null | undefined) {
|
||||
elevation="0"
|
||||
border
|
||||
rounded="lg"
|
||||
:link="false"
|
||||
:ripple="false"
|
||||
class="watch-slot-card watch-slot-card--empty d-flex align-center justify-center pa-4"
|
||||
variant="outlined"
|
||||
role="button"
|
||||
@@ -184,22 +235,27 @@ function pct(v: number | null | undefined) {
|
||||
|
||||
<style scoped>
|
||||
.watch-slot-card {
|
||||
min-height: 220px;
|
||||
min-height: 280px;
|
||||
}
|
||||
/* Vuetify v-card--link 会在 hover 时铺 v-card__overlay 灰层,此处仅保留外框高亮 */
|
||||
.watch-slot-card :deep(.v-card__overlay) {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
.watch-slot-card--empty {
|
||||
min-height: 220px;
|
||||
border-style: dashed !important;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
.watch-slot-card--empty:hover {
|
||||
background: rgba(var(--v-theme-primary), 0.04);
|
||||
border-color: rgb(var(--v-theme-primary)) !important;
|
||||
}
|
||||
.watch-slot-card--filled {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
.watch-slot-card--filled:hover {
|
||||
background: rgba(var(--v-theme-surface-variant), 0.3);
|
||||
border-color: rgb(var(--v-theme-primary)) !important;
|
||||
}
|
||||
.watch-slot-card--error {
|
||||
border-color: rgb(var(--v-theme-error)) !important;
|
||||
@@ -212,4 +268,24 @@ function pct(v: number | null | undefined) {
|
||||
flex: 0 0 auto;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
.watch-ttl-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
.watch-ttl-label {
|
||||
flex: 0 0 auto;
|
||||
line-height: 24px;
|
||||
}
|
||||
.watch-ttl-chips {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.watch-ttl-chip {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.watch-slot-waiting {
|
||||
min-height: 160px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useWatchPins, type WatchSlot } from '@/composables/useWatchPins'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { formatApiError } from '@/utils/apiError'
|
||||
import { http } from '@/api'
|
||||
import { watchTtlShortLabel, type WatchTtlMinutes } from '@/constants/watchTtl'
|
||||
|
||||
const router = useRouter()
|
||||
const snackbar = useSnackbar()
|
||||
@@ -42,12 +43,12 @@ async function onMonitoring(slotIndex: number, enabled: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onTtl(slotIndex: number, hours: 8 | 24) {
|
||||
async function onTtl(slotIndex: number, minutes: WatchTtlMinutes) {
|
||||
const slot = slots.value.find((s) => s.slot_index === slotIndex)
|
||||
if (!slot || slot.empty || slot.ttl_hours === hours) return
|
||||
if (!slot || slot.empty || slot.ttl_minutes === minutes) return
|
||||
try {
|
||||
await setSlotTtl(slotIndex, hours)
|
||||
snackbar(`监测时长已设为 ${hours} 小时 · 槽 ${slotIndex + 1}`, 'success')
|
||||
await setSlotTtl(slotIndex, minutes)
|
||||
snackbar(`监测时长已设为 ${watchTtlShortLabel(minutes)} · 槽 ${slotIndex + 1}`, 'success')
|
||||
} catch (e: unknown) {
|
||||
snackbar(formatApiError(e, '更新监测时长失败'), 'error')
|
||||
}
|
||||
@@ -114,7 +115,7 @@ async function onPickServer(serverId: number) {
|
||||
<div class="watch-slot-row mb-4">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<span class="text-subtitle-2">实时监测</span>
|
||||
<v-chip size="x-small" variant="tonal" class="ml-2" label>最多 4 台 · 默认 8h · 暂停不占 TTL</v-chip>
|
||||
<v-chip size="x-small" variant="tonal" class="ml-2" label>最多 4 台 · 默认 30 分 · 暂停不占 TTL</v-chip>
|
||||
<v-spacer />
|
||||
<v-btn size="small" variant="text" prepend-icon="mdi-history" :to="{ name: 'WatchMetrics' }">
|
||||
监测历史
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { useFilesStore } from '@/stores/files'
|
||||
import type { FileEntry } from '@/types/api'
|
||||
import { isArchiveFile } from '@/utils/fileSort'
|
||||
import { saveBlobAsFile } from '@/utils/fileDownload'
|
||||
import { invalidateCachedFile } from '@/utils/filePreload'
|
||||
import {
|
||||
joinRemotePath,
|
||||
@@ -27,6 +26,7 @@ export function useFilesActions(options: {
|
||||
previewFile: (item: FileEntry) => Promise<void>
|
||||
openInTerminal: () => void
|
||||
editorWorkbench: Ref<{ openFile: (p: OpenFilePayload) => void } | null>
|
||||
waitForEditorWorkbench?: () => Promise<{ openFile: (p: OpenFilePayload) => void } | null>
|
||||
actionLoading?: Ref<boolean>
|
||||
}) {
|
||||
const snackbar = useSnackbar()
|
||||
@@ -172,9 +172,6 @@ export function useFilesActions(options: {
|
||||
case 'preview':
|
||||
void options.previewFile(item)
|
||||
break
|
||||
case 'download':
|
||||
void downloadFile(item)
|
||||
break
|
||||
case 'copyPath':
|
||||
void copyRemotePath(item)
|
||||
break
|
||||
@@ -293,7 +290,11 @@ export function useFilesActions(options: {
|
||||
}
|
||||
|
||||
async function doNewFile() {
|
||||
if (!selectedServer.value || !newFileName.value) return
|
||||
if (!selectedServer.value) {
|
||||
snackbar('请先选择服务器', 'warning')
|
||||
return
|
||||
}
|
||||
if (!newFileName.value) return
|
||||
const check = validatePathSegment(newFileName.value)
|
||||
if (check !== true) {
|
||||
snackbar(check, 'warning')
|
||||
@@ -315,7 +316,13 @@ export function useFilesActions(options: {
|
||||
await options.browse({ force: true })
|
||||
snackbar('文件已创建')
|
||||
await nextTick()
|
||||
options.editorWorkbench.value?.openFile({
|
||||
const wb =
|
||||
(await options.waitForEditorWorkbench?.()) ?? options.editorWorkbench.value
|
||||
if (!wb?.openFile) {
|
||||
snackbar('文件已创建;编辑器未就绪,请从列表双击打开', 'warning')
|
||||
return
|
||||
}
|
||||
wb.openFile({
|
||||
path,
|
||||
name,
|
||||
content: '',
|
||||
@@ -414,22 +421,6 @@ export function useFilesActions(options: {
|
||||
else snackbar(`删除完成:成功 ${ok},失败 ${fail}`, fail > 0 ? 'warning' : 'success')
|
||||
}
|
||||
|
||||
async function downloadFile(item: FileEntry) {
|
||||
if (!selectedServer.value) return
|
||||
try {
|
||||
const remotePath = joinRemotePath(currentPath.value, item.name)
|
||||
const { blob, filename } = await http.download('/sync/download', {
|
||||
server_id: selectedServer.value,
|
||||
path: remotePath,
|
||||
})
|
||||
saveBlobAsFile(blob, filename)
|
||||
snackbar(`已下载 ${filename}`)
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '下载失败'
|
||||
snackbar(msg, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteFile(item: FileEntry) {
|
||||
deletingFile.value = item
|
||||
showFileDelete.value = true
|
||||
@@ -561,6 +552,14 @@ export function useFilesActions(options: {
|
||||
showContextMenu.value = true
|
||||
}
|
||||
|
||||
function openNewFileDialog() {
|
||||
if (!selectedServer.value) {
|
||||
snackbar('请先选择服务器', 'warning')
|
||||
return
|
||||
}
|
||||
showNewFile.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
dropActive,
|
||||
showUpload,
|
||||
@@ -600,12 +599,12 @@ export function useFilesActions(options: {
|
||||
doCompress,
|
||||
doDecompress,
|
||||
doNewFile,
|
||||
openNewFileDialog,
|
||||
startRename,
|
||||
doRename,
|
||||
startChmod,
|
||||
doChmod,
|
||||
batchDelete,
|
||||
downloadFile,
|
||||
confirmDeleteFile,
|
||||
doDeleteFile,
|
||||
doMkdir,
|
||||
|
||||
@@ -63,7 +63,7 @@ export function useFilesEditor(options: {
|
||||
function rejectOversizedFile(item: FileEntry, action: '编辑' | '查看'): boolean {
|
||||
if (exceedsEditorMaxSize(item.size)) {
|
||||
snackbar(
|
||||
`文件超过 2MB(${formatSize(item.size)}),无法${action},请下载或使用终端`,
|
||||
`文件超过 2MB(${formatSize(item.size)}),无法${action},请使用终端`,
|
||||
'warning',
|
||||
)
|
||||
return true
|
||||
@@ -155,6 +155,7 @@ export function useFilesEditor(options: {
|
||||
previewContent,
|
||||
previewLoading,
|
||||
editorWorkbench,
|
||||
waitForEditorWorkbench,
|
||||
previewFile,
|
||||
editFile,
|
||||
onEditorSaved,
|
||||
|
||||
@@ -89,6 +89,7 @@ export function useFilesPage() {
|
||||
previewFile: editor.previewFile,
|
||||
openInTerminal: nav.openInTerminal,
|
||||
editorWorkbench: editor.editorWorkbench,
|
||||
waitForEditorWorkbench: editor.waitForEditorWorkbench,
|
||||
actionLoading,
|
||||
})
|
||||
|
||||
@@ -140,7 +141,7 @@ export function useFilesPage() {
|
||||
{ title: '归属', key: 'owner', width: 110, sortable: false },
|
||||
{ title: '大小', key: 'size', width: 120 },
|
||||
{ title: '修改时间', key: 'modified', width: 180 },
|
||||
{ title: '操作', key: 'actions', width: 300, sortable: false, align: 'end' as const },
|
||||
{ title: '操作', key: 'actions', width: 160, sortable: false, align: 'end' as const },
|
||||
]
|
||||
|
||||
useFilesHotkeys(
|
||||
@@ -228,6 +229,7 @@ export function useFilesPage() {
|
||||
showNewFile: actions.showNewFile,
|
||||
newFileName: actions.newFileName,
|
||||
doNewFile: actions.doNewFile,
|
||||
openNewFileDialog: actions.openNewFileDialog,
|
||||
showMkdir: actions.showMkdir,
|
||||
mkdirName: actions.mkdirName,
|
||||
doMkdir: actions.doMkdir,
|
||||
@@ -268,7 +270,6 @@ export function useFilesPage() {
|
||||
pasteClipboard: actions.pasteClipboard,
|
||||
editFile: editor.editFile,
|
||||
previewFile: editor.previewFile,
|
||||
downloadFile: actions.downloadFile,
|
||||
confirmDeleteFile: actions.confirmDeleteFile,
|
||||
openInTerminal: nav.openInTerminal,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ import { FitAddon } from '@xterm/addon-fit'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { http } from '@/api'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { sortQuickCommands, type QuickCmdItem } from '@/composables/useTerminalQuickCommands'
|
||||
import {
|
||||
formatQuickCmdForSend,
|
||||
sortQuickCommands,
|
||||
type QuickCmdItem,
|
||||
} from '@/composables/useTerminalQuickCommands'
|
||||
import { buildWebSocketUrl } from '@/utils/wsUrl'
|
||||
import {
|
||||
createNative,
|
||||
@@ -704,7 +708,7 @@ export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
|
||||
snackbar(s?.status === 'connecting' ? '终端连接中,请稍候' : '终端未连接', 'warning')
|
||||
return
|
||||
}
|
||||
sendWsData(s.id, cmd)
|
||||
sendWsData(s.id, formatQuickCmdForSend(cmd))
|
||||
getNative(s.id)?.term?.focus()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* Global floating browser — persists tabs/history to MySQL via /api/browser/state.
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { http } from '@/api'
|
||||
import { normalizeBrowserUrl } from '@/utils/browserUrl'
|
||||
import type { FloatRect } from '@/composables/useFloatingPanel'
|
||||
|
||||
const LEGACY_HISTORY_KEY = 'nexus_browser_history_v1'
|
||||
const SAVE_DEBOUNCE_MS = 700
|
||||
|
||||
export interface BrowserVisit {
|
||||
url: string
|
||||
title: string
|
||||
at: string
|
||||
}
|
||||
|
||||
export interface BrowserTab {
|
||||
id: string
|
||||
url: string
|
||||
title: string
|
||||
stack: string[]
|
||||
stackIndex: number
|
||||
}
|
||||
|
||||
export interface BrowserStateResponse {
|
||||
url_history: string[]
|
||||
visits: BrowserVisit[]
|
||||
tabs: Array<{
|
||||
id: string
|
||||
url: string
|
||||
title?: string
|
||||
stack?: string[]
|
||||
stack_index?: number
|
||||
}>
|
||||
active_tab_id: string | null
|
||||
minimized: boolean
|
||||
rect: FloatRect | null
|
||||
minimized_rect: FloatRect | null
|
||||
}
|
||||
|
||||
const bootstrapped = ref(false)
|
||||
const saving = ref(false)
|
||||
const panelOpen = ref(false)
|
||||
const minimized = ref(false)
|
||||
const maximized = ref(false)
|
||||
const lastError = ref<string | null>(null)
|
||||
const addressDraft = ref('')
|
||||
const urlHistory = ref<string[]>([])
|
||||
const visits = ref<BrowserVisit[]>([])
|
||||
const tabs = ref<BrowserTab[]>([])
|
||||
const activeTabId = ref<string | null>(null)
|
||||
const panelRect = ref<FloatRect | null>(null)
|
||||
const minimizedRect = ref<FloatRect | null>(null)
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function tabLabel(tab: BrowserTab): string {
|
||||
try {
|
||||
return tab.title || new URL(tab.url).hostname
|
||||
} catch {
|
||||
return tab.title || tab.url
|
||||
}
|
||||
}
|
||||
|
||||
function newTabId(): string {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
function serializeTabs(): BrowserStateResponse['tabs'] {
|
||||
return tabs.value.map((t) => ({
|
||||
id: t.id,
|
||||
url: t.url,
|
||||
title: t.title,
|
||||
stack: t.stack,
|
||||
stack_index: t.stackIndex,
|
||||
}))
|
||||
}
|
||||
|
||||
function applyServerState(data: BrowserStateResponse) {
|
||||
urlHistory.value = data.url_history || []
|
||||
visits.value = (data.visits || []).map((v) => ({
|
||||
url: v.url,
|
||||
title: v.title || v.url,
|
||||
at: v.at,
|
||||
}))
|
||||
tabs.value = (data.tabs || []).map((t) => ({
|
||||
id: t.id,
|
||||
url: t.url,
|
||||
title: t.title || t.url,
|
||||
stack: t.stack?.length ? t.stack : (t.url ? [t.url] : []),
|
||||
stackIndex: typeof t.stack_index === 'number' ? t.stack_index : (t.stack?.length ?? 1) - 1,
|
||||
}))
|
||||
activeTabId.value = data.active_tab_id
|
||||
if (!activeTabId.value && tabs.value.length) {
|
||||
activeTabId.value = tabs.value[0]!.id
|
||||
}
|
||||
minimized.value = Boolean(data.minimized)
|
||||
panelRect.value = data.rect
|
||||
minimizedRect.value = data.minimized_rect
|
||||
panelOpen.value = tabs.value.length > 0 && !minimized.value
|
||||
syncAddressFromActive()
|
||||
}
|
||||
|
||||
function syncAddressFromActive() {
|
||||
const tab = activeTab.value
|
||||
addressDraft.value = tab?.url ?? ''
|
||||
}
|
||||
|
||||
const activeTab = computed(() => tabs.value.find((t) => t.id === activeTabId.value) ?? null)
|
||||
|
||||
const isActive = computed(() => tabs.value.length > 0 || panelOpen.value)
|
||||
|
||||
const canGoBack = computed(() => {
|
||||
const tab = activeTab.value
|
||||
return tab ? tab.stackIndex > 0 : false
|
||||
})
|
||||
|
||||
const canGoForward = computed(() => {
|
||||
const tab = activeTab.value
|
||||
return tab ? tab.stackIndex < tab.stack.length - 1 : false
|
||||
})
|
||||
|
||||
function scheduleSave(extra?: { recordUrl?: string; recordTitle?: string }) {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => {
|
||||
void persistState(extra)
|
||||
}, SAVE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
async function persistState(extra?: { recordUrl?: string; recordTitle?: string }) {
|
||||
saving.value = true
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
tabs: serializeTabs(),
|
||||
active_tab_id: activeTabId.value,
|
||||
minimized: minimized.value,
|
||||
rect: panelRect.value,
|
||||
minimized_rect: minimizedRect.value,
|
||||
}
|
||||
if (extra?.recordUrl) {
|
||||
body.record_url = extra.recordUrl
|
||||
if (extra.recordTitle) body.record_title = extra.recordTitle
|
||||
} else {
|
||||
body.url_history = urlHistory.value
|
||||
body.visits = visits.value
|
||||
}
|
||||
const res = await http.put<BrowserStateResponse>('/browser/state', body)
|
||||
applyServerState(res)
|
||||
} catch {
|
||||
/* keep local state */
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateLegacyHistory() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(LEGACY_HISTORY_KEY)
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return
|
||||
const urls = parsed.filter((u): u is string => typeof u === 'string' && !!normalizeBrowserUrl(u))
|
||||
if (!urls.length) return
|
||||
for (const url of [...urls].reverse()) {
|
||||
const normalized = normalizeBrowserUrl(url)
|
||||
if (normalized) {
|
||||
await persistState({ recordUrl: normalized, recordTitle: normalized })
|
||||
}
|
||||
}
|
||||
sessionStorage.removeItem(LEGACY_HISTORY_KEY)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
if (bootstrapped.value) return
|
||||
try {
|
||||
const res = await http.get<BrowserStateResponse>('/browser/state')
|
||||
applyServerState(res)
|
||||
if (!visits.value.length && !urlHistory.value.length) {
|
||||
await migrateLegacyHistory()
|
||||
}
|
||||
} catch {
|
||||
/* offline */
|
||||
} finally {
|
||||
bootstrapped.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function openPanel() {
|
||||
panelOpen.value = true
|
||||
minimized.value = false
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function minimizePanel() {
|
||||
minimized.value = true
|
||||
panelOpen.value = false
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function restartBrowser() {
|
||||
const tab: BrowserTab = {
|
||||
id: newTabId(),
|
||||
url: '',
|
||||
title: '新标签',
|
||||
stack: [],
|
||||
stackIndex: -1,
|
||||
}
|
||||
tabs.value = [tab]
|
||||
activeTabId.value = tab.id
|
||||
addressDraft.value = ''
|
||||
lastError.value = null
|
||||
openPanel()
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function openUrl(input: string, opts?: { title?: string; newTab?: boolean }) {
|
||||
const normalized = normalizeBrowserUrl(input)
|
||||
if (!normalized) {
|
||||
lastError.value = '请输入有效的 http 或 https 地址'
|
||||
return false
|
||||
}
|
||||
lastError.value = null
|
||||
|
||||
if (opts?.newTab || !tabs.value.length || !activeTab.value) {
|
||||
const tab: BrowserTab = {
|
||||
id: newTabId(),
|
||||
url: normalized,
|
||||
title: opts?.title || normalized,
|
||||
stack: [normalized],
|
||||
stackIndex: 0,
|
||||
}
|
||||
tabs.value = [...tabs.value, tab].slice(-12)
|
||||
activeTabId.value = tab.id
|
||||
} else {
|
||||
const tab = activeTab.value
|
||||
const stack = tab.stack.slice(0, tab.stackIndex + 1)
|
||||
if (!stack.length || stack[stack.length - 1] !== normalized) stack.push(normalized)
|
||||
tab.url = normalized
|
||||
if (opts?.title) tab.title = opts.title
|
||||
tab.stack = stack
|
||||
tab.stackIndex = stack.length - 1
|
||||
}
|
||||
|
||||
addressDraft.value = normalized
|
||||
openPanel()
|
||||
scheduleSave({ recordUrl: normalized, recordTitle: opts?.title })
|
||||
return true
|
||||
}
|
||||
|
||||
function navigate(input: string) {
|
||||
return openUrl(input)
|
||||
}
|
||||
|
||||
function updateActiveFromRemote(url: string, title: string) {
|
||||
const tab = activeTab.value
|
||||
if (!tab || !url) return
|
||||
tab.url = url
|
||||
if (title) tab.title = title
|
||||
addressDraft.value = url
|
||||
scheduleSave({ recordUrl: url, recordTitle: title })
|
||||
}
|
||||
|
||||
function refreshActive() {
|
||||
/* remote reload handled in panel */
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
const tab = activeTab.value
|
||||
if (!tab || tab.stackIndex <= 0) return
|
||||
tab.stackIndex -= 1
|
||||
tab.url = tab.stack[tab.stackIndex]!
|
||||
addressDraft.value = tab.url
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function goForward() {
|
||||
const tab = activeTab.value
|
||||
if (!tab || tab.stackIndex >= tab.stack.length - 1) return
|
||||
tab.stackIndex += 1
|
||||
tab.url = tab.stack[tab.stackIndex]!
|
||||
addressDraft.value = tab.url
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function openExternal() {
|
||||
const tab = activeTab.value
|
||||
if (!tab?.url) return
|
||||
window.open(tab.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
function switchTab(id: string) {
|
||||
activeTabId.value = id
|
||||
syncAddressFromActive()
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function closeTab(id: string) {
|
||||
if (tabs.value.length <= 1) return
|
||||
const idx = tabs.value.findIndex((t) => t.id === id)
|
||||
if (idx < 0) return
|
||||
const next = tabs.value.filter((t) => t.id !== id)
|
||||
tabs.value = next
|
||||
if (activeTabId.value === id) {
|
||||
activeTabId.value = next[idx]?.id ?? next[idx - 1]?.id ?? null
|
||||
}
|
||||
syncAddressFromActive()
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function newEmptyTab() {
|
||||
const tab: BrowserTab = {
|
||||
id: newTabId(),
|
||||
url: '',
|
||||
title: '新标签',
|
||||
stack: [],
|
||||
stackIndex: -1,
|
||||
}
|
||||
tabs.value = [...tabs.value, tab].slice(-12)
|
||||
activeTabId.value = tab.id
|
||||
addressDraft.value = ''
|
||||
openPanel()
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function openFromHistory(url: string) {
|
||||
openUrl(url)
|
||||
}
|
||||
|
||||
function savePanelRect(rect: FloatRect) {
|
||||
panelRect.value = rect
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function saveMinimizedRect(rect: FloatRect) {
|
||||
minimizedRect.value = rect
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
export function useGlobalBrowser() {
|
||||
return {
|
||||
bootstrapped,
|
||||
saving,
|
||||
panelOpen,
|
||||
minimized,
|
||||
maximized,
|
||||
lastError,
|
||||
addressDraft,
|
||||
urlHistory,
|
||||
visits,
|
||||
tabs,
|
||||
activeTabId,
|
||||
activeTab,
|
||||
panelRect,
|
||||
minimizedRect,
|
||||
isActive,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
tabLabel,
|
||||
bootstrap,
|
||||
openPanel,
|
||||
minimizePanel,
|
||||
restartBrowser,
|
||||
openUrl,
|
||||
navigate,
|
||||
updateActiveFromRemote,
|
||||
refreshActive,
|
||||
goBack,
|
||||
goForward,
|
||||
openExternal,
|
||||
switchTab,
|
||||
closeTab,
|
||||
newEmptyTab,
|
||||
openFromHistory,
|
||||
savePanelRect,
|
||||
saveMinimizedRect,
|
||||
scheduleSave,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Remote browser session — canvas screencast over /ws/browser/{session_id}.
|
||||
*/
|
||||
import { onUnmounted, ref, shallowRef } from 'vue'
|
||||
import { http } from '@/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { buildWebSocketUrl } from '@/utils/wsUrl'
|
||||
|
||||
export interface BrowserSessionInfo {
|
||||
session_id: string
|
||||
viewport_w: number
|
||||
viewport_h: number
|
||||
}
|
||||
|
||||
export function useRemoteBrowserSession() {
|
||||
const connected = ref(false)
|
||||
const connecting = ref(false)
|
||||
const remoteError = ref<string | null>(null)
|
||||
const sessionId = shallowRef<string | null>(null)
|
||||
const viewportW = ref(1280)
|
||||
const viewportH = ref(720)
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let canvasEl: HTMLCanvasElement | null = null
|
||||
let onPageInfo: ((url: string, title: string) => void) | null = null
|
||||
let dragActive = false
|
||||
let lastPointerX = 0
|
||||
let lastPointerY = 0
|
||||
|
||||
function setCanvas(el: HTMLCanvasElement | null) {
|
||||
canvasEl = el
|
||||
}
|
||||
|
||||
function setPageInfoHandler(fn: (url: string, title: string) => void) {
|
||||
onPageInfo = fn
|
||||
}
|
||||
|
||||
function send(msg: Record<string, unknown>) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
ws.send(JSON.stringify(msg))
|
||||
}
|
||||
|
||||
function drawFrame(dataB64: string, metadata?: { deviceWidth?: number; deviceHeight?: number }) {
|
||||
if (!canvasEl || !dataB64) return
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
if (!canvasEl) return
|
||||
const w = metadata?.deviceWidth || img.width
|
||||
const h = metadata?.deviceHeight || img.height
|
||||
if (canvasEl.width !== w) canvasEl.width = w
|
||||
if (canvasEl.height !== h) canvasEl.height = h
|
||||
const ctx = canvasEl.getContext('2d')
|
||||
ctx?.drawImage(img, 0, 0, w, h)
|
||||
}
|
||||
img.src = `data:image/jpeg;base64,${dataB64}`
|
||||
}
|
||||
|
||||
function canvasCoords(event: MouseEvent): { x: number; y: number } {
|
||||
if (!canvasEl) return { x: 0, y: 0 }
|
||||
const rect = canvasEl.getBoundingClientRect()
|
||||
const scaleX = canvasEl.width / rect.width
|
||||
const scaleY = canvasEl.height / rect.height
|
||||
return {
|
||||
x: (event.clientX - rect.left) * scaleX,
|
||||
y: (event.clientY - rect.top) * scaleY,
|
||||
}
|
||||
}
|
||||
|
||||
function sendPointerMove(x: number, y: number) {
|
||||
const dx = x - lastPointerX
|
||||
const dy = y - lastPointerY
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
const steps = Math.max(1, Math.min(25, Math.ceil(dist / 6)))
|
||||
send({ type: 'MOUSE', event: 'move', x, y, steps })
|
||||
lastPointerX = x
|
||||
lastPointerY = y
|
||||
}
|
||||
|
||||
function onWindowMouseMove(e: MouseEvent) {
|
||||
if (!dragActive || !canvasEl) return
|
||||
const { x, y } = canvasCoords(e)
|
||||
sendPointerMove(x, y)
|
||||
}
|
||||
|
||||
function endPointerDrag(e: MouseEvent) {
|
||||
if (!dragActive) return
|
||||
dragActive = false
|
||||
window.removeEventListener('mousemove', onWindowMouseMove)
|
||||
window.removeEventListener('mouseup', endPointerDrag)
|
||||
const { x, y } = canvasCoords(e)
|
||||
const button = e.button === 2 ? 'right' : e.button === 1 ? 'middle' : 'left'
|
||||
send({ type: 'MOUSE', event: 'up', x, y, button })
|
||||
}
|
||||
|
||||
function onCanvasMouseMove(e: MouseEvent) {
|
||||
if (dragActive) return
|
||||
const { x, y } = canvasCoords(e)
|
||||
sendPointerMove(x, y)
|
||||
}
|
||||
|
||||
function onCanvasMouseDown(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
const { x, y } = canvasCoords(e)
|
||||
lastPointerX = x
|
||||
lastPointerY = y
|
||||
const button = e.button === 2 ? 'right' : e.button === 1 ? 'middle' : 'left'
|
||||
send({ type: 'MOUSE', event: 'down', x, y, button })
|
||||
dragActive = true
|
||||
window.addEventListener('mousemove', onWindowMouseMove)
|
||||
window.addEventListener('mouseup', endPointerDrag)
|
||||
}
|
||||
|
||||
function onCanvasMouseUp(e: MouseEvent) {
|
||||
endPointerDrag(e)
|
||||
}
|
||||
|
||||
function onCanvasWheel(e: WheelEvent) {
|
||||
e.preventDefault()
|
||||
const { x, y } = canvasCoords(e)
|
||||
send({ type: 'MOUSE', event: 'wheel', x, y, delta_y: e.deltaY })
|
||||
}
|
||||
|
||||
function onCanvasKeyDown(e: KeyboardEvent) {
|
||||
e.preventDefault()
|
||||
send({ type: 'KEY', event: 'down', key: e.key, code: e.code })
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
send({ type: 'KEY', event: 'type', text: e.key })
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
connected.value = false
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
sessionId.value = null
|
||||
}
|
||||
|
||||
async function connect(viewportWIn = 1280, viewportHIn = 720) {
|
||||
if (connecting.value || connected.value) return
|
||||
connecting.value = true
|
||||
remoteError.value = null
|
||||
try {
|
||||
const status = await http.get<{ enabled: boolean }>('/browser/status')
|
||||
if (!status.enabled) {
|
||||
remoteError.value = '服务端浏览器未启用'
|
||||
return
|
||||
}
|
||||
const created = await http.post<BrowserSessionInfo>('/browser/sessions', {
|
||||
viewport_w: viewportWIn,
|
||||
viewport_h: viewportHIn,
|
||||
})
|
||||
sessionId.value = created.session_id
|
||||
viewportW.value = created.viewport_w
|
||||
viewportH.value = created.viewport_h
|
||||
|
||||
const token = useAuthStore().token
|
||||
if (!token) {
|
||||
remoteError.value = '未登录'
|
||||
await http.delete(`/browser/sessions/${created.session_id}`).catch(() => {})
|
||||
sessionId.value = null
|
||||
return
|
||||
}
|
||||
const url = buildWebSocketUrl(`/ws/browser/${created.session_id}`, { token })
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = new WebSocket(url)
|
||||
ws = socket
|
||||
socket.onopen = () => {
|
||||
connected.value = true
|
||||
resolve()
|
||||
}
|
||||
socket.onerror = () => reject(new Error('WebSocket 连接失败'))
|
||||
socket.onclose = () => {
|
||||
connected.value = false
|
||||
}
|
||||
socket.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(String(ev.data)) as Record<string, unknown>
|
||||
const type = msg.type as string
|
||||
if (type === 'SCREENCAST_FRAME') {
|
||||
drawFrame(String(msg.data_b64 || ''), msg.metadata as { deviceWidth?: number; deviceHeight?: number })
|
||||
} else if (type === 'PAGE_INFO') {
|
||||
onPageInfo?.(String(msg.url || ''), String(msg.title || ''))
|
||||
} else if (type === 'ERROR') {
|
||||
remoteError.value = String(msg.message || '远程浏览器错误')
|
||||
} else if (type === 'BROWSER_INIT') {
|
||||
viewportW.value = Number(msg.viewport_w) || viewportW.value
|
||||
viewportH.value = Number(msg.viewport_h) || viewportH.value
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed */
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (wsErr) {
|
||||
await http.delete(`/browser/sessions/${created.session_id}`).catch(() => {})
|
||||
throw wsErr
|
||||
}
|
||||
} catch (e) {
|
||||
remoteError.value = e instanceof Error ? e.message : '无法连接远程浏览器'
|
||||
disconnect()
|
||||
} finally {
|
||||
connecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function navigate(url: string) {
|
||||
send({ type: 'NAVIGATE', url })
|
||||
}
|
||||
|
||||
function reload() {
|
||||
send({ type: 'RELOAD' })
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
send({ type: 'GO_BACK' })
|
||||
}
|
||||
|
||||
function goForward() {
|
||||
send({ type: 'GO_FORWARD' })
|
||||
}
|
||||
|
||||
function resizeViewport(width: number, height: number) {
|
||||
send({ type: 'VIEWPORT', width, height })
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('mousemove', onWindowMouseMove)
|
||||
window.removeEventListener('mouseup', endPointerDrag)
|
||||
disconnect()
|
||||
})
|
||||
|
||||
return {
|
||||
connected,
|
||||
connecting,
|
||||
remoteError,
|
||||
sessionId,
|
||||
viewportW,
|
||||
viewportH,
|
||||
setCanvas,
|
||||
setPageInfoHandler,
|
||||
connect,
|
||||
disconnect,
|
||||
navigate,
|
||||
reload,
|
||||
goBack,
|
||||
goForward,
|
||||
resizeViewport,
|
||||
onCanvasMouseMove,
|
||||
onCanvasMouseDown,
|
||||
onCanvasMouseUp,
|
||||
onCanvasWheel,
|
||||
onCanvasKeyDown,
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,12 @@ export function sortQuickCommands(items: QuickCmdItem[]): QuickCmdItem[] {
|
||||
})
|
||||
}
|
||||
|
||||
/** 快捷命令点击执行时追加回车(与命令栏 sendCmd 一致);已含 \\r/\\n 时不重复追加 */
|
||||
export function formatQuickCmdForSend(cmd: string): string {
|
||||
const body = cmd.replace(/[\r\n]+$/, '')
|
||||
return body + '\r'
|
||||
}
|
||||
|
||||
export function useTerminalQuickCommands() {
|
||||
const commands = ref<QuickCmdItem[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
@@ -7,6 +7,8 @@ import { buildWebSocketUrl } from '@/utils/wsUrl'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { formatApiError } from '@/utils/apiError'
|
||||
|
||||
import type { WatchTtlMinutes } from '@/constants/watchTtl'
|
||||
|
||||
export interface WatchMetrics {
|
||||
probe_status?: string
|
||||
source?: string
|
||||
@@ -14,6 +16,9 @@ export interface WatchMetrics {
|
||||
cpu_pct?: number | null
|
||||
mem_pct?: number | null
|
||||
disk_pct?: number | null
|
||||
load_1?: number | null
|
||||
load_5?: number | null
|
||||
load_15?: number | null
|
||||
net_up_bps?: number | null
|
||||
net_down_bps?: number | null
|
||||
disk_read_bps?: number | null
|
||||
@@ -49,7 +54,9 @@ export interface WatchSlot {
|
||||
expires_at?: string
|
||||
remaining_sec?: number
|
||||
monitoring_enabled?: boolean
|
||||
ttl_hours?: 8 | 24
|
||||
ttl_minutes?: WatchTtlMinutes
|
||||
/** @deprecated 使用 ttl_minutes */
|
||||
ttl_hours?: number
|
||||
metrics?: WatchMetrics | null
|
||||
sparkline?: WatchSparkPoint[]
|
||||
processes?: WatchProcess[]
|
||||
@@ -78,11 +85,14 @@ function tickCountdown() {
|
||||
let shouldRefresh = false
|
||||
for (const slot of slots.value) {
|
||||
if (slot.empty || slot.monitoring_enabled === false) continue
|
||||
if (slot.remaining_sec != null && slot.remaining_sec > 0) {
|
||||
slot.remaining_sec -= 1
|
||||
if (slot.remaining_sec <= 0) {
|
||||
shouldRefresh = true
|
||||
}
|
||||
if (slot.remaining_sec == null) continue
|
||||
if (slot.remaining_sec <= 0) {
|
||||
shouldRefresh = true
|
||||
continue
|
||||
}
|
||||
slot.remaining_sec -= 1
|
||||
if (slot.remaining_sec <= 0) {
|
||||
shouldRefresh = true
|
||||
}
|
||||
}
|
||||
if (shouldRefresh) {
|
||||
@@ -196,12 +206,10 @@ async function setSlotMonitoring(slotIndex: number, monitoringEnabled: boolean)
|
||||
applySlots(data)
|
||||
}
|
||||
|
||||
export type WatchTtlHours = 8 | 24
|
||||
|
||||
async function setSlotTtl(slotIndex: number, ttlHours: WatchTtlHours) {
|
||||
async function setSlotTtl(slotIndex: number, ttlMinutes: WatchTtlMinutes) {
|
||||
const data = await http.patch<{ slots: WatchSlot[] }>(
|
||||
`/watch/pins/${slotIndex}/ttl`,
|
||||
{ ttl_hours: ttlHours },
|
||||
{ ttl_minutes: ttlMinutes },
|
||||
)
|
||||
applySlots(data)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/** Watch slot monitoring duration options (minutes). */
|
||||
|
||||
export type WatchTtlMinutes = 30 | 60 | 120 | 480 | 1440
|
||||
|
||||
export const WATCH_TTL_DEFAULT_MINUTES: WatchTtlMinutes = 30
|
||||
|
||||
export const WATCH_TTL_OPTIONS: { minutes: WatchTtlMinutes; label: string }[] = [
|
||||
{ minutes: 30, label: '30分' },
|
||||
{ minutes: 60, label: '1时' },
|
||||
{ minutes: 120, label: '2时' },
|
||||
{ minutes: 480, label: '8时' },
|
||||
{ minutes: 1440, label: '24时' },
|
||||
]
|
||||
|
||||
export function normalizeWatchTtlMinutes(raw: number | null | undefined): WatchTtlMinutes {
|
||||
const allowed = new Set(WATCH_TTL_OPTIONS.map((o) => o.minutes))
|
||||
if (raw != null && allowed.has(raw as WatchTtlMinutes)) return raw as WatchTtlMinutes
|
||||
if (raw === 8) return 480
|
||||
if (raw === 24) return 1440
|
||||
return WATCH_TTL_DEFAULT_MINUTES
|
||||
}
|
||||
|
||||
export function watchTtlLabel(minutes: WatchTtlMinutes): string {
|
||||
return WATCH_TTL_OPTIONS.find((o) => o.minutes === minutes)?.label ?? `${minutes}分`
|
||||
}
|
||||
|
||||
export function watchTtlShortLabel(minutes: WatchTtlMinutes): string {
|
||||
const map: Record<WatchTtlMinutes, string> = {
|
||||
30: '30分钟',
|
||||
60: '1小时',
|
||||
120: '2小时',
|
||||
480: '8小时',
|
||||
1440: '24小时',
|
||||
}
|
||||
return map[minutes] ?? `${minutes}分钟`
|
||||
}
|
||||
@@ -67,7 +67,7 @@
|
||||
|
||||
<template #item.target="{ item }">
|
||||
<span class="text-body-2">
|
||||
{{ auditTargetLabel(item.resource_type) }}{{ item.resource_id ? ` #${item.resource_id}` : '' }}
|
||||
{{ auditTargetText(item) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -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, auditActionFilterOptions } from '@/utils/auditLabels'
|
||||
import { auditActionLabel, auditTargetLabel, auditTargetText, auditActionFilterOptions } from '@/utils/auditLabels'
|
||||
import {
|
||||
formatAuditBatchDetailText,
|
||||
isBatchJobAuditAction,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="pa-4">
|
||||
<p class="text-body-2 text-medium-emphasis">正在打开全局浏览器…</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
|
||||
|
||||
const router = useRouter()
|
||||
const { openPanel, newEmptyTab, tabs } = useGlobalBrowser()
|
||||
|
||||
onMounted(() => {
|
||||
if (!tabs.value.length) newEmptyTab()
|
||||
else openPanel()
|
||||
router.replace('/')
|
||||
})
|
||||
</script>
|
||||
@@ -738,6 +738,7 @@ 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'
|
||||
@@ -875,6 +876,7 @@ async function confirmReplaceSlot(slotIndex: number) {
|
||||
}
|
||||
}
|
||||
const router = useRouter()
|
||||
const globalBrowser = useGlobalBrowser()
|
||||
const route = useRoute()
|
||||
const showCredentialsDialog = ref(false)
|
||||
|
||||
@@ -1545,7 +1547,7 @@ function openBrowser(item: ServerApiItem) {
|
||||
snackbar('该服务器无有效域名', 'warning')
|
||||
return
|
||||
}
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
globalBrowser.openUrl(url, { title: item.name || url })
|
||||
}
|
||||
function openFiles(item: ServerApiItem) {
|
||||
router.push({ path: '/files', query: { server_id: String(item.id) } })
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
<v-chip v-if="telegramTokenSet && !telegramTokenDraft" size="small" color="success" variant="tonal" class="mb-3">Token 已配置</v-chip>
|
||||
<v-text-field v-model="settings.telegram_chat_id" label="Chat ID" variant="outlined" density="compact" class="mb-3" />
|
||||
<p class="text-caption text-medium-emphasis mb-3">
|
||||
在 Telegram 向 Bot 发送任意消息后,可点击下方按钮自动检测 Chat ID。
|
||||
私聊:向 Bot 发送任意消息。群聊:将 Bot 拉入群后发送 <code>/start@你的Bot用户名</code>(或 @提及 Bot),再点击下方检测。
|
||||
</p>
|
||||
<div class="d-flex flex-wrap ga-2 mb-4">
|
||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-radar" @click="detectTelegramChats" :loading="detectingTgChats">检测 Chat ID</v-btn>
|
||||
@@ -165,6 +165,72 @@
|
||||
/>
|
||||
</template>
|
||||
|
||||
<v-divider class="my-3" />
|
||||
<div class="text-subtitle-2 mb-1">子机离线告警专用 Bot(可选)</div>
|
||||
<p class="text-caption text-medium-emphasis mb-3">
|
||||
与上方默认 Bot 分离:仅「子机离线告警」走此通道。Bot Token 与 Chat ID 均填写后生效;留空则离线告警仍走默认 Bot。
|
||||
</p>
|
||||
<v-text-field
|
||||
v-model="telegramOfflineTokenDraft"
|
||||
label="离线告警 Bot Token"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
:placeholder="telegramOfflineTokenSet ? '已配置(留空保持不变)' : '输入专用 Bot Token'"
|
||||
:type="showOfflineToken ? 'text' : 'password'"
|
||||
:append-inner-icon="showOfflineToken ? 'mdi-eye-off' : 'mdi-eye'"
|
||||
@click:append-inner="toggleOfflineTelegramTokenVisibility"
|
||||
/>
|
||||
<v-chip
|
||||
v-if="telegramOfflineTokenSet && !telegramOfflineTokenDraft"
|
||||
size="small"
|
||||
color="success"
|
||||
variant="tonal"
|
||||
class="mb-3"
|
||||
>
|
||||
离线 Token 已配置
|
||||
</v-chip>
|
||||
<v-text-field
|
||||
v-model="settings.telegram_offline_chat_id"
|
||||
label="离线告警 Chat ID"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
/>
|
||||
<p class="text-caption text-medium-emphasis mb-3">
|
||||
检测规则与默认 Bot 相同:群聊需拉入专用 Bot 后在群内发送 <code>/start@Bot用户名</code>。
|
||||
</p>
|
||||
<div class="d-flex flex-wrap ga-2 mb-2">
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-radar"
|
||||
:loading="detectingOfflineTgChats"
|
||||
@click="detectOfflineTelegramChats"
|
||||
>
|
||||
检测离线 Chat ID
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-message-text"
|
||||
:loading="testingOfflineTg"
|
||||
@click="testOfflineTelegram"
|
||||
>
|
||||
测试离线通道
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
prepend-icon="mdi-content-save"
|
||||
:loading="savingOfflineTelegram"
|
||||
@click="saveOfflineTelegramConfig"
|
||||
>
|
||||
保存离线 Bot
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-4" />
|
||||
<div class="text-subtitle-2 mb-1">Layer 3 宿主机巡检</div>
|
||||
<p class="text-caption text-medium-emphasis mb-3">
|
||||
@@ -387,14 +453,14 @@
|
||||
<v-card-title>选择 Chat ID</v-card-title>
|
||||
<v-card-text>
|
||||
<div v-if="!tgChats.length" class="text-body-2 text-medium-emphasis">
|
||||
未检测到对话。请先在 Telegram 向 Bot 发送一条消息,然后重新检测。
|
||||
未检测到对话。私聊请向 Bot 发消息;群聊请将 Bot 拉入群后在群内发送 <code>/start@Bot用户名</code>,然后重新检测。
|
||||
</div>
|
||||
<v-list v-else density="compact" lines="two">
|
||||
<v-list-item
|
||||
v-for="chat in tgChats"
|
||||
:key="chat.id"
|
||||
:title="chat.title || chat.username || `Chat ${chat.id}`"
|
||||
:subtitle="`${chat.type}${chat.username ? ' · @' + chat.username : ''} · ID ${chat.id}`"
|
||||
:subtitle="`${formatTelegramChatType(chat.type)}${chat.username ? ' · @' + chat.username : ''} · ID ${chat.id}`"
|
||||
@click="applyTelegramChat(chat)"
|
||||
/>
|
||||
</v-list>
|
||||
@@ -475,9 +541,17 @@ const settings = ref({
|
||||
cpu_alert_threshold: 80, mem_alert_threshold: 80, disk_alert_threshold: 80,
|
||||
db_pool_size: 10, db_max_overflow: 20,
|
||||
telegram_bot_token: '', telegram_chat_id: '',
|
||||
telegram_offline_chat_id: '',
|
||||
})
|
||||
const telegramTokenDraft = ref('')
|
||||
const telegramTokenSet = ref(false)
|
||||
const telegramOfflineTokenDraft = ref('')
|
||||
const telegramOfflineTokenSet = ref(false)
|
||||
const showOfflineToken = ref(false)
|
||||
const savingOfflineTelegram = ref(false)
|
||||
const testingOfflineTg = ref(false)
|
||||
const detectingOfflineTgChats = ref(false)
|
||||
const tgChatTarget = ref<'default' | 'offline'>('default')
|
||||
const saving = ref(false)
|
||||
const systemSettingsFormRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null)
|
||||
const testingTg = ref(false)
|
||||
@@ -501,6 +575,16 @@ const notifyToggleGroups = computed(() => {
|
||||
return groups
|
||||
})
|
||||
|
||||
function formatTelegramChatType(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
private: '私聊',
|
||||
group: '群组',
|
||||
supergroup: '群组',
|
||||
channel: '频道',
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
function parseNotifyEnabled(val: unknown): boolean {
|
||||
const s = String(val ?? 'true').trim().toLowerCase()
|
||||
return !['false', '0', 'no', 'off'].includes(s)
|
||||
@@ -554,6 +638,11 @@ async function loadSettings(silent = false) {
|
||||
telegramTokenDraft.value = ''
|
||||
continue
|
||||
}
|
||||
if (key === 'telegram_offline_bot_token') {
|
||||
telegramOfflineTokenSet.value = Boolean(item.value_set)
|
||||
telegramOfflineTokenDraft.value = ''
|
||||
continue
|
||||
}
|
||||
if (key === 'script_exec_complete_sound') {
|
||||
scriptExecCompleteSound.value = normalizeScriptCompleteSound(item.value)
|
||||
continue
|
||||
@@ -697,6 +786,109 @@ async function saveTelegramConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
async function persistOfflineTelegramConfig(options?: { requireToken?: boolean; requireChatId?: boolean }): Promise<boolean> {
|
||||
const tokenUpdate = telegramOfflineTokenDraft.value.trim()
|
||||
const chatId = String(settings.value.telegram_offline_chat_id || '').trim()
|
||||
|
||||
if (options?.requireToken && !tokenUpdate && !telegramOfflineTokenSet.value) {
|
||||
snackbar('请先填写离线告警 Bot Token', 'error')
|
||||
return false
|
||||
}
|
||||
if (options?.requireChatId && !chatId) {
|
||||
snackbar('请先填写或检测离线告警 Chat ID', 'error')
|
||||
return false
|
||||
}
|
||||
|
||||
if (tokenUpdate) {
|
||||
await http.put('/settings/telegram_offline_bot_token', { value: tokenUpdate })
|
||||
telegramOfflineTokenSet.value = true
|
||||
telegramOfflineTokenDraft.value = ''
|
||||
showOfflineToken.value = false
|
||||
}
|
||||
if (chatId) {
|
||||
await http.put('/settings/telegram_offline_chat_id', { value: chatId })
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function saveOfflineTelegramConfig() {
|
||||
savingOfflineTelegram.value = true
|
||||
try {
|
||||
const tokenUpdate = telegramOfflineTokenDraft.value.trim()
|
||||
const chatId = String(settings.value.telegram_offline_chat_id || '').trim()
|
||||
if (!tokenUpdate && !chatId && !telegramOfflineTokenSet.value) {
|
||||
snackbar('请先填写离线 Bot Token 或 Chat ID', 'warning')
|
||||
return
|
||||
}
|
||||
await persistOfflineTelegramConfig()
|
||||
snackbar('离线告警 Bot 已保存')
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '保存失败', 'error')
|
||||
} finally {
|
||||
savingOfflineTelegram.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testOfflineTelegram() {
|
||||
testingOfflineTg.value = true
|
||||
try {
|
||||
if (!await persistOfflineTelegramConfig({ requireToken: true, requireChatId: true })) return
|
||||
await http.post('/settings/telegram/offline/test')
|
||||
snackbar('离线通道测试消息已发送')
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '发送失败', 'error')
|
||||
} finally {
|
||||
testingOfflineTg.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function detectOfflineTelegramChats() {
|
||||
detectingOfflineTgChats.value = true
|
||||
try {
|
||||
if (!await persistOfflineTelegramConfig({ requireToken: true })) return
|
||||
const res = await http.get<TelegramChatsResponse>('/settings/telegram/offline/chats')
|
||||
tgChats.value = res.chats || []
|
||||
if (!tgChats.value.length) {
|
||||
snackbar('未检测到对话:私聊发消息,或群聊拉入 Bot 后发送 /start@Bot用户名', 'warning')
|
||||
return
|
||||
}
|
||||
tgChatTarget.value = 'offline'
|
||||
tgChatDialog.value = true
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '检测失败', 'error')
|
||||
} finally {
|
||||
detectingOfflineTgChats.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleOfflineTelegramTokenVisibility() {
|
||||
if (showOfflineToken.value) {
|
||||
showOfflineToken.value = false
|
||||
if (telegramOfflineTokenSet.value) {
|
||||
telegramOfflineTokenDraft.value = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
if (telegramOfflineTokenDraft.value.trim()) {
|
||||
showOfflineToken.value = true
|
||||
return
|
||||
}
|
||||
if (!telegramOfflineTokenSet.value) {
|
||||
showOfflineToken.value = true
|
||||
return
|
||||
}
|
||||
revealingTelegramToken.value = true
|
||||
try {
|
||||
const res = await http.post<{ value: string }>('/settings/telegram/offline/reveal-token', {})
|
||||
telegramOfflineTokenDraft.value = res.value
|
||||
showOfflineToken.value = true
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '获取离线 Bot Token 失败', 'error')
|
||||
} finally {
|
||||
revealingTelegramToken.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onScriptExecSoundChange(value: ScriptCompleteSoundId | null) {
|
||||
if (!value || scriptSoundSaving.value) return
|
||||
const prev = scriptExecCompleteSound.value
|
||||
@@ -769,9 +961,10 @@ async function detectTelegramChats() {
|
||||
const res = await http.get<TelegramChatsResponse>('/settings/telegram/chats')
|
||||
tgChats.value = res.chats || []
|
||||
if (!tgChats.value.length) {
|
||||
snackbar('未检测到对话,请先向 Bot 发送一条消息后再试', 'warning')
|
||||
snackbar('未检测到对话:私聊发消息,或群聊拉入 Bot 后发送 /start@Bot用户名', 'warning')
|
||||
return
|
||||
}
|
||||
tgChatTarget.value = 'default'
|
||||
tgChatDialog.value = true
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '检测失败', 'error')
|
||||
@@ -782,10 +975,16 @@ async function detectTelegramChats() {
|
||||
|
||||
async function applyTelegramChat(chat: TelegramChatItem) {
|
||||
const chatId = String(chat.id)
|
||||
settings.value.telegram_chat_id = chatId
|
||||
const isOffline = tgChatTarget.value === 'offline'
|
||||
if (isOffline) {
|
||||
settings.value.telegram_offline_chat_id = chatId
|
||||
} else {
|
||||
settings.value.telegram_chat_id = chatId
|
||||
}
|
||||
tgChatDialog.value = false
|
||||
try {
|
||||
await http.put('/settings/telegram_chat_id', { value: chatId })
|
||||
const key = isOffline ? 'telegram_offline_chat_id' : 'telegram_chat_id'
|
||||
await http.put(`/settings/${key}`, { value: chatId })
|
||||
snackbar(`已选择 Chat ID: ${chatId}`)
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '保存 Chat ID 失败', 'error')
|
||||
|
||||
@@ -101,7 +101,11 @@
|
||||
<TerminalServerPicker
|
||||
mode="sidebar"
|
||||
:servers="filteredServers"
|
||||
:search="serverSearch"
|
||||
:search-history="terminalSearchHistory"
|
||||
:loading="serversLoading"
|
||||
@update:search="onServerSearchUpdate"
|
||||
@search-commit="commitServerSearch"
|
||||
@select="(id) => newSession(id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -8,11 +8,11 @@ import { useWatchPins } from '@/composables/useWatchPins'
|
||||
|
||||
defineOptions({ name: 'WatchMetricsPage' })
|
||||
|
||||
const WATCH_SLOT_COUNT = 4
|
||||
|
||||
const route = useRoute()
|
||||
const tab = ref('trend')
|
||||
const hours = ref(24)
|
||||
const serverId = ref<number | null>(null)
|
||||
const sessionId = ref<number | null>(null)
|
||||
const points = ref<WatchTrendPoint[]>([])
|
||||
const loading = ref(false)
|
||||
const selectedSeries = ref<Array<'cpu_pct' | 'mem_pct' | 'disk_pct' | 'net_up_bps' | 'net_down_bps'>>([
|
||||
@@ -21,21 +21,47 @@ const selectedSeries = ref<Array<'cpu_pct' | 'mem_pct' | 'disk_pct' | 'net_up_bp
|
||||
|
||||
const { slots, refreshPins } = useWatchPins()
|
||||
|
||||
const serverOptions = computed(() =>
|
||||
slots.value
|
||||
.filter((s) => !s.empty && s.server_id)
|
||||
.map((s) => ({ title: s.server_name || `ID ${s.server_id}`, value: s.server_id! })),
|
||||
const slotPanels = computed(() =>
|
||||
Array.from({ length: WATCH_SLOT_COUNT }, (_, slotIndex) => {
|
||||
const slot = slots.value.find((s) => s.slot_index === slotIndex)
|
||||
const empty = !slot || slot.empty
|
||||
return {
|
||||
slotIndex,
|
||||
empty,
|
||||
serverId: empty ? null : (slot.server_id ?? null),
|
||||
serverName: empty ? '' : (slot.server_name || `ID ${slot.server_id}`),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const pinnedServerIds = computed(() =>
|
||||
slotPanels.value
|
||||
.map((p) => p.serverId)
|
||||
.filter((id): id is number => id != null),
|
||||
)
|
||||
|
||||
const pointsByServer = computed(() => {
|
||||
const map = new Map<number, WatchTrendPoint[]>()
|
||||
for (const p of points.value) {
|
||||
const sid = p.server_id
|
||||
if (sid == null) continue
|
||||
const list = map.get(sid) ?? []
|
||||
list.push(p)
|
||||
map.set(sid, list)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
async function loadTrend() {
|
||||
if (!serverId.value) {
|
||||
const ids = pinnedServerIds.value
|
||||
if (!ids.length) {
|
||||
points.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await http.get<{ points: WatchTrendPoint[] }>('/watch/metrics', {
|
||||
server_ids: String(serverId.value),
|
||||
server_ids: ids.join(','),
|
||||
hours: hours.value,
|
||||
})
|
||||
points.value = data.points || []
|
||||
@@ -46,22 +72,18 @@ async function loadTrend() {
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshPins()
|
||||
const qSid = route.query.server_id
|
||||
const qSess = route.query.session_id
|
||||
if (qSid) serverId.value = Number(qSid)
|
||||
if (qSess) sessionId.value = Number(qSess)
|
||||
if (!serverId.value && serverOptions.value.length) {
|
||||
serverId.value = serverOptions.value[0].value
|
||||
}
|
||||
const qTab = route.query.tab
|
||||
if (qTab === 'records') tab.value = 'records'
|
||||
})
|
||||
|
||||
watch([serverId, hours], loadTrend, { immediate: true })
|
||||
watch([pinnedServerIds, hours], loadTrend, { immediate: true, deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container fluid class="pa-4 pa-md-6">
|
||||
<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-spacer />
|
||||
<v-btn variant="text" prepend-icon="mdi-arrow-left" :to="{ name: 'Servers' }">返回服务器</v-btn>
|
||||
</div>
|
||||
@@ -76,17 +98,6 @@ watch([serverId, hours], loadTrend, { immediate: true })
|
||||
<v-window v-model="tab">
|
||||
<v-window-item value="trend">
|
||||
<div class="d-flex flex-wrap ga-2 mb-4">
|
||||
<v-select
|
||||
v-model="serverId"
|
||||
:items="serverOptions"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
label="服务器"
|
||||
density="compact"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
style="min-width: 200px"
|
||||
/>
|
||||
<v-btn-toggle v-model="hours" mandatory density="compact" variant="outlined" divided>
|
||||
<v-btn :value="1" size="small">1h</v-btn>
|
||||
<v-btn :value="6" size="small">6h</v-btn>
|
||||
@@ -104,7 +115,7 @@ watch([serverId, hours], loadTrend, { immediate: true })
|
||||
]"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
label="曲线"
|
||||
label="曲线(各槽共用)"
|
||||
multiple
|
||||
chips
|
||||
density="compact"
|
||||
@@ -114,20 +125,62 @@ watch([serverId, hours], loadTrend, { immediate: true })
|
||||
/>
|
||||
</div>
|
||||
<v-progress-linear v-if="loading" indeterminate class="mb-2" />
|
||||
<WatchTrendChart v-if="points.length" :points="points" :series="selectedSeries" />
|
||||
<div v-else class="text-center text-medium-emphasis py-8">
|
||||
暂无趋势数据(需先 Pin 监测并等待探针采样)
|
||||
</div>
|
||||
<v-row dense>
|
||||
<v-col
|
||||
v-for="panel in slotPanels"
|
||||
:key="panel.slotIndex"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<v-card variant="outlined" rounded="lg" class="watch-metrics-slot pa-3">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-chip size="x-small" variant="tonal" color="info" label>
|
||||
槽 {{ panel.slotIndex + 1 }}
|
||||
</v-chip>
|
||||
<span class="text-subtitle-2 text-truncate ml-2" :title="panel.serverName">
|
||||
{{ panel.empty ? '未占用' : panel.serverName }}
|
||||
</span>
|
||||
</div>
|
||||
<WatchTrendChart
|
||||
v-if="!panel.empty && panel.serverId != null && (pointsByServer.get(panel.serverId)?.length ?? 0) > 0"
|
||||
:points="pointsByServer.get(panel.serverId!) ?? []"
|
||||
:series="selectedSeries"
|
||||
:height="280"
|
||||
/>
|
||||
<div
|
||||
v-else-if="panel.empty"
|
||||
class="text-center text-medium-emphasis watch-metrics-slot__empty"
|
||||
>
|
||||
未添加监测
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-center text-medium-emphasis watch-metrics-slot__empty"
|
||||
>
|
||||
暂无趋势数据(需开启监测并等待探针采样)
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-window-item>
|
||||
<v-window-item value="records">
|
||||
<WatchProbeRecordsTable
|
||||
:server-id="serverId"
|
||||
:session-id="sessionId"
|
||||
:hours="hours"
|
||||
/>
|
||||
<WatchProbeRecordsTable :hours="hours" />
|
||||
</v-window-item>
|
||||
</v-window>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.watch-metrics-slot {
|
||||
min-height: 320px;
|
||||
}
|
||||
.watch-metrics-slot__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 280px;
|
||||
padding: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,7 @@ 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') },
|
||||
|
||||
@@ -75,7 +75,7 @@ export const NOTIFY_TOGGLE_ITEMS: NotifyToggleItem[] = [
|
||||
{ key: 'notify_alert_mem', label: '内存超阈值告警', desc: '内存超阈值或恢复时推送;关闭则该类告警与恢复均不通知', group: '服务器资源' },
|
||||
{ key: 'notify_alert_disk', label: '磁盘超阈值告警', desc: '磁盘超阈值或恢复时推送;关闭则该类告警与恢复均不通知', group: '服务器资源' },
|
||||
{ key: 'notify_recovery', label: '资源恢复正常', desc: 'CPU/内存/磁盘回落正常时推送(须总开关开启且对应资源开关未关)', group: '服务器资源' },
|
||||
{ key: 'notify_alert_offline', label: '子机离线告警', desc: 'Agent 心跳丢失、子机由在线变为离线时推送一次', group: '服务器' },
|
||||
{ key: 'notify_alert_offline', label: '子机离线告警', desc: 'Agent 心跳丢失、子机由在线变为离线时推送一次;可在下方配置专用 Telegram Bot', group: '服务器' },
|
||||
{ key: 'notify_time_drift', label: '时钟偏差严重 (≥60s)', desc: '子机与主机时间偏差过大影响 TOTP/cron', group: '服务器' },
|
||||
{ key: 'notify_system_redis', label: 'Redis 连接异常 / 恢复', desc: 'Redis 不可达或恢复时推送', group: '系统' },
|
||||
{ key: 'notify_system_mysql', label: 'MySQL 连接异常 / 恢复', desc: 'MySQL 不可达或恢复时推送', group: '系统' },
|
||||
@@ -419,6 +419,8 @@ export interface AuditItem {
|
||||
detail: string | null
|
||||
ip_address: string | null
|
||||
created_at: string | null
|
||||
/** 目标为 server 时由 API 解析的服务器名称 */
|
||||
target_name?: string | null
|
||||
}
|
||||
|
||||
/** DB credential from /api/scripts/credentials (password never returned) */
|
||||
|
||||
@@ -90,6 +90,7 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
delete_ssh_key_preset: '删除 SSH 密钥预设',
|
||||
reveal_ssh_key_preset: '查看 SSH 密钥预设',
|
||||
reveal_telegram_token: '查看 Telegram Token',
|
||||
reveal_offline_telegram_token: '查看离线告警 Telegram Token',
|
||||
parse_subscription: '解析订阅节点',
|
||||
update_ip_allowlist: '更新登录 IP 白名单',
|
||||
toggle_ip_allowlist: '开关登录 IP 白名单',
|
||||
@@ -267,6 +268,27 @@ export function auditTargetLabel(type: string): string {
|
||||
return fallbackActionLabel(key)
|
||||
}
|
||||
|
||||
/** 审计列表「目标」列:服务器显示名称,其它类型保留 #id。 */
|
||||
export function auditTargetText(item: {
|
||||
resource_type?: string
|
||||
target_type?: string
|
||||
resource_id?: number | null
|
||||
target_id?: number | null
|
||||
target_name?: string | null
|
||||
}): string {
|
||||
const type = (item.resource_type || item.target_type || '').trim()
|
||||
const id = item.resource_id ?? item.target_id ?? null
|
||||
const label = auditTargetLabel(type)
|
||||
if (type === 'server') {
|
||||
const name = (item.target_name || '').trim()
|
||||
if (name) return `${label} ${name}`
|
||||
if (id != null && id > 0) return `${label} #${id}`
|
||||
return label || '—'
|
||||
}
|
||||
if (id != null && id > 0) return `${label} #${id}`
|
||||
return label || '—'
|
||||
}
|
||||
|
||||
/** 审计页「操作类型」筛选(中文标签 + 英文 value) */
|
||||
export const auditActionFilterOptions: { label: string; value: string }[] = [
|
||||
{ label: '登录成功', value: 'login_success' },
|
||||
|
||||
@@ -15,6 +15,7 @@ export function normalizeAuditItem(raw: Record<string, unknown>): AuditItem {
|
||||
detail: raw.detail != null ? String(raw.detail) : null,
|
||||
ip_address: raw.ip_address != null ? String(raw.ip_address) : null,
|
||||
created_at: raw.created_at != null ? String(raw.created_at) : null,
|
||||
target_name: raw.target_name != null ? String(raw.target_name) : null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
/** Format watch probe byte rates for display. */
|
||||
|
||||
export interface WatchMetricsLike {
|
||||
probe_status?: string | null
|
||||
}
|
||||
|
||||
/** 监测已开启但尚未收到任何探针回传(含失败态) */
|
||||
export function isWatchMetricsPending(metrics: WatchMetricsLike | null | undefined): boolean {
|
||||
const status = metrics?.probe_status
|
||||
return status == null || status === ''
|
||||
}
|
||||
|
||||
export function formatBytesPerSec(bps: number | null | undefined): string {
|
||||
if (bps == null || bps < 0) return '—'
|
||||
if (bps < 1024) return `${bps} B/s`
|
||||
@@ -7,6 +17,19 @@ export function formatBytesPerSec(bps: number | null | undefined): string {
|
||||
return `${(bps / 1024 / 1024).toFixed(1)} MB/s`
|
||||
}
|
||||
|
||||
/** Linux 1 分钟平均负载(宝塔「负载」同源) */
|
||||
export function formatLoadAvg(load: number | null | undefined): string {
|
||||
if (load == null || Number.isNaN(load)) return '—'
|
||||
return load.toFixed(2)
|
||||
}
|
||||
|
||||
export function loadAvgColor(load: number | null | undefined): string {
|
||||
if (load == null || Number.isNaN(load)) return 'grey'
|
||||
if (load >= 2) return 'error'
|
||||
if (load >= 1) return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
export function formatRemaining(sec: number | null | undefined): string {
|
||||
if (sec == null || sec <= 0) return '已到期'
|
||||
const h = Math.floor(sec / 3600)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Embedded browser UI preferences API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.api.auth_jwt import get_current_admin
|
||||
from server.api.dependencies import get_db
|
||||
from server.domain.models import Admin
|
||||
from server.infrastructure.database.admin_ui_preference_repo import AdminUiPreferenceRepositoryImpl
|
||||
from server.utils.browser_ui_state import (
|
||||
BROWSER_UI_CONTEXT,
|
||||
merge_browser_state,
|
||||
parse_browser_state,
|
||||
record_visit,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/browser", tags=["browser"])
|
||||
|
||||
|
||||
class BrowserVisitRecord(BaseModel):
|
||||
url: str
|
||||
title: str | None = None
|
||||
|
||||
|
||||
class BrowserTabState(BaseModel):
|
||||
id: str
|
||||
url: str
|
||||
title: str | None = None
|
||||
stack: list[str] | None = None
|
||||
stack_index: int | None = None
|
||||
|
||||
|
||||
class BrowserPanelRect(BaseModel):
|
||||
x: float
|
||||
y: float
|
||||
w: float
|
||||
h: float
|
||||
|
||||
|
||||
class BrowserStateUpdate(BaseModel):
|
||||
url_history: list[str] | None = None
|
||||
visits: list[dict] | None = None
|
||||
tabs: list[BrowserTabState] | None = None
|
||||
active_tab_id: str | None = None
|
||||
minimized: bool | None = None
|
||||
rect: BrowserPanelRect | None = None
|
||||
minimized_rect: BrowserPanelRect | None = None
|
||||
record_url: str | None = Field(default=None, description="Append one visit + history entry")
|
||||
record_title: str | None = None
|
||||
|
||||
|
||||
@router.get("/state")
|
||||
async def get_browser_state(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = AdminUiPreferenceRepositoryImpl(db)
|
||||
raw = await repo.get(admin.id, BROWSER_UI_CONTEXT)
|
||||
return parse_browser_state(raw)
|
||||
|
||||
|
||||
@router.put("/state")
|
||||
async def put_browser_state(
|
||||
payload: BrowserStateUpdate,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = AdminUiPreferenceRepositoryImpl(db)
|
||||
current = parse_browser_state(await repo.get(admin.id, BROWSER_UI_CONTEXT))
|
||||
|
||||
incoming = payload.model_dump(exclude_none=True)
|
||||
if payload.tabs is not None:
|
||||
incoming["tabs"] = [t.model_dump(exclude_none=True) for t in payload.tabs]
|
||||
if payload.rect is not None:
|
||||
incoming["rect"] = payload.rect.model_dump()
|
||||
if payload.minimized_rect is not None:
|
||||
incoming["minimized_rect"] = payload.minimized_rect.model_dump()
|
||||
|
||||
merged = merge_browser_state(current, incoming)
|
||||
if payload.record_url:
|
||||
merged = record_visit(merged, payload.record_url, payload.record_title)
|
||||
|
||||
await repo.upsert(admin.id, BROWSER_UI_CONTEXT, merged)
|
||||
return merged
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Remote browser session API — bridge to Browser Worker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
import websockets
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from server.api.auth_jwt import get_current_admin
|
||||
from server.api.websocket import _verify_ws_token
|
||||
from server.config import settings
|
||||
from server.domain.models import Admin
|
||||
from server.infrastructure.browser import session_registry
|
||||
from server.infrastructure.browser.worker_client import (
|
||||
BrowserWorkerError,
|
||||
browser_enabled,
|
||||
create_worker_session,
|
||||
delete_worker_session,
|
||||
worker_health,
|
||||
worker_stream_url,
|
||||
)
|
||||
from server.utils.browser_url_safe import validate_navigation_url
|
||||
|
||||
logger = logging.getLogger("nexus.browser.session")
|
||||
|
||||
router = APIRouter(prefix="/api/browser", tags=["browser"])
|
||||
ws_router = APIRouter(tags=["browser"])
|
||||
|
||||
|
||||
class CreateBrowserSessionBody(BaseModel):
|
||||
viewport_w: int = Field(default=1280, ge=320, le=1920)
|
||||
viewport_h: int = Field(default=720, ge=240, le=1080)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def browser_status(admin: Admin = Depends(get_current_admin)):
|
||||
del admin
|
||||
enabled = browser_enabled()
|
||||
health = await worker_health() if enabled else None
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"worker_ok": health is not None,
|
||||
"worker_sessions": health.get("sessions") if health else None,
|
||||
"max_sessions": settings.BROWSER_MAX_SESSIONS,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/sessions")
|
||||
async def create_browser_session(
|
||||
body: CreateBrowserSessionBody,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
if not browser_enabled():
|
||||
raise HTTPException(status_code=503, detail="服务端浏览器未启用")
|
||||
# 清理断线残留(避免 409 导致前端无法重连)
|
||||
for stale_id in session_registry.session_ids_for_admin(admin.id):
|
||||
session_registry.remove(stale_id)
|
||||
await delete_worker_session(stale_id)
|
||||
if session_registry.total_count() >= settings.BROWSER_MAX_SESSIONS:
|
||||
raise HTTPException(status_code=503, detail="浏览器会话已达上限")
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
try:
|
||||
await create_worker_session(session_id, body.viewport_w, body.viewport_h)
|
||||
except BrowserWorkerError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
|
||||
|
||||
session_registry.register(session_id, admin.id, body.viewport_w, body.viewport_h)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"viewport_w": body.viewport_w,
|
||||
"viewport_h": body.viewport_h,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}")
|
||||
async def delete_browser_session(
|
||||
session_id: str,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
sess = session_registry.get(session_id)
|
||||
if not sess or sess.admin_id != admin.id:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
session_registry.remove(session_id)
|
||||
await delete_worker_session(session_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _validate_client_navigate(msg: dict[str, Any]) -> dict[str, Any] | None:
|
||||
if msg.get("type") != "NAVIGATE":
|
||||
return msg
|
||||
url = msg.get("url", "")
|
||||
normalized, err = validate_navigation_url(str(url))
|
||||
if err:
|
||||
return {"type": "ERROR", "message": err, "code": "URL_BLOCKED"}
|
||||
out = dict(msg)
|
||||
out["url"] = normalized
|
||||
return out
|
||||
|
||||
|
||||
@ws_router.websocket("/ws/browser/{session_id}")
|
||||
async def browser_ws(
|
||||
websocket: WebSocket,
|
||||
session_id: str,
|
||||
token: Optional[str] = Query(None),
|
||||
):
|
||||
if not browser_enabled():
|
||||
await websocket.close(code=4503, reason="Browser not enabled")
|
||||
return
|
||||
if not token:
|
||||
await websocket.close(code=4001, reason="Missing JWT token")
|
||||
return
|
||||
|
||||
admin = await _verify_ws_token(token)
|
||||
if not admin:
|
||||
await websocket.close(code=4401, reason="Invalid or expired JWT token")
|
||||
return
|
||||
|
||||
sess = session_registry.get(session_id)
|
||||
if not sess or sess.admin_id != admin.id:
|
||||
await websocket.close(code=4403, reason="Session not authorized")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
worker_url = worker_stream_url(session_id)
|
||||
headers = {"X-Nexus-Worker-Key": settings.BROWSER_WORKER_SECRET}
|
||||
|
||||
try:
|
||||
async with websockets.connect(
|
||||
worker_url,
|
||||
additional_headers=headers,
|
||||
open_timeout=15,
|
||||
max_size=8 * 1024 * 1024,
|
||||
) as worker_ws:
|
||||
|
||||
async def client_to_worker() -> None:
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
session_registry.touch(session_id)
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
await websocket.send_json({
|
||||
"type": "ERROR",
|
||||
"message": "Invalid JSON",
|
||||
"code": "BAD_JSON",
|
||||
})
|
||||
continue
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
validated = _validate_client_navigate(msg)
|
||||
if validated:
|
||||
await worker_ws.send(json.dumps(validated))
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
|
||||
async def worker_to_client() -> None:
|
||||
try:
|
||||
async for raw in worker_ws:
|
||||
session_registry.touch(session_id)
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode("utf-8", errors="replace")
|
||||
await websocket.send_text(raw)
|
||||
except websockets.ConnectionClosed:
|
||||
pass
|
||||
|
||||
done, pending = await asyncio.wait(
|
||||
[asyncio.create_task(client_to_worker()), asyncio.create_task(worker_to_client())],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
for task in done:
|
||||
try:
|
||||
await task
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.warning("browser bridge error session=%s: %s", session_id, exc)
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"type": "ERROR",
|
||||
"message": "无法连接 Browser Worker",
|
||||
"code": "WORKER_UNREACHABLE",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
session_registry.remove(session_id)
|
||||
await delete_worker_session(session_id)
|
||||
+218
-35
@@ -37,7 +37,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
IMMUTABLE_KEYS = {"secret_key", "api_key", "encryption_key", "database_url"}
|
||||
|
||||
# Settings whose values are masked in GET responses
|
||||
SENSITIVE_KEYS = {"secret_key", "api_key", "encryption_key", "telegram_bot_token"}
|
||||
SENSITIVE_KEYS = {
|
||||
"secret_key",
|
||||
"api_key",
|
||||
"encryption_key",
|
||||
"telegram_bot_token",
|
||||
"telegram_offline_bot_token",
|
||||
}
|
||||
|
||||
# S-02: Whitelist of keys accepted by PUT /{key} (superset of DB_OVERRIDE_MAP + notify toggles)
|
||||
MUTABLE_KEYS: set[str] = {
|
||||
@@ -45,6 +51,7 @@ MUTABLE_KEYS: set[str] = {
|
||||
"redis_url", "heartbeat_timeout", "api_base_url",
|
||||
"cpu_alert_threshold", "mem_alert_threshold", "disk_alert_threshold",
|
||||
"telegram_bot_token", "telegram_chat_id",
|
||||
"telegram_offline_bot_token", "telegram_offline_chat_id",
|
||||
"login_allowlist_enabled", "login_subscription_url",
|
||||
"login_subscription_ips", "login_manual_ips", "login_allowed_ips",
|
||||
"notify_alert_cpu", "notify_alert_mem", "notify_alert_disk",
|
||||
@@ -64,6 +71,13 @@ SETTING_ENUM_VALIDATORS: dict[str, frozenset[str]] = {
|
||||
"theme": frozenset({"dark", "light"}),
|
||||
}
|
||||
|
||||
# GET /{key} when row not yet in MySQL (PUT upserts on first save)
|
||||
SETTING_DEFAULTS: dict[str, str] = {
|
||||
"script_exec_complete_sound": "beep_double",
|
||||
"push_complete_sound": "beep_double",
|
||||
"theme": "dark",
|
||||
}
|
||||
|
||||
# S-02: Per-key validation rules: (type, min, max) for int settings
|
||||
SETTING_VALIDATORS: dict[str, tuple] = {
|
||||
"db_pool_size": (int, 1, 1000),
|
||||
@@ -310,7 +324,10 @@ async def get_setting(key: str, admin: Admin = Depends(get_current_admin), db: A
|
||||
repo = SettingRepositoryImpl(db)
|
||||
value = await repo.get(key)
|
||||
if value is None:
|
||||
raise HTTPException(status_code=404, detail="Setting not found")
|
||||
default = SETTING_DEFAULTS.get(key)
|
||||
if default is None:
|
||||
raise HTTPException(status_code=404, detail="Setting not found")
|
||||
return _format_setting_response(key, default, None)
|
||||
return _format_setting_response(key, value, None)
|
||||
|
||||
|
||||
@@ -1115,6 +1132,7 @@ async def telegram_test_send(
|
||||
f"具体情况:Bot Token 与 Chat ID 配置正确,Telegram 通道可用\n"
|
||||
f"将接收的通知类型:\n"
|
||||
f"• 子机 CPU/内存/磁盘超阈值告警与恢复\n"
|
||||
f"• 子机离线告警(可另配专用 Bot,见设置页)\n"
|
||||
f"• Redis / MySQL 连接异常与恢复\n"
|
||||
f"• 子机时钟偏差、文件推送结果\n"
|
||||
f"• Layer 3 宿主机巡检 /health 失败与自动重启\n"
|
||||
@@ -1167,30 +1185,67 @@ async def reveal_telegram_token(
|
||||
return {"key": "telegram_bot_token", "value": value}
|
||||
|
||||
|
||||
@router.get("/telegram/chats", response_model=dict)
|
||||
async def telegram_get_chats(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""Call getUpdates to detect recent chats and extract unique chat_ids.
|
||||
def _telegram_chat_entry(chat: dict) -> dict | None:
|
||||
cid = chat.get("id")
|
||||
if cid is None:
|
||||
return None
|
||||
return {
|
||||
"id": cid,
|
||||
"type": chat.get("type", ""),
|
||||
"title": chat.get("title") or chat.get("first_name") or chat.get("username") or "",
|
||||
"username": chat.get("username") or "",
|
||||
}
|
||||
|
||||
User must send at least one message to the Bot before calling this.
|
||||
Returns up to 5 distinct chats from recent messages.
|
||||
"""
|
||||
|
||||
def _extract_chats_from_telegram_updates(updates: list) -> list[dict]:
|
||||
"""Extract unique chats from getUpdates (DM, group join, channel, callbacks)."""
|
||||
seen: set[int] = set()
|
||||
chats: list[dict] = []
|
||||
|
||||
def add(chat: object) -> None:
|
||||
if not isinstance(chat, dict):
|
||||
return
|
||||
entry = _telegram_chat_entry(chat)
|
||||
if not entry or entry["id"] in seen:
|
||||
return
|
||||
seen.add(entry["id"])
|
||||
chats.append(entry)
|
||||
|
||||
for update in updates:
|
||||
if not isinstance(update, dict):
|
||||
continue
|
||||
for key in ("message", "edited_message", "channel_post", "edited_channel_post"):
|
||||
msg = update.get(key)
|
||||
if isinstance(msg, dict):
|
||||
add(msg.get("chat"))
|
||||
mcm = update.get("my_chat_member")
|
||||
if isinstance(mcm, dict):
|
||||
add(mcm.get("chat"))
|
||||
cm = update.get("chat_member")
|
||||
if isinstance(cm, dict):
|
||||
add(cm.get("chat"))
|
||||
cq = update.get("callback_query")
|
||||
if isinstance(cq, dict):
|
||||
msg = cq.get("message")
|
||||
if isinstance(msg, dict):
|
||||
add(msg.get("chat"))
|
||||
|
||||
type_order = {"supergroup": 0, "group": 1, "channel": 2, "private": 3}
|
||||
chats.sort(key=lambda c: (type_order.get(c.get("type", ""), 9), c.get("id", 0)))
|
||||
return chats
|
||||
|
||||
|
||||
async def _telegram_fetch_chats(token: str) -> dict:
|
||||
import httpx
|
||||
from server.config import settings as _settings
|
||||
from server.infrastructure.telegram import TELEGRAM_API_BASE
|
||||
|
||||
token = _settings.TELEGRAM_BOT_TOKEN
|
||||
if not token:
|
||||
raise HTTPException(status_code=400, detail="TELEGRAM_BOT_TOKEN 未配置")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
base_url = f"{TELEGRAM_API_BASE}/bot{token}"
|
||||
await _telegram_clear_webhook_if_set(client, base_url)
|
||||
resp = await client.get(
|
||||
f"{base_url}/getUpdates",
|
||||
params={"limit": 50},
|
||||
params={"limit": 100},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=502, detail=f"Telegram API 返回 {resp.status_code}")
|
||||
@@ -1203,27 +1258,125 @@ async def telegram_get_chats(
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"请求失败: {e}") from e
|
||||
|
||||
# Extract unique chats
|
||||
seen: set[int] = set()
|
||||
chats: list[dict] = []
|
||||
for update in data.get("result", []):
|
||||
msg = update.get("message") or update.get("channel_post") or {}
|
||||
chat = msg.get("chat", {})
|
||||
cid = chat.get("id")
|
||||
if cid and cid not in seen:
|
||||
seen.add(cid)
|
||||
chats.append({
|
||||
"id": cid,
|
||||
"type": chat.get("type", ""),
|
||||
"title": chat.get("title") or chat.get("first_name") or "",
|
||||
"username": chat.get("username") or "",
|
||||
})
|
||||
if len(chats) >= 5:
|
||||
break
|
||||
|
||||
chats = _extract_chats_from_telegram_updates(data.get("result", []))[:10]
|
||||
return {"chats": chats, "count": len(chats)}
|
||||
|
||||
|
||||
@router.get("/telegram/chats", response_model=dict)
|
||||
async def telegram_get_chats(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""Call getUpdates to detect recent chats and extract unique chat_ids.
|
||||
|
||||
For private chats: send any message to the Bot.
|
||||
For groups: add the Bot to the group, then send /start@BotName (privacy mode)
|
||||
or any message mentioning the Bot. Bot join events (my_chat_member) are included.
|
||||
Returns up to 10 distinct chats, groups listed first.
|
||||
"""
|
||||
from server.config import settings as _settings
|
||||
|
||||
token = (_settings.TELEGRAM_BOT_TOKEN or "").strip()
|
||||
if not token:
|
||||
raise HTTPException(status_code=400, detail="TELEGRAM_BOT_TOKEN 未配置")
|
||||
return await _telegram_fetch_chats(token)
|
||||
|
||||
|
||||
def _offline_telegram_token_or_400() -> str:
|
||||
from server.config import settings as _settings
|
||||
|
||||
token = (_settings.TELEGRAM_OFFLINE_BOT_TOKEN or "").strip()
|
||||
if not token:
|
||||
raise HTTPException(status_code=400, detail="telegram_offline_bot_token 未配置")
|
||||
return token
|
||||
|
||||
|
||||
def _offline_telegram_credentials_or_400() -> tuple[str, str]:
|
||||
"""Resolve offline-dedicated Bot; 400 if not fully configured."""
|
||||
from server.config import settings as _settings
|
||||
|
||||
token = _offline_telegram_token_or_400()
|
||||
chat_id = (_settings.TELEGRAM_OFFLINE_CHAT_ID or "").strip()
|
||||
if not chat_id:
|
||||
raise HTTPException(status_code=400, detail="telegram_offline_chat_id 未配置,请先检测并填写")
|
||||
return token, chat_id
|
||||
|
||||
|
||||
@router.post("/telegram/offline/test", response_model=dict)
|
||||
async def telegram_offline_test_send(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""Send a test message via offline-dedicated Telegram Bot (not the default channel)."""
|
||||
import httpx
|
||||
from server.config import settings as _settings
|
||||
from server.infrastructure.telegram import TELEGRAM_API_BASE
|
||||
from server.utils.display_time import format_beijing_now
|
||||
|
||||
token, chat_id = _offline_telegram_credentials_or_400()
|
||||
now = format_beijing_now()
|
||||
sys_name = html.escape((_settings.SYSTEM_NAME or "Nexus").strip())
|
||||
message = (
|
||||
f"✅ <b>【离线告警 Telegram 测试】消息发送成功</b>\n"
|
||||
f"平台:{sys_name}\n"
|
||||
f"具体情况:离线专用 Bot Token 与 Chat ID 配置正确\n"
|
||||
f"将接收的通知:子机由在线变为离线时的 Telegram 推送(须开启「子机离线告警」开关)\n"
|
||||
f"说明:未配置离线专用 Bot 时,离线告警回退至默认 Telegram 通道\n"
|
||||
f"时间:{now}"
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(
|
||||
f"{TELEGRAM_API_BASE}/bot{token}/sendMessage",
|
||||
json={
|
||||
"chat_id": chat_id,
|
||||
"text": message,
|
||||
"parse_mode": "HTML",
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"请求失败: {e}") from e
|
||||
|
||||
if resp.status_code == 200 and data.get("ok"):
|
||||
return {"success": True}
|
||||
desc = data.get("description") or f"HTTP {resp.status_code}"
|
||||
raise HTTPException(status_code=502, detail=f"Telegram API: {desc}")
|
||||
|
||||
|
||||
@router.post("/telegram/offline/reveal-token", response_model=dict)
|
||||
async def reveal_offline_telegram_token(
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Reveal offline-dedicated Telegram Bot Token (JWT + audit)."""
|
||||
repo = SettingRepositoryImpl(db)
|
||||
value = await repo.get("telegram_offline_bot_token")
|
||||
if value is None:
|
||||
raise HTTPException(status_code=404, detail="telegram_offline_bot_token not found")
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username,
|
||||
action="reveal_offline_telegram_token",
|
||||
target_type="setting",
|
||||
detail="查看离线告警 Telegram Bot Token",
|
||||
ip_address=request.client.host if request.client else "",
|
||||
))
|
||||
return {"key": "telegram_offline_bot_token", "value": value}
|
||||
|
||||
|
||||
@router.get("/telegram/offline/chats", response_model=dict)
|
||||
async def telegram_offline_get_chats(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""Detect chats for offline-dedicated Bot via getUpdates (same rules as /telegram/chats)."""
|
||||
token = _offline_telegram_token_or_400()
|
||||
return await _telegram_fetch_chats(token)
|
||||
|
||||
|
||||
# ── IP Allowlist ──
|
||||
|
||||
_IP_RE = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(/\d{1,2})?$")
|
||||
@@ -1539,10 +1692,40 @@ async def list_audit_logs(
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"items": [_audit_to_dict(log) for log in logs],
|
||||
"items": await _audit_items_enriched(db, logs),
|
||||
}
|
||||
|
||||
|
||||
async def _audit_items_enriched(db, logs: list[AuditLog]) -> list[dict]:
|
||||
"""Attach target_name for server rows (audit UI shows name instead of #id)."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from server.domain.models import Server
|
||||
|
||||
server_ids = {
|
||||
int(log.target_id)
|
||||
for log in logs
|
||||
if log.target_type == "server" and log.target_id
|
||||
}
|
||||
name_by_id: dict[int, str] = {}
|
||||
if server_ids:
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Server.id, Server.name).where(Server.id.in_(server_ids))
|
||||
)
|
||||
).all()
|
||||
name_by_id = {int(sid): (name or f"server-{sid}") for sid, name in rows}
|
||||
|
||||
items: list[dict] = []
|
||||
for log in logs:
|
||||
row = _audit_to_dict(log)
|
||||
if log.target_type == "server" and log.target_id:
|
||||
tid = int(log.target_id)
|
||||
row["target_name"] = name_by_id.get(tid)
|
||||
items.append(row)
|
||||
return items
|
||||
|
||||
|
||||
# ── Retry Jobs ──
|
||||
|
||||
retry_router = APIRouter(prefix="/api/retries", tags=["retries"])
|
||||
|
||||
+23
-27
@@ -1448,12 +1448,10 @@ async def download_file(
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
conn = await ssh_pool.acquire(server)
|
||||
# NOTE: connection is released INSIDE file_generator (after stream finishes),
|
||||
# not in a finally block here — releasing before the generator runs would close
|
||||
# the SFTP channel mid-download.
|
||||
# Pre-check in a short SFTP session; stream opens its own session so the
|
||||
# context manager is not exited before StreamingResponse consumes the body.
|
||||
try:
|
||||
async with sftp_session(conn) as sftp:
|
||||
# Check file exists and size
|
||||
try:
|
||||
stat = await sftp.stat(remote_path)
|
||||
except FileNotFoundError:
|
||||
@@ -1467,42 +1465,40 @@ async def download_file(
|
||||
await ssh_pool.release(server.id)
|
||||
raise HTTPException(status_code=400, detail="只能下载文件,不能下载目录")
|
||||
|
||||
# H-15: Download file size limit (500MB)
|
||||
MAX_DOWNLOAD_SIZE = 524_288_000
|
||||
if hasattr(stat, "size") and stat.size and stat.size > MAX_DOWNLOAD_SIZE:
|
||||
await ssh_pool.release(server.id)
|
||||
raise HTTPException(status_code=413, detail=f"文件大小 {stat.size} 字节超过下载限制 100MB")
|
||||
|
||||
# H-02: Sanitize filename for Content-Disposition (RFC 6266)
|
||||
import re as _re
|
||||
filename = _re.sub(r'[^\w.\-]', '_', posix_basename(remote_path)) or "download"
|
||||
|
||||
async def file_generator():
|
||||
try:
|
||||
async with sftp.open(remote_path, "rb") as f:
|
||||
while chunk := await f.read(65536):
|
||||
yield chunk
|
||||
finally:
|
||||
# Release after stream is fully consumed (or on client disconnect)
|
||||
await ssh_pool.release(server.id)
|
||||
|
||||
await _audit_sync(
|
||||
"file_download", "server", payload.server_id,
|
||||
f"下载文件: {remote_path}",
|
||||
admin.username, request,
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
file_generator(),
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await ssh_pool.release(server.id)
|
||||
raise HTTPException(status_code=502, detail=f"下载失败: {e}") from e
|
||||
|
||||
async def file_generator():
|
||||
try:
|
||||
async with sftp_session(conn) as sftp:
|
||||
async with sftp.open(remote_path, "rb") as f:
|
||||
while chunk := await f.read(65536):
|
||||
yield chunk
|
||||
finally:
|
||||
await ssh_pool.release(server.id)
|
||||
|
||||
await _audit_sync(
|
||||
"file_download", "server", payload.server_id,
|
||||
f"下载文件: {remote_path}",
|
||||
admin.username, request,
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
file_generator(),
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/read-file")
|
||||
async def read_file(
|
||||
|
||||
+9
-7
@@ -11,7 +11,9 @@ from server.api.auth_jwt import get_current_admin
|
||||
from server.api.dependencies import get_db
|
||||
from server.application.services.watch_service import SlotsFullError, WatchService
|
||||
from server.domain.models import Admin
|
||||
from server.utils.watch_metrics import WATCH_PIN_TTL_HOURS
|
||||
from server.utils.watch_metrics import WATCH_PIN_TTL_DEFAULT_MINUTES
|
||||
|
||||
WatchTtlMinutes = Literal[30, 60, 120, 480, 1440]
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
router = APIRouter(prefix="/api/watch", tags=["watch"])
|
||||
@@ -21,9 +23,9 @@ class WatchPinCreate(BaseModel):
|
||||
server_id: int = Field(..., gt=0)
|
||||
slot_index: int | None = Field(None, ge=0, le=3)
|
||||
replace_slot: int | None = Field(None, ge=0, le=3, description="监测已满时指定替换槽位")
|
||||
ttl_hours: Literal[8, 24] = Field(
|
||||
WATCH_PIN_TTL_HOURS,
|
||||
description="监测窗口时长,默认 8 小时;24 小时需显式指定",
|
||||
ttl_minutes: WatchTtlMinutes = Field(
|
||||
WATCH_PIN_TTL_DEFAULT_MINUTES,
|
||||
description="监测窗口:30分/1h/2h/8h/24h,默认 30 分钟",
|
||||
)
|
||||
|
||||
|
||||
@@ -36,7 +38,7 @@ class WatchPinMonitoringUpdate(BaseModel):
|
||||
|
||||
|
||||
class WatchPinTtlUpdate(BaseModel):
|
||||
ttl_hours: Literal[8, 24] = Field(..., description="监测窗口时长:8 或 24 小时")
|
||||
ttl_minutes: WatchTtlMinutes = Field(..., description="监测窗口:30分/1h/2h/8h/24h")
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
@@ -66,7 +68,7 @@ async def create_watch_pin(
|
||||
server_id=payload.server_id,
|
||||
slot_index=payload.slot_index,
|
||||
replace_slot=payload.replace_slot,
|
||||
ttl_hours=payload.ttl_hours,
|
||||
ttl_minutes=payload.ttl_minutes,
|
||||
ip_address=_client_ip(request),
|
||||
)
|
||||
except SlotsFullError as e:
|
||||
@@ -146,7 +148,7 @@ async def update_watch_pin_ttl(
|
||||
return await svc.set_slot_ttl(
|
||||
admin=admin,
|
||||
slot_index=slot_index,
|
||||
ttl_hours=payload.ttl_hours,
|
||||
ttl_minutes=payload.ttl_minutes,
|
||||
ip_address=_client_ip(request),
|
||||
)
|
||||
except ValueError as e:
|
||||
|
||||
@@ -16,8 +16,9 @@ from server.infrastructure.redis.client import get_redis
|
||||
from server.utils.watch_state import clear_watch_redis_snapshot
|
||||
from server.utils.watch_metrics import (
|
||||
WATCH_MAX_SLOTS,
|
||||
WATCH_PIN_TTL_HOURS,
|
||||
normalize_watch_ttl_hours,
|
||||
WATCH_PIN_TTL_DEFAULT_MINUTES,
|
||||
normalize_watch_ttl_minutes,
|
||||
resolve_pin_ttl_minutes,
|
||||
server_can_be_watched,
|
||||
)
|
||||
|
||||
@@ -119,7 +120,8 @@ async def _slot_payload(
|
||||
"expires_at": pin_row["expires_at"].isoformat() if pin_row.get("expires_at") else None,
|
||||
"remaining_sec": remaining,
|
||||
"monitoring_enabled": monitoring_enabled,
|
||||
"ttl_hours": int(pin_row.get("ttl_hours") or WATCH_PIN_TTL_HOURS),
|
||||
"ttl_minutes": int(pin_row.get("ttl_minutes") or WATCH_PIN_TTL_DEFAULT_MINUTES),
|
||||
"ttl_hours": int(pin_row.get("ttl_hours") or max(1, (int(pin_row.get("ttl_minutes") or 30) + 59) // 60)),
|
||||
"metrics": metrics,
|
||||
"sparkline": sparkline,
|
||||
"processes": processes,
|
||||
@@ -150,11 +152,11 @@ class WatchService:
|
||||
server_id: int,
|
||||
slot_index: int | None = None,
|
||||
replace_slot: int | None = None,
|
||||
ttl_hours: int = WATCH_PIN_TTL_HOURS,
|
||||
ttl_minutes: int = WATCH_PIN_TTL_DEFAULT_MINUTES,
|
||||
ip_address: str = "",
|
||||
) -> dict[str, Any]:
|
||||
await _finalize_due_pins(self.watch_repo, self.db)
|
||||
hours = normalize_watch_ttl_hours(ttl_hours)
|
||||
minutes = normalize_watch_ttl_minutes(ttl_minutes)
|
||||
server = await self.server_repo.get_by_id(server_id)
|
||||
if not server:
|
||||
raise ValueError("服务器不存在")
|
||||
@@ -165,12 +167,12 @@ class WatchService:
|
||||
|
||||
existing = await self.watch_repo.get_pin_by_server(admin.id, server_id)
|
||||
if existing:
|
||||
await self.watch_repo.reset_pin_ttl(admin.id, server_id, hours)
|
||||
await self.watch_repo.reset_pin_ttl(admin.id, server_id, minutes)
|
||||
await self._audit(
|
||||
admin=admin,
|
||||
action="watch_pin_add",
|
||||
server=server,
|
||||
detail=f"重置监测 {hours}h · 槽 {existing.slot_index + 1}",
|
||||
detail=f"重置监测 {minutes}min · 槽 {existing.slot_index + 1}",
|
||||
ip_address=ip_address,
|
||||
)
|
||||
await self.db.commit()
|
||||
@@ -181,13 +183,13 @@ class WatchService:
|
||||
admin_id=admin.id,
|
||||
slot_index=slot_index,
|
||||
server_id=server_id,
|
||||
ttl_hours=hours,
|
||||
ttl_minutes=minutes,
|
||||
)
|
||||
await self._audit(
|
||||
admin=admin,
|
||||
action="watch_pin_replace" if replace_slot is not None else "watch_pin_add",
|
||||
server=server,
|
||||
detail=f"加入监测 · 槽 {slot_index + 1} · {hours}h",
|
||||
detail=f"加入监测 · 槽 {slot_index + 1} · {minutes}min",
|
||||
ip_address=ip_address,
|
||||
)
|
||||
await self.db.commit()
|
||||
@@ -199,13 +201,13 @@ class WatchService:
|
||||
admin_id=admin.id,
|
||||
server_id=server_id,
|
||||
slot_index=empty,
|
||||
ttl_hours=hours,
|
||||
ttl_minutes=minutes,
|
||||
)
|
||||
await self._audit(
|
||||
admin=admin,
|
||||
action="watch_pin_add",
|
||||
server=server,
|
||||
detail=f"加入监测 · 槽 {empty + 1} · {hours}h",
|
||||
detail=f"加入监测 · 槽 {empty + 1} · {minutes}min",
|
||||
ip_address=ip_address,
|
||||
)
|
||||
await self.db.commit()
|
||||
@@ -216,13 +218,13 @@ class WatchService:
|
||||
admin_id=admin.id,
|
||||
slot_index=replace_slot,
|
||||
server_id=server_id,
|
||||
ttl_hours=hours,
|
||||
ttl_minutes=minutes,
|
||||
)
|
||||
await self._audit(
|
||||
admin=admin,
|
||||
action="watch_pin_replace",
|
||||
server=server,
|
||||
detail=f"替换监测 · 槽 {replace_slot + 1} · {hours}h",
|
||||
detail=f"替换监测 · 槽 {replace_slot + 1} · {minutes}min",
|
||||
ip_address=ip_address,
|
||||
)
|
||||
await self.db.commit()
|
||||
@@ -252,18 +254,25 @@ class WatchService:
|
||||
await self.watch_repo.end_session(other.session_id, "replaced")
|
||||
await self.watch_repo.delete_pin(other.id)
|
||||
existing = await self.watch_repo.get_pin_by_slot(admin.id, slot_index)
|
||||
slot_ttl = normalize_watch_ttl_hours(int(existing.ttl_hours or 8)) if existing else WATCH_PIN_TTL_HOURS
|
||||
slot_ttl = (
|
||||
resolve_pin_ttl_minutes(
|
||||
ttl_minutes=getattr(existing, "ttl_minutes", None),
|
||||
ttl_hours=existing.ttl_hours,
|
||||
)
|
||||
if existing
|
||||
else WATCH_PIN_TTL_DEFAULT_MINUTES
|
||||
)
|
||||
await self.watch_repo.replace_pin_server(
|
||||
admin_id=admin.id,
|
||||
slot_index=slot_index,
|
||||
server_id=server_id,
|
||||
ttl_hours=slot_ttl,
|
||||
ttl_minutes=slot_ttl,
|
||||
)
|
||||
await self._audit(
|
||||
admin=admin,
|
||||
action="watch_pin_replace",
|
||||
server=server,
|
||||
detail=f"替换监测 · 槽 {slot_index + 1} · {slot_ttl}h",
|
||||
detail=f"替换监测 · 槽 {slot_index + 1} · {slot_ttl}min",
|
||||
ip_address=ip_address,
|
||||
)
|
||||
await self.db.commit()
|
||||
@@ -314,10 +323,10 @@ class WatchService:
|
||||
if not pin:
|
||||
raise ValueError("槽位为空")
|
||||
server = await self.server_repo.get_by_id(pin.server_id)
|
||||
hours = int(pin.ttl_hours or WATCH_PIN_TTL_HOURS)
|
||||
minutes = int(pin.ttl_minutes or WATCH_PIN_TTL_DEFAULT_MINUTES)
|
||||
action = "watch_pin_resume" if monitoring_enabled else "watch_pin_pause"
|
||||
detail = (
|
||||
f"开启实时监测 · 槽 {slot_index + 1} · 重置 {hours}h"
|
||||
f"开启实时监测 · 槽 {slot_index + 1} · 重置 {minutes}min"
|
||||
if monitoring_enabled
|
||||
else f"暂停实时监测 · 槽 {slot_index + 1} · 恢复 Agent 60s"
|
||||
)
|
||||
@@ -338,14 +347,14 @@ class WatchService:
|
||||
*,
|
||||
admin: Admin,
|
||||
slot_index: int,
|
||||
ttl_hours: int,
|
||||
ttl_minutes: int,
|
||||
ip_address: str = "",
|
||||
) -> dict[str, Any]:
|
||||
await _finalize_due_pins(self.watch_repo, self.db)
|
||||
if slot_index < 0 or slot_index >= WATCH_MAX_SLOTS:
|
||||
raise ValueError("槽位无效")
|
||||
hours = normalize_watch_ttl_hours(ttl_hours)
|
||||
pin = await self.watch_repo.set_pin_ttl_hours(admin.id, slot_index, hours)
|
||||
minutes = normalize_watch_ttl_minutes(ttl_minutes)
|
||||
pin = await self.watch_repo.set_pin_ttl_minutes(admin.id, slot_index, minutes)
|
||||
if not pin:
|
||||
raise ValueError("槽位为空")
|
||||
server = await self.server_repo.get_by_id(pin.server_id)
|
||||
@@ -353,7 +362,7 @@ class WatchService:
|
||||
admin=admin,
|
||||
action="watch_pin_ttl",
|
||||
server=server,
|
||||
detail=f"监测时长 · 槽 {slot_index + 1} · {hours}h",
|
||||
detail=f"监测时长 · 槽 {slot_index + 1} · {minutes}min",
|
||||
ip_address=ip_address,
|
||||
)
|
||||
await self.db.commit()
|
||||
|
||||
@@ -80,6 +80,9 @@ class Settings(BaseSettings):
|
||||
# Telegram (mutable — configurable from settings UI)
|
||||
TELEGRAM_BOT_TOKEN: str = ""
|
||||
TELEGRAM_CHAT_ID: str = ""
|
||||
# 子机离线告警专用(二者均配置时优先于默认 Bot;否则回退 TELEGRAM_*)
|
||||
TELEGRAM_OFFLINE_BOT_TOKEN: str = ""
|
||||
TELEGRAM_OFFLINE_CHAT_ID: str = ""
|
||||
|
||||
# API docs — "true" exposes /docs /redoc /openapi.json (default off for production)
|
||||
EXPOSE_API_DOCS: str = "false"
|
||||
@@ -131,6 +134,8 @@ class Settings(BaseSettings):
|
||||
"disk_alert_threshold": "DISK_ALERT_THRESHOLD",
|
||||
"telegram_bot_token": "TELEGRAM_BOT_TOKEN",
|
||||
"telegram_chat_id": "TELEGRAM_CHAT_ID",
|
||||
"telegram_offline_bot_token": "TELEGRAM_OFFLINE_BOT_TOKEN",
|
||||
"telegram_offline_chat_id": "TELEGRAM_OFFLINE_CHAT_ID",
|
||||
"login_allowlist_enabled": "LOGIN_ALLOWLIST_ENABLED",
|
||||
"login_subscription_url": "LOGIN_SUBSCRIPTION_URL",
|
||||
"login_subscription_ips": "LOGIN_SUBSCRIPTION_IPS",
|
||||
|
||||
@@ -193,7 +193,13 @@ class WatchPin(Base):
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=8,
|
||||
comment="监测窗口时长(小时),仅允许 8 或 24",
|
||||
comment="遗留字段(小时),新逻辑用 ttl_minutes",
|
||||
)
|
||||
ttl_minutes = Column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=30,
|
||||
comment="监测窗口时长(分钟) 30|60|120|480|1440",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Browser Worker bridge infrastructure."""
|
||||
@@ -0,0 +1,51 @@
|
||||
"""In-memory browser session ownership (single-operator; primary worker only)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class BridgeSession:
|
||||
session_id: str
|
||||
admin_id: int
|
||||
viewport_w: int
|
||||
viewport_h: int
|
||||
created_at: float = field(default_factory=time.time)
|
||||
last_activity: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
_sessions: dict[str, BridgeSession] = {}
|
||||
|
||||
|
||||
def register(session_id: str, admin_id: int, viewport_w: int, viewport_h: int) -> BridgeSession:
|
||||
sess = BridgeSession(session_id, admin_id, viewport_w, viewport_h)
|
||||
_sessions[session_id] = sess
|
||||
return sess
|
||||
|
||||
|
||||
def get(session_id: str) -> BridgeSession | None:
|
||||
return _sessions.get(session_id)
|
||||
|
||||
|
||||
def touch(session_id: str) -> None:
|
||||
sess = _sessions.get(session_id)
|
||||
if sess:
|
||||
sess.last_activity = time.time()
|
||||
|
||||
|
||||
def remove(session_id: str) -> BridgeSession | None:
|
||||
return _sessions.pop(session_id, None)
|
||||
|
||||
|
||||
def count_for_admin(admin_id: int) -> int:
|
||||
return sum(1 for s in _sessions.values() if s.admin_id == admin_id)
|
||||
|
||||
|
||||
def session_ids_for_admin(admin_id: int) -> list[str]:
|
||||
return [sid for sid, s in _sessions.items() if s.admin_id == admin_id]
|
||||
|
||||
|
||||
def total_count() -> int:
|
||||
return len(_sessions)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""HTTP client for Browser Worker VPS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
import httpx
|
||||
|
||||
from server.config import settings
|
||||
|
||||
logger = logging.getLogger("nexus.browser.worker")
|
||||
|
||||
|
||||
class BrowserWorkerError(Exception):
|
||||
def __init__(self, message: str, status_code: int = 503) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def browser_enabled() -> bool:
|
||||
return (
|
||||
settings.BROWSER_ENABLED.lower() in ("1", "true", "yes")
|
||||
and bool(settings.BROWSER_WORKER_URL)
|
||||
and bool(settings.BROWSER_WORKER_SECRET)
|
||||
)
|
||||
|
||||
|
||||
def _headers() -> dict[str, str]:
|
||||
return {"X-Nexus-Worker-Key": settings.BROWSER_WORKER_SECRET}
|
||||
|
||||
|
||||
def _tls_verify() -> bool:
|
||||
return settings.BROWSER_WORKER_TLS_VERIFY.lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def worker_stream_url(session_id: str) -> str:
|
||||
base = settings.BROWSER_WORKER_URL.rstrip("/")
|
||||
parsed = urlparse(base)
|
||||
scheme = "wss" if parsed.scheme == "https" else "ws"
|
||||
netloc = parsed.netloc or parsed.path
|
||||
path = f"/v1/sessions/{session_id}/stream"
|
||||
return urlunparse((scheme, netloc, path, "", "", ""))
|
||||
|
||||
|
||||
async def create_worker_session(session_id: str, viewport_w: int, viewport_h: int) -> dict[str, Any]:
|
||||
if not browser_enabled():
|
||||
raise BrowserWorkerError("Browser worker not enabled", 503)
|
||||
url = f"{settings.BROWSER_WORKER_URL.rstrip('/')}/v1/sessions"
|
||||
payload = {"session_id": session_id, "viewport_w": viewport_w, "viewport_h": viewport_h}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0, verify=_tls_verify()) as client:
|
||||
resp = await client.post(url, json=payload, headers=_headers())
|
||||
except httpx.RequestError as exc:
|
||||
logger.warning("worker create failed: %s", exc)
|
||||
raise BrowserWorkerError("Browser worker unreachable", 503) from exc
|
||||
if resp.status_code >= 400:
|
||||
detail = resp.text[:200]
|
||||
raise BrowserWorkerError(detail or "Worker rejected session", resp.status_code)
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def delete_worker_session(session_id: str) -> None:
|
||||
if not browser_enabled():
|
||||
return
|
||||
url = f"{settings.BROWSER_WORKER_URL.rstrip('/')}/v1/sessions/{session_id}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0, verify=_tls_verify()) as client:
|
||||
await client.delete(url, headers=_headers())
|
||||
except httpx.RequestError as exc:
|
||||
logger.warning("worker delete failed session=%s: %s", session_id, exc)
|
||||
|
||||
|
||||
async def worker_health() -> dict[str, Any] | None:
|
||||
if not browser_enabled():
|
||||
return None
|
||||
url = f"{settings.BROWSER_WORKER_URL.rstrip('/')}/health"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0, verify=_tls_verify()) as client:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except httpx.RequestError:
|
||||
return None
|
||||
return None
|
||||
@@ -240,7 +240,8 @@ async def run_schema_migrations():
|
||||
`pinned_at` DATETIME NOT NULL,
|
||||
`expires_at` DATETIME NOT NULL,
|
||||
`monitoring_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否开启实时探针',
|
||||
`ttl_hours` INT NOT NULL DEFAULT 8 COMMENT '监测窗口时长(小时) 8|24',
|
||||
`ttl_hours` INT NOT NULL DEFAULT 1 COMMENT '遗留字段(小时)',
|
||||
`ttl_minutes` INT NOT NULL DEFAULT 30 COMMENT '监测窗口时长(分钟)',
|
||||
UNIQUE KEY `uq_watch_pins_admin_slot` (`admin_id`, `slot_index`),
|
||||
UNIQUE KEY `uq_watch_pins_admin_server` (`admin_id`, `server_id`),
|
||||
INDEX `idx_watch_pins_expires` (`expires_at`),
|
||||
@@ -285,6 +286,8 @@ async def run_schema_migrations():
|
||||
"ALTER TABLE `watch_probe_records` ADD COLUMN `processes_json` JSON NULL COMMENT 'Top5 processes snapshot'",
|
||||
"ALTER TABLE `watch_pins` ADD COLUMN `monitoring_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否开启实时探针'",
|
||||
"ALTER TABLE `watch_pins` ADD COLUMN `ttl_hours` INT NOT NULL DEFAULT 8 COMMENT '监测窗口时长(小时) 8|24'",
|
||||
"ALTER TABLE `watch_pins` ADD COLUMN `ttl_minutes` INT NOT NULL DEFAULT 30 COMMENT '监测窗口时长(分钟)'",
|
||||
"UPDATE `watch_pins` SET `ttl_minutes` = `ttl_hours` * 60 WHERE `ttl_hours` IN (8, 24)",
|
||||
]
|
||||
async with AsyncSessionLocal() as session:
|
||||
for sql in migrations:
|
||||
|
||||
@@ -9,7 +9,26 @@ from sqlalchemy import delete, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.domain.models import Server, WatchPin, WatchPinSession, WatchProbeRecord
|
||||
from server.utils.watch_metrics import normalize_watch_ttl_hours
|
||||
from server.utils.watch_metrics import (
|
||||
WATCH_PIN_TTL_DEFAULT_MINUTES,
|
||||
normalize_watch_ttl_minutes,
|
||||
)
|
||||
|
||||
|
||||
def _pin_ttl_minutes(pin: WatchPin) -> int:
|
||||
raw = getattr(pin, "ttl_minutes", None)
|
||||
if raw and int(raw) in {30, 60, 120, 480, 1440}:
|
||||
return int(raw)
|
||||
if pin.ttl_hours in (8, 24):
|
||||
return int(pin.ttl_hours) * 60
|
||||
return WATCH_PIN_TTL_DEFAULT_MINUTES
|
||||
|
||||
|
||||
def _apply_pin_ttl(pin: WatchPin, minutes: int) -> int:
|
||||
m = normalize_watch_ttl_minutes(minutes)
|
||||
pin.ttl_minutes = m
|
||||
pin.ttl_hours = max(1, (m + 59) // 60)
|
||||
return m
|
||||
|
||||
|
||||
def _utc_naive_now() -> datetime:
|
||||
@@ -54,6 +73,7 @@ class WatchRepositoryImpl:
|
||||
"pinned_at": pin.pinned_at,
|
||||
"expires_at": pin.expires_at,
|
||||
"monitoring_enabled": bool(pin.monitoring_enabled),
|
||||
"ttl_minutes": _pin_ttl_minutes(pin),
|
||||
"ttl_hours": int(pin.ttl_hours or 8),
|
||||
}
|
||||
)
|
||||
@@ -109,11 +129,11 @@ class WatchRepositoryImpl:
|
||||
admin_id: int,
|
||||
server_id: int,
|
||||
slot_index: int,
|
||||
ttl_hours: int = 8,
|
||||
ttl_minutes: int = WATCH_PIN_TTL_DEFAULT_MINUTES,
|
||||
) -> dict[str, Any]:
|
||||
now = _utc_naive_now()
|
||||
hours = normalize_watch_ttl_hours(ttl_hours)
|
||||
expires = now + timedelta(hours=hours)
|
||||
minutes = normalize_watch_ttl_minutes(ttl_minutes)
|
||||
expires = now + timedelta(minutes=minutes)
|
||||
sess = WatchPinSession(
|
||||
admin_id=admin_id,
|
||||
server_id=server_id,
|
||||
@@ -130,7 +150,8 @@ class WatchRepositoryImpl:
|
||||
pinned_at=now,
|
||||
expires_at=expires,
|
||||
monitoring_enabled=True,
|
||||
ttl_hours=hours,
|
||||
ttl_hours=max(1, (minutes + 59) // 60),
|
||||
ttl_minutes=minutes,
|
||||
)
|
||||
self.session.add(pin)
|
||||
await self.session.flush()
|
||||
@@ -141,7 +162,8 @@ class WatchRepositoryImpl:
|
||||
"server_id": server_id,
|
||||
"pinned_at": now,
|
||||
"expires_at": expires,
|
||||
"ttl_hours": hours,
|
||||
"ttl_minutes": minutes,
|
||||
"ttl_hours": pin.ttl_hours,
|
||||
}
|
||||
|
||||
async def end_session(self, session_id: int, reason: str) -> None:
|
||||
@@ -168,7 +190,7 @@ class WatchRepositoryImpl:
|
||||
admin_id: int,
|
||||
slot_index: int,
|
||||
server_id: int,
|
||||
ttl_hours: int = 8,
|
||||
ttl_minutes: int = WATCH_PIN_TTL_DEFAULT_MINUTES,
|
||||
) -> dict[str, Any]:
|
||||
existing = await self.get_pin_by_slot(admin_id, slot_index)
|
||||
if existing:
|
||||
@@ -182,23 +204,25 @@ class WatchRepositoryImpl:
|
||||
admin_id=admin_id,
|
||||
server_id=server_id,
|
||||
slot_index=slot_index,
|
||||
ttl_hours=ttl_hours,
|
||||
ttl_minutes=ttl_minutes,
|
||||
)
|
||||
|
||||
async def reset_pin_ttl(self, admin_id: int, server_id: int, ttl_hours: int | None = None) -> dict[str, Any]:
|
||||
async def reset_pin_ttl(self, admin_id: int, server_id: int, ttl_minutes: int | None = None) -> dict[str, Any]:
|
||||
"""Re-pin same server: end old session and start fresh monitoring window."""
|
||||
pin = await self.get_pin_by_server(admin_id, server_id)
|
||||
if not pin:
|
||||
raise ValueError("pin not found")
|
||||
slot_index = pin.slot_index
|
||||
hours = normalize_watch_ttl_hours(ttl_hours if ttl_hours is not None else int(pin.ttl_hours or 8))
|
||||
minutes = normalize_watch_ttl_minutes(
|
||||
ttl_minutes if ttl_minutes is not None else _pin_ttl_minutes(pin)
|
||||
)
|
||||
await self.end_session(pin.session_id, "replaced")
|
||||
await self.delete_pin(pin.id)
|
||||
return await self.create_pin(
|
||||
admin_id=admin_id,
|
||||
server_id=server_id,
|
||||
slot_index=slot_index,
|
||||
ttl_hours=hours,
|
||||
ttl_minutes=minutes,
|
||||
)
|
||||
|
||||
async def remove_pin_by_slot(self, admin_id: int, slot_index: int) -> WatchPin | None:
|
||||
@@ -233,37 +257,35 @@ class WatchRepositoryImpl:
|
||||
slot_index: int,
|
||||
enabled: bool,
|
||||
*,
|
||||
ttl_hours: int | None = None,
|
||||
ttl_minutes: int | None = None,
|
||||
) -> WatchPin | None:
|
||||
pin = await self.get_pin_by_slot(admin_id, slot_index)
|
||||
if not pin:
|
||||
return None
|
||||
if ttl_hours is not None:
|
||||
pin.ttl_hours = normalize_watch_ttl_hours(ttl_hours)
|
||||
if ttl_minutes is not None:
|
||||
_apply_pin_ttl(pin, ttl_minutes)
|
||||
if enabled:
|
||||
await self._ensure_open_session(pin)
|
||||
hours = normalize_watch_ttl_hours(int(pin.ttl_hours or 8))
|
||||
pin.ttl_hours = hours
|
||||
minutes = _apply_pin_ttl(pin, _pin_ttl_minutes(pin))
|
||||
pin.monitoring_enabled = True
|
||||
pin.expires_at = _utc_naive_now() + timedelta(hours=hours)
|
||||
pin.expires_at = _utc_naive_now() + timedelta(minutes=minutes)
|
||||
else:
|
||||
pin.monitoring_enabled = False
|
||||
await self.session.flush()
|
||||
return pin
|
||||
|
||||
async def set_pin_ttl_hours(
|
||||
async def set_pin_ttl_minutes(
|
||||
self,
|
||||
admin_id: int,
|
||||
slot_index: int,
|
||||
ttl_hours: int,
|
||||
ttl_minutes: int,
|
||||
) -> WatchPin | None:
|
||||
pin = await self.get_pin_by_slot(admin_id, slot_index)
|
||||
if not pin:
|
||||
return None
|
||||
hours = normalize_watch_ttl_hours(ttl_hours)
|
||||
pin.ttl_hours = hours
|
||||
minutes = _apply_pin_ttl(pin, ttl_minutes)
|
||||
if pin.monitoring_enabled:
|
||||
pin.expires_at = _utc_naive_now() + timedelta(hours=hours)
|
||||
pin.expires_at = _utc_naive_now() + timedelta(minutes=minutes)
|
||||
await self.session.flush()
|
||||
return pin
|
||||
|
||||
|
||||
@@ -419,6 +419,9 @@ async def remote_write_bytes(
|
||||
|
||||
if not skip_sftp:
|
||||
try:
|
||||
parent = posix_dirname(remote_path)
|
||||
if parent and parent != "/":
|
||||
await _ensure_remote_parent_dir(conn, remote_path, use_sudo=False)
|
||||
async with await conn.start_sftp_client() as sftp:
|
||||
await sftp_write_bytes(sftp, remote_path, data)
|
||||
return
|
||||
|
||||
@@ -93,17 +93,38 @@ async def close_client():
|
||||
_http_client = None
|
||||
|
||||
|
||||
async def send_telegram(message: str) -> bool:
|
||||
def resolve_offline_telegram_credentials() -> tuple[str, str] | None:
|
||||
"""Bot + chat for offline alerts: dedicated pair if both set, else default."""
|
||||
off_token = (settings.TELEGRAM_OFFLINE_BOT_TOKEN or "").strip()
|
||||
off_chat = (settings.TELEGRAM_OFFLINE_CHAT_ID or "").strip()
|
||||
if off_token and off_chat:
|
||||
return off_token, off_chat
|
||||
|
||||
main_token = (settings.TELEGRAM_BOT_TOKEN or "").strip()
|
||||
main_chat = (settings.TELEGRAM_CHAT_ID or "").strip()
|
||||
if main_token and main_chat:
|
||||
return main_token, main_chat
|
||||
return None
|
||||
|
||||
|
||||
async def send_telegram(
|
||||
message: str,
|
||||
*,
|
||||
bot_token: str | None = None,
|
||||
chat_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Send a Telegram message via Bot API."""
|
||||
if not settings.TELEGRAM_BOT_TOKEN or not settings.TELEGRAM_CHAT_ID:
|
||||
token = (bot_token or settings.TELEGRAM_BOT_TOKEN or "").strip()
|
||||
chat = (chat_id or settings.TELEGRAM_CHAT_ID or "").strip()
|
||||
if not token or not chat:
|
||||
return False
|
||||
|
||||
try:
|
||||
client = await _get_client()
|
||||
resp = await client.post(
|
||||
f"{TELEGRAM_API_BASE}/bot{settings.TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||
f"{TELEGRAM_API_BASE}/bot{token}/sendMessage",
|
||||
json={
|
||||
"chat_id": settings.TELEGRAM_CHAT_ID,
|
||||
"chat_id": chat,
|
||||
"text": message,
|
||||
"parse_mode": "HTML",
|
||||
},
|
||||
@@ -175,6 +196,11 @@ async def send_telegram_offline_alert(
|
||||
|
||||
ip_line = _telegram_ip_line(public_ip, private_ip)
|
||||
|
||||
creds = resolve_offline_telegram_credentials()
|
||||
if not creds:
|
||||
return False
|
||||
|
||||
bot_token, tg_chat_id = creds
|
||||
return await send_telegram(
|
||||
f"🔴 <b>【子机离线】Agent 心跳丢失</b>\n"
|
||||
f"平台:{_system_name()}\n"
|
||||
@@ -182,7 +208,9 @@ async def send_telegram_offline_alert(
|
||||
f"检测来源:Layer 2 server_offline_monitor(30 秒周期)\n"
|
||||
f"具体情况:子机未在预期时间内上报心跳,平台已将其标记为离线\n"
|
||||
f"建议操作:检查子机 Agent 进程、网络与防火墙;或在 Nexus 服务器页执行诊断\n"
|
||||
f"时间:{_now_cn()}"
|
||||
f"时间:{_now_cn()}",
|
||||
bot_token=bot_token,
|
||||
chat_id=tg_chat_id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -72,6 +72,8 @@ from server.api.files import router as files_router
|
||||
from server.api.search import router as search_router
|
||||
from server.api.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
|
||||
@@ -696,6 +698,11 @@ 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)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user