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":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
|
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-servers-search-manual-trigger.md 28lines"}
|
||||||
|
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-servers-search-manual-trigger.md"}
|
||||||
|
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-11T17:54:51+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-files-chmod-not-effective-fix.md 32lines"}
|
||||||
|
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-files-chmod-not-effective-fix.md"}
|
||||||
|
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-11T18:12:57+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-files-upload-www-permissions.md 39lines"}
|
||||||
|
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-files-chmod-not-effective-fix.md"}
|
||||||
|
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||||
|
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-11T18:23:41+08:00","date":"2026-06-11","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||||
|
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-files-upload-www-permissions.md 39lines"}
|
||||||
|
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-files-upload-www-permissions.md"}
|
||||||
|
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-11T18:24:13+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-embedded-browser.md 38lines"}
|
||||||
|
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-embedded-browser.md"}
|
||||||
|
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-11T18:30:51+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-global-floating-browser.md 41lines"}
|
||||||
|
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"audit","result":"BLOCK","detail":"missing sections"}
|
||||||
|
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||||
|
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-11T20:11:04+08:00","date":"2026-06-11","gate":"summary","result":"BLOCK","detail":"5/7"}
|
||||||
|
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-global-floating-browser.md 41lines"}
|
||||||
|
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-global-floating-browser.md"}
|
||||||
|
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-11T20:11:24+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-global-floating-browser.md 47lines"}
|
||||||
|
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-global-floating-browser.md"}
|
||||||
|
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-11T20:15:48+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-global-floating-browser.md 48lines"}
|
||||||
|
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-global-floating-browser.md"}
|
||||||
|
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||||
|
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-11T20:19:51+08:00","date":"2026-06-11","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||||
|
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-global-floating-browser.md 48lines"}
|
||||||
|
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-global-floating-browser.md"}
|
||||||
|
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-11T20:20:04+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-rsync-push-sudo-elevation.md 43lines"}
|
||||||
|
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-rsync-push-sudo-elevation.md"}
|
||||||
|
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-11T23:20:24+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-push-default-wwwroot.md 41lines"}
|
||||||
|
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-rsync-push-sudo-elevation.md"}
|
||||||
|
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||||
|
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"shell_eol","result":"BLOCK","detail":"CR in tracked sh"}
|
||||||
|
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-11T23:43:11+08:00","date":"2026-06-11","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||||
|
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-push-default-wwwroot.md 41lines"}
|
||||||
|
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-push-default-wwwroot.md"}
|
||||||
|
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-11T23:45:44+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"changelog","result":"BLOCK","detail":"file not found"}
|
||||||
|
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"audit","result":"BLOCK","detail":"file not found"}
|
||||||
|
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"no audit file"}
|
||||||
|
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T00:18:55+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"4/7"}
|
||||||
|
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-onboarding-automation.md 25lines"}
|
||||||
|
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-server-onboarding-automation.md"}
|
||||||
|
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||||
|
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T00:19:19+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||||
|
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-onboarding-automation.md 25lines"}
|
||||||
|
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-server-onboarding-automation.md"}
|
||||||
|
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T00:19:31+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-onboarding-automation.md 25lines"}
|
||||||
|
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-server-onboarding-automation.md"}
|
||||||
|
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||||
|
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T01:44:02+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||||
|
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-onboarding-automation.md 25lines"}
|
||||||
|
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-remove-browser-push-ui.md"}
|
||||||
|
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T01:44:24+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-terminal-server-rail-search-history.md 34lines"}
|
||||||
|
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-terminal-server-rail-search-history.md"}
|
||||||
|
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T01:47:53+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-push-remove-name-copy.md 29lines"}
|
||||||
|
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-push-remove-name-copy.md"}
|
||||||
|
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T01:51:46+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-terminal-search-center-empty-state.md 31lines"}
|
||||||
|
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-push-terminal-ui.md"}
|
||||||
|
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T01:58:39+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-search-history-isolated-scopes.md 35lines"}
|
||||||
|
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-search-history-isolated-scopes.md"}
|
||||||
|
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T02:03:52+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-watch-slots.md 54lines"}
|
||||||
|
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-server-watch-slots.md"}
|
||||||
|
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||||
|
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T02:55:32+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||||
|
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-watch-slots.md 54lines"}
|
||||||
|
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-server-watch-slots.md"}
|
||||||
|
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||||
|
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T02:56:25+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||||
|
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-watch-slots.md 54lines"}
|
||||||
|
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-server-watch-slots.md"}
|
||||||
|
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T02:56:43+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-watch-slots.md 54lines"}
|
||||||
|
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-server-watch-slots.md"}
|
||||||
|
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"test","result":"BLOCK","detail":"tests failed"}
|
||||||
|
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||||
|
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T03:08:34+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"5/7"}
|
||||||
|
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-watch-slots.md 54lines"}
|
||||||
|
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-files-watch-ui-fixes.md"}
|
||||||
|
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T03:10:05+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-watch-slots.md 54lines"}
|
||||||
|
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-files-watch-ui-fixes.md"}
|
||||||
|
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T03:12:19+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-agent-action-hint.md 33lines"}
|
||||||
|
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-files-watch-ui-fixes.md"}
|
||||||
|
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"test","result":"BLOCK","detail":"tests failed"}
|
||||||
|
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||||
|
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T03:40:50+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"5/7"}
|
||||||
|
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-agent-action-hint.md 33lines"}
|
||||||
|
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-files-watch-ui-fixes.md"}
|
||||||
|
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||||
|
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T03:41:07+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||||
|
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-server-agent-action-hint.md 33lines"}
|
||||||
|
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-files-watch-ui-fixes.md"}
|
||||||
|
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||||
|
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T03:42:10+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||||
|
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-watch-ttl-expiry-race-fix.md 35lines"}
|
||||||
|
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-watch-slot-monitoring-deploy.md"}
|
||||||
|
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T03:48:00+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-watch-install-psutil-ssh.md 40lines"}
|
||||||
|
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-watch-install-psutil-ssh.md"}
|
||||||
|
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||||
|
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T04:04:48+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||||
|
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-watch-install-psutil-ssh.md 40lines"}
|
||||||
|
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-watch-install-psutil-ssh.md"}
|
||||||
|
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T04:04:59+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-psutil-install-sudo-fix.md 19lines"}
|
||||||
|
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-psutil-install-sudo-fix.md"}
|
||||||
|
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T04:09:10+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-audit-log-server-target-name.md 18lines"}
|
||||||
|
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-audit-log-server-target-name.md"}
|
||||||
|
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T04:16:46+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-watch-slot-hover-border.md 15lines"}
|
||||||
|
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-audit-log-server-target-name.md"}
|
||||||
|
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||||
|
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T04:21:16+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||||
|
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-watch-slot-hover-border.md 15lines"}
|
||||||
|
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-watch-slot-hover-border.md"}
|
||||||
|
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T04:21:28+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-watch-slot-ttl-metrics-ui.md 43lines"}
|
||||||
|
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-watch-slot-hover-border.md"}
|
||||||
|
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T04:32:30+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-browser-top-appbar.md 19lines"}
|
||||||
|
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-audit-log-server-target-name.md"}
|
||||||
|
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"test","result":"BLOCK","detail":"tests failed"}
|
||||||
|
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"security","result":"BLOCK","detail":"3 HIGH findings"}
|
||||||
|
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||||
|
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"shell_eol","result":"BLOCK","detail":"CR in tracked sh"}
|
||||||
|
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T06:49:23+08:00","date":"2026-06-12","gate":"summary","result":"BLOCK","detail":"4/7"}
|
||||||
|
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"changelog","result":"PASS","detail":"2026-06-12-browser-top-appbar.md 19lines"}
|
||||||
|
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"audit","result":"PASS","detail":"2026-06-12-remote-browser-worker.md"}
|
||||||
|
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-12T06:50:36+08:00","date":"2026-06-12","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"changelog","result":"PASS","detail":"2026-06-13-offline-alert-telegram-split.md 37lines"}
|
||||||
|
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"audit","result":"BLOCK","detail":"file not found"}
|
||||||
|
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"review","result":"BLOCK","detail":"no audit file"}
|
||||||
|
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-13T01:43:01+08:00","date":"2026-06-13","gate":"summary","result":"BLOCK","detail":"5/7"}
|
||||||
|
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"changelog","result":"PASS","detail":"2026-06-13-offline-alert-telegram-split.md 37lines"}
|
||||||
|
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"audit","result":"PASS","detail":"2026-06-13-offline-quick-cmd-telegram.md"}
|
||||||
|
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"test","result":"PASS","detail":"all passed"}
|
||||||
|
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||||
|
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"import","result":"PASS","detail":"ok"}
|
||||||
|
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||||
|
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||||
|
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||||
|
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||||
|
{"ts":"2026-06-13T01:45:17+08:00","date":"2026-06-13","gate":"summary","result":"PASS","detail":"7/7"}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 审计 — 文件管理「新建文件」修复
|
||||||
|
|
||||||
|
**Changelog**: `docs/changelog/2026-06-10-files-new-file-fix.md`
|
||||||
|
|
||||||
|
## 变更文件
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `frontend/src/composables/files/useFilesActions.ts` | `openNewFileDialog`、等待编辑器就绪 |
|
||||||
|
| `frontend/src/composables/files/useFilesEditor.ts` | 导出 `waitForEditorWorkbench` |
|
||||||
|
| `frontend/src/composables/files/useFilesPage.ts` | 透传 helper |
|
||||||
|
| `frontend/src/components/files/FilesToolbar.vue` | 未选服务器时提示 |
|
||||||
|
| `server/infrastructure/ssh/asyncssh_pool.py` | SFTP 写前确保父目录 |
|
||||||
|
|
||||||
|
## Step 3(规则扫描)
|
||||||
|
|
||||||
|
| 规则 | 结论 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 鉴权 | PASS | 仍走 `/sync/write-file` + admin JWT |
|
||||||
|
| 路径 | PASS | `validatePathSegment` 不变 |
|
||||||
|
| 静默失败 | PASS | 编辑器未就绪有 warning snackbar |
|
||||||
|
| SSH | PASS | `mkdir -p` 仅父目录,不扩大写范围 |
|
||||||
|
|
||||||
|
## Closure
|
||||||
|
|
||||||
|
| 文件 | 结论 |
|
||||||
|
|------|------|
|
||||||
|
| `useFilesActions.ts` | SAFE — 无 v-html;错误经 snackbar |
|
||||||
|
| `asyncssh_pool.py` | SAFE — 复用已有 `_ensure_remote_parent_dir` |
|
||||||
|
|
||||||
|
## DoD
|
||||||
|
|
||||||
|
- [x] changelog + 本审计
|
||||||
|
- [x] frontend type-check
|
||||||
|
- [ ] 生产:文件管理新建文件 → 编辑器打开
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 审计 — 审计日志目标列显示服务器名称
|
||||||
|
|
||||||
|
**日期**:2026-06-12
|
||||||
|
**Changelog**: `docs/changelog/2026-06-12-audit-log-server-target-name.md`
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
| 文件 |
|
||||||
|
|------|
|
||||||
|
| `server/api/settings.py` |
|
||||||
|
| `frontend/src/pages/AuditPage.vue` |
|
||||||
|
| `frontend/src/utils/auditLabels.ts` |
|
||||||
|
| `frontend/src/utils/auditNormalize.ts` |
|
||||||
|
| `frontend/src/types/api.ts` |
|
||||||
|
| `tests/integration/test_alerts_audit.py` |
|
||||||
|
|
||||||
|
## Step 3 / Closure / DoD
|
||||||
|
|
||||||
|
- PASS — 只读增强,批量 IN 查 name
|
||||||
|
- DoD: integration test passed
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# 审计 — 远程浏览器(Playwright Worker)
|
||||||
|
|
||||||
|
**Changelog**: `docs/changelog/2026-06-12-remote-browser-worker-deploy.md`
|
||||||
|
**设计 SSOT**: `docs/design/specs/2026-06-11-server-browser-chromium-design.md`
|
||||||
|
**范围**: 2026-06-12 新增/恢复的浏览器 Worker 全链路(未含 `web/app` 构建产物)
|
||||||
|
|
||||||
|
## 范围(文件清单)
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `browser-worker/Dockerfile` | Worker 镜像 |
|
||||||
|
| `browser-worker/docker-compose.yml` | Worker 编排 |
|
||||||
|
| `browser-worker/docker-entrypoint.sh` | headed + Xvfb 入口 |
|
||||||
|
| `browser-worker/requirements.txt` | Worker 依赖 |
|
||||||
|
| `browser-worker/config.py` | Worker 环境配置 |
|
||||||
|
| `browser-worker/main.py` | Worker HTTP/WS API |
|
||||||
|
| `browser-worker/session_manager.py` | Playwright + screencast |
|
||||||
|
| `browser-worker/browser_stealth.py` | 反自动化 / 验证码辅助 |
|
||||||
|
| `browser-worker/browser_url_safe.py` | 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;禁止嵌入的站点请用「新标签打开」。
|
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 |
|
| HTTP 反向代理 | 可绕过 frame 限制 | SSRF 风险、需维护代理与 Cookie |
|
||||||
| 仅外链新标签 | 零风险 | 非「内置」 |
|
| 仅外链新标签 | 零风险 | 非「内置」 |
|
||||||
|
|
||||||
**选定**:iframe + 地址栏 + 新标签兜底;首版不做 HTTP 代理。
|
**选定(2026-06-11 前)**:iframe + 地址栏 + 新标签兜底;首版不做 HTTP 代理。
|
||||||
|
|
||||||
|
> **2026-06-11 变更**:用户拒绝 iframe 方案,改为 **服务端 Playwright Chromium + WebSocket 画面流**。
|
||||||
|
> 见 [2026-06-11-server-browser-chromium-design.md](./2026-06-11-server-browser-chromium-design.md)。
|
||||||
|
> 本文档仍有效部分:路由入口、URL 白名单原则、UI 状态 API;**iframe 渲染已废弃**。
|
||||||
|
|
||||||
## 接口与路由
|
## 接口与路由
|
||||||
|
|
||||||
@@ -28,7 +32,9 @@
|
|||||||
|
|
||||||
## 验收
|
## 验收
|
||||||
|
|
||||||
- [ ] 侧栏「浏览器」进入全屏 iframe 页
|
- [x] 全局浮动面板、最小化可拖、不可关闭(见 global-floating-browser changelog)
|
||||||
|
- [ ] 侧栏「浏览器」或左下角入口打开浮动窗
|
||||||
- [ ] 地址栏输入 URL 可导航;刷新 / 新标签打开
|
- [ ] 地址栏输入 URL 可导航;刷新 / 新标签打开
|
||||||
- [ ] 服务器列表「站点」跳转并加载域名
|
- [ ] 服务器列表「站点」跳转并加载域名
|
||||||
- [ ] 非法 URL 拒绝并提示
|
- [ ] 非法 URL 拒绝并提示
|
||||||
|
- [ ] **网站导航(收藏)** — 见 [browser-nav-bookmarks-design.md](./2026-06-11-browser-nav-bookmarks-design.md)
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# 服务端远程浏览器(Playwright Chromium)— 设计说明
|
||||||
|
|
||||||
|
> **取代** iframe 渲染方案([2026-06-11-embedded-browser-design.md](./2026-06-11-embedded-browser-design.md) 中的页面加载方式)。
|
||||||
|
> UI 状态(标签、收藏 `nav_links`、窗口位置)仍走 `/api/browser/state`,不变。
|
||||||
|
|
||||||
|
## 背景与目标
|
||||||
|
|
||||||
|
运维需要在 Nexus 浮动面板内**完整浏览任意公网站点**,不受 `X-Frame-Options` / CSP `frame-ancestors` 限制,且使用**服务端独立浏览器内核**,而非用户本机标签页内的 iframe。
|
||||||
|
|
||||||
|
### 用户确认(2026-06-11)
|
||||||
|
|
||||||
|
| 决策项 | 选择 |
|
||||||
|
|--------|------|
|
||||||
|
| 可访问范围 | **任意公网** `http` / `https` |
|
||||||
|
| 内核 | **Chromium**(Playwright),不要求 Firefox |
|
||||||
|
| 交互 | 在 Nexus 面板内看画面 + 键鼠操作(非「新标签打开」为主) |
|
||||||
|
|
||||||
|
### 与 iframe 方案对比
|
||||||
|
|
||||||
|
| | iframe(现状) | 服务端 Chromium(目标) |
|
||||||
|
|--|----------------|---------------------------|
|
||||||
|
| 渲染位置 | 用户浏览器 | Nexus 服务器 |
|
||||||
|
| 站点嵌入限制 | 常被拒绝 | 无(顶层窗口) |
|
||||||
|
| SSRF 风险 | 无 | **有,必须缓解** |
|
||||||
|
| 资源 | 几乎无 | 每会话 ~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 脚本)
|
文档:`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>
|
<template>
|
||||||
<v-app>
|
<v-app :style="appChromeVars">
|
||||||
<!-- ── App Bar (隐藏于登录页) ── -->
|
<!-- ── App Bar (隐藏于登录页) ── -->
|
||||||
<v-app-bar v-if="auth.isLoggedIn" color="primary" elevation="0" flat class="ps-4">
|
<v-app-bar v-if="auth.isLoggedIn" color="primary" elevation="0" flat class="ps-4">
|
||||||
<v-app-bar-nav-icon class="mr-3" @click="drawer = !drawer" />
|
<v-app-bar-nav-icon class="mr-3" @click="drawer = !drawer" />
|
||||||
@@ -202,8 +202,10 @@
|
|||||||
</v-alert>
|
</v-alert>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<GlobalBrowserPanel v-if="auth.isLoggedIn" />
|
||||||
|
|
||||||
<!-- ── Main Content ── -->
|
<!-- ── Main Content ── -->
|
||||||
<v-main :class="mainLayoutClass" :style="scriptExecBarOffset">
|
<v-main :class="mainLayoutClass" :style="mainContentOffset">
|
||||||
<router-view v-slot="{ Component, route: activeRoute }">
|
<router-view v-slot="{ Component, route: activeRoute }">
|
||||||
<keep-alive :include="cachedPageNames" :max="12">
|
<keep-alive :include="cachedPageNames" :max="12">
|
||||||
<component :is="Component" :key="activeRoute.name ?? activeRoute.path" />
|
<component :is="Component" :key="activeRoute.name ?? activeRoute.path" />
|
||||||
@@ -249,6 +251,7 @@ import { clearCachedQueries } from '@/composables/useCachedQuery'
|
|||||||
import { scheduleRoutePrefetch, cancelRoutePrefetch } from '@/composables/useRoutePrefetch'
|
import { scheduleRoutePrefetch, cancelRoutePrefetch } from '@/composables/useRoutePrefetch'
|
||||||
import { CACHED_PAGE_NAMES } from '@/constants/cachedPages'
|
import { CACHED_PAGE_NAMES } from '@/constants/cachedPages'
|
||||||
import { api } from '@/api'
|
import { api } from '@/api'
|
||||||
|
import GlobalBrowserPanel from '@/components/GlobalBrowserPanel.vue'
|
||||||
|
|
||||||
const cachedPageNames = [...CACHED_PAGE_NAMES]
|
const cachedPageNames = [...CACHED_PAGE_NAMES]
|
||||||
|
|
||||||
@@ -330,12 +333,24 @@ const mainLayoutClass = computed(() => ({
|
|||||||
'nexus-page-main': route.path !== '/terminal' && route.path !== '/login',
|
'nexus-page-main': route.path !== '/terminal' && route.path !== '/login',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const scriptExecBarOffset = computed(() => {
|
const BROWSER_PANEL_HEIGHT = 420
|
||||||
const n = runningItems.value.length
|
|
||||||
if (!n || !auth.isLoggedIn) return undefined
|
const appChromeVars = computed(() => {
|
||||||
const extra = n * 52
|
const dock = auth.isLoggedIn ? `${BROWSER_PANEL_HEIGHT}px` : '0px'
|
||||||
return {
|
return {
|
||||||
paddingTop: `calc(var(--v-layout-top, 64px) + ${extra}px)`,
|
'--nexus-browser-dock-height': dock,
|
||||||
|
'--nexus-browser-dock-offset': dock,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mainContentOffset = computed(() => {
|
||||||
|
if (!auth.isLoggedIn) return undefined
|
||||||
|
const execRows = runningItems.value.length
|
||||||
|
const execExtra = execRows ? execRows * 52 : 0
|
||||||
|
const browserExtra = BROWSER_PANEL_HEIGHT
|
||||||
|
const total = execExtra + browserExtra
|
||||||
|
return {
|
||||||
|
paddingTop: `calc(var(--v-layout-top, 64px) + ${total}px)`,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -437,7 +452,7 @@ window.$snackbar = (text: string, color = 'success') => {
|
|||||||
|
|
||||||
.nexus-script-exec-bar {
|
.nexus-script-exec-bar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: var(--v-layout-top, 64px);
|
top: calc(var(--v-layout-top, 64px) + var(--nexus-browser-dock-offset, 0px));
|
||||||
left: var(--v-layout-left, 0);
|
left: var(--v-layout-left, 0);
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 1005;
|
z-index: 1005;
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="tabs.length"
|
||||||
|
class="global-browser-fixed"
|
||||||
|
:class="{ 'global-browser-fixed--maximized': maximized }"
|
||||||
|
>
|
||||||
|
<v-card border rounded="0" class="global-browser-panel d-flex flex-column h-100">
|
||||||
|
<div class="global-browser-toolbar d-flex align-center flex-shrink-0">
|
||||||
|
<v-tabs
|
||||||
|
:model-value="activeTabId ?? undefined"
|
||||||
|
density="compact"
|
||||||
|
show-arrows
|
||||||
|
class="global-browser-tabs flex-grow-1 min-w-0"
|
||||||
|
@update:model-value="onTabSelected"
|
||||||
|
>
|
||||||
|
<v-tab v-for="tab in tabs" :key="tab.id" :value="tab.id" class="text-none px-2">
|
||||||
|
<span class="text-truncate" style="max-width: 120px">{{ tabLabel(tab) }}</span>
|
||||||
|
<v-btn
|
||||||
|
v-if="tabs.length > 1"
|
||||||
|
icon="mdi-close"
|
||||||
|
size="x-small"
|
||||||
|
variant="text"
|
||||||
|
class="ml-1 opacity-60"
|
||||||
|
density="compact"
|
||||||
|
@click.stop="closeTab(tab.id)"
|
||||||
|
/>
|
||||||
|
</v-tab>
|
||||||
|
</v-tabs>
|
||||||
|
|
||||||
|
<v-btn size="small" variant="text" icon="mdi-plus" title="新标签" density="compact" @click="newEmptyTab" />
|
||||||
|
<v-divider vertical class="mx-1" style="height: 20px; align-self: center" />
|
||||||
|
<v-btn size="small" variant="text" icon="mdi-history" title="浏览记录" density="compact" @click="showHistory = !showHistory" />
|
||||||
|
<v-btn size="small" variant="text" icon="mdi-restart" title="重启浏览器(保留历史记录)" density="compact" @click="onRestart" />
|
||||||
|
<v-btn
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
:icon="maximized ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'"
|
||||||
|
density="compact"
|
||||||
|
title="全屏"
|
||||||
|
class="mr-1"
|
||||||
|
@click="maximized = !maximized"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="global-browser-nav d-flex align-center px-2 py-1 flex-shrink-0 border-b">
|
||||||
|
<v-btn icon="mdi-arrow-left" variant="text" size="small" :disabled="!canGoBack" @click="onGoBack" />
|
||||||
|
<v-btn icon="mdi-arrow-right" variant="text" size="small" :disabled="!canGoForward" @click="onGoForward" />
|
||||||
|
<v-btn icon="mdi-refresh" variant="text" size="small" :disabled="!activeTab?.url" @click="onRefresh" />
|
||||||
|
<v-text-field
|
||||||
|
v-model="addressDraft"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
hide-details
|
||||||
|
placeholder="https://example.com"
|
||||||
|
prepend-inner-icon="mdi-web"
|
||||||
|
class="global-browser-url mx-2"
|
||||||
|
@keydown.enter.prevent="onGo"
|
||||||
|
/>
|
||||||
|
<v-btn color="primary" variant="flat" size="small" class="text-none" @click="onGo">打开</v-btn>
|
||||||
|
<v-btn icon="mdi-open-in-new" variant="text" size="small" :disabled="!activeTab?.url" @click="openExternal" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
v-if="displayError"
|
||||||
|
type="warning"
|
||||||
|
density="compact"
|
||||||
|
variant="tonal"
|
||||||
|
class="ma-2 mb-0 flex-shrink-0"
|
||||||
|
closable
|
||||||
|
@click:close="clearErrors"
|
||||||
|
>
|
||||||
|
{{ displayError }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<div class="global-browser-main d-flex flex-grow-1 min-h-0">
|
||||||
|
<aside v-if="showHistory" class="global-browser-history flex-shrink-0 border-e">
|
||||||
|
<div class="text-caption font-weight-medium px-3 py-2">浏览记录(已同步账号)</div>
|
||||||
|
<v-list density="compact" nav class="py-0 global-browser-history-list">
|
||||||
|
<v-list-item
|
||||||
|
v-for="(visit, idx) in visits"
|
||||||
|
:key="`${visit.at}-${idx}`"
|
||||||
|
:title="visit.title"
|
||||||
|
:subtitle="visit.url"
|
||||||
|
@click="openFromHistory(visit.url)"
|
||||||
|
/>
|
||||||
|
<v-list-item v-if="!visits.length" title="暂无记录" disabled />
|
||||||
|
</v-list>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="global-browser-content flex-grow-1 d-flex flex-column min-w-0 min-h-0">
|
||||||
|
<div
|
||||||
|
v-if="!activeTab?.url"
|
||||||
|
class="flex-grow-1 d-flex flex-column align-center justify-center text-medium-emphasis pa-4"
|
||||||
|
>
|
||||||
|
<v-icon icon="mdi-web" size="48" class="mb-3 opacity-50" />
|
||||||
|
<p class="text-body-2 text-center">输入地址后按回车或点「打开」</p>
|
||||||
|
<p class="text-caption mt-2 text-center">
|
||||||
|
由 Worker 上的 Chromium 远程渲染,不受 iframe 嵌入限制。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="global-browser-canvas-wrap flex-grow-1 d-flex flex-column min-h-0">
|
||||||
|
<div v-if="connecting" class="d-flex align-center justify-center flex-grow-1">
|
||||||
|
<v-progress-circular indeterminate color="primary" />
|
||||||
|
<span class="ml-3 text-body-2">正在连接远程浏览器…</span>
|
||||||
|
</div>
|
||||||
|
<canvas
|
||||||
|
v-show="connected"
|
||||||
|
ref="canvasRef"
|
||||||
|
class="global-browser-canvas flex-grow-1"
|
||||||
|
tabindex="0"
|
||||||
|
@mousemove="onCanvasMouseMove"
|
||||||
|
@mousedown="onCanvasMouseDown"
|
||||||
|
@mouseup="onCanvasMouseUp"
|
||||||
|
@wheel.prevent="onCanvasWheel"
|
||||||
|
@keydown="onCanvasKeyDown"
|
||||||
|
@contextmenu.prevent
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-dialog v-model="showRestartConfirm" max-width="400">
|
||||||
|
<v-card border>
|
||||||
|
<v-card-title>重启浏览器?</v-card-title>
|
||||||
|
<v-card-text>将关闭所有标签并打开空白页,浏览历史记录会保留。</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="showRestartConfirm = false">取消</v-btn>
|
||||||
|
<v-btn color="primary" variant="flat" @click="confirmRestart">重启</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
|
||||||
|
import { useRemoteBrowserSession } from '@/composables/useRemoteBrowserSession'
|
||||||
|
|
||||||
|
const showHistory = ref(false)
|
||||||
|
const showRestartConfirm = ref(false)
|
||||||
|
const maximized = ref(false)
|
||||||
|
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
lastError,
|
||||||
|
addressDraft,
|
||||||
|
visits,
|
||||||
|
tabs,
|
||||||
|
activeTabId,
|
||||||
|
activeTab,
|
||||||
|
canGoBack,
|
||||||
|
canGoForward,
|
||||||
|
tabLabel,
|
||||||
|
openPanel,
|
||||||
|
restartBrowser,
|
||||||
|
navigate,
|
||||||
|
updateActiveFromRemote,
|
||||||
|
goBack,
|
||||||
|
goForward,
|
||||||
|
openExternal,
|
||||||
|
switchTab,
|
||||||
|
closeTab,
|
||||||
|
newEmptyTab,
|
||||||
|
openFromHistory,
|
||||||
|
bootstrap,
|
||||||
|
} = useGlobalBrowser()
|
||||||
|
|
||||||
|
const {
|
||||||
|
connected,
|
||||||
|
connecting,
|
||||||
|
remoteError,
|
||||||
|
setCanvas,
|
||||||
|
setPageInfoHandler,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
navigate: remoteNavigate,
|
||||||
|
reload,
|
||||||
|
goBack: remoteGoBack,
|
||||||
|
goForward: remoteGoForward,
|
||||||
|
onCanvasMouseMove,
|
||||||
|
onCanvasMouseDown,
|
||||||
|
onCanvasMouseUp,
|
||||||
|
onCanvasWheel,
|
||||||
|
onCanvasKeyDown,
|
||||||
|
} = useRemoteBrowserSession()
|
||||||
|
|
||||||
|
const displayError = computed(() => remoteError.value || lastError.value)
|
||||||
|
|
||||||
|
function clearErrors() {
|
||||||
|
lastError.value = null
|
||||||
|
if (remoteError.value) remoteError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
setPageInfoHandler((url, title) => updateActiveFromRemote(url, title))
|
||||||
|
|
||||||
|
async function ensureRemoteSession() {
|
||||||
|
if (!activeTab.value?.url || connecting.value || connected.value) return
|
||||||
|
await nextTick()
|
||||||
|
if (canvasRef.value) setCanvas(canvasRef.value)
|
||||||
|
const w = Math.max(640, Math.floor(canvasRef.value?.clientWidth || 1280))
|
||||||
|
const h = Math.max(480, Math.floor(canvasRef.value?.clientHeight || 360))
|
||||||
|
await connect(w, h)
|
||||||
|
if (connected.value && activeTab.value?.url) {
|
||||||
|
remoteNavigate(activeTab.value.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(activeTab, () => {
|
||||||
|
if (activeTab.value?.url) {
|
||||||
|
openPanel()
|
||||||
|
void ensureRemoteSession()
|
||||||
|
}
|
||||||
|
}, { flush: 'post' })
|
||||||
|
|
||||||
|
watch(activeTabId, () => {
|
||||||
|
disconnect()
|
||||||
|
if (activeTab.value?.url) {
|
||||||
|
void ensureRemoteSession()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await bootstrap()
|
||||||
|
if (!tabs.value.length) {
|
||||||
|
newEmptyTab()
|
||||||
|
}
|
||||||
|
openPanel()
|
||||||
|
if (activeTab.value?.url) {
|
||||||
|
void ensureRemoteSession()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onTabSelected(id: unknown) {
|
||||||
|
if (typeof id === 'string') switchTab(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGo() {
|
||||||
|
if (navigate(addressDraft.value)) {
|
||||||
|
void ensureRemoteSession().then(() => {
|
||||||
|
if (activeTab.value?.url) remoteNavigate(activeTab.value.url)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRestart() {
|
||||||
|
showRestartConfirm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmRestart() {
|
||||||
|
showRestartConfirm.value = false
|
||||||
|
disconnect()
|
||||||
|
restartBrowser()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRefresh() {
|
||||||
|
if (connected.value) reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGoBack() {
|
||||||
|
goBack()
|
||||||
|
if (connected.value) remoteGoBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGoForward() {
|
||||||
|
goForward()
|
||||||
|
if (connected.value) remoteGoForward()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.global-browser-fixed {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--v-layout-top, 64px);
|
||||||
|
left: var(--v-layout-left, 0);
|
||||||
|
right: 0;
|
||||||
|
z-index: 1004;
|
||||||
|
height: var(--nexus-browser-dock-height, 420px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
.global-browser-fixed--maximized {
|
||||||
|
height: calc(100dvh - var(--v-layout-top, 64px)) !important;
|
||||||
|
}
|
||||||
|
.global-browser-panel {
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
background: rgb(var(--v-theme-surface));
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
.global-browser-toolbar {
|
||||||
|
height: 40px;
|
||||||
|
min-height: 40px;
|
||||||
|
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
}
|
||||||
|
.global-browser-nav {
|
||||||
|
background: rgb(var(--v-theme-surface));
|
||||||
|
}
|
||||||
|
.global-browser-url {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
.global-browser-main {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.global-browser-history {
|
||||||
|
width: 280px;
|
||||||
|
max-width: 40%;
|
||||||
|
background: rgb(var(--v-theme-surface));
|
||||||
|
}
|
||||||
|
.global-browser-history-list {
|
||||||
|
max-height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.global-browser-canvas-wrap {
|
||||||
|
min-height: 0;
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
.global-browser-canvas {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
cursor: default;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -138,8 +138,7 @@ async function save() {
|
|||||||
snackbar('名称和命令不能为空', 'warning')
|
snackbar('名称和命令不能为空', 'warning')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let cmdText = formCmd.value.trim()
|
const cmdText = formCmd.value.trim()
|
||||||
if (!cmdText.endsWith('\r')) cmdText += '\r'
|
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
if (editingId.value) await update(editingId.value, formName.value, cmdText)
|
if (editingId.value) await update(editingId.value, formName.value, cmdText)
|
||||||
|
|||||||
@@ -37,13 +37,13 @@ const p = useFilesPageContext()
|
|||||||
show-select
|
show-select
|
||||||
return-object
|
return-object
|
||||||
hover
|
hover
|
||||||
density="comfortable"
|
density="compact"
|
||||||
item-value="name"
|
item-value="name"
|
||||||
:row-props="p.fileRowProps"
|
:row-props="p.fileRowProps"
|
||||||
>
|
>
|
||||||
<template #item.name="{ item }">
|
<template #item.name="{ item }">
|
||||||
<div class="d-flex align-center ga-2 min-w-0">
|
<div class="d-flex align-center ga-2 min-w-0">
|
||||||
<v-icon :color="p.entryIconColor(item)" size="20">
|
<v-icon :color="p.entryIconColor(item)" size="18">
|
||||||
{{ p.entryIcon(item) }}
|
{{ p.entryIcon(item) }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<v-icon
|
<v-icon
|
||||||
@@ -96,41 +96,28 @@ const p = useFilesPageContext()
|
|||||||
<div class="files-row-actions">
|
<div class="files-row-actions">
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="p.isRegularFile(item)"
|
v-if="p.isRegularFile(item)"
|
||||||
variant="outlined"
|
variant="text"
|
||||||
size="small"
|
size="x-small"
|
||||||
color="primary"
|
color="primary"
|
||||||
class="files-action-btn"
|
density="compact"
|
||||||
prepend-icon="mdi-pencil-outline"
|
|
||||||
@click.stop="p.editFile(item)"
|
@click.stop="p.editFile(item)"
|
||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="p.isRegularFile(item)"
|
v-if="p.isRegularFile(item)"
|
||||||
variant="outlined"
|
variant="text"
|
||||||
size="small"
|
size="x-small"
|
||||||
class="files-action-btn"
|
density="compact"
|
||||||
prepend-icon="mdi-eye-outline"
|
|
||||||
@click.stop="p.previewFile(item)"
|
@click.stop="p.previewFile(item)"
|
||||||
>
|
>
|
||||||
查看
|
查看
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="p.isRegularFile(item)"
|
variant="text"
|
||||||
variant="outlined"
|
size="x-small"
|
||||||
size="small"
|
|
||||||
class="files-action-btn"
|
|
||||||
prepend-icon="mdi-download-outline"
|
|
||||||
@click.stop="p.downloadFile(item)"
|
|
||||||
>
|
|
||||||
下载
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
color="error"
|
color="error"
|
||||||
class="files-action-btn"
|
density="compact"
|
||||||
prepend-icon="mdi-delete-outline"
|
|
||||||
@click.stop="p.confirmDeleteFile(item)"
|
@click.stop="p.confirmDeleteFile(item)"
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
@@ -176,13 +163,6 @@ const p = useFilesPageContext()
|
|||||||
>
|
>
|
||||||
<v-list-item-title>查看</v-list-item-title>
|
<v-list-item-title>查看</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-item
|
|
||||||
v-if="p.contextFile && p.isRegularFile(p.contextFile)"
|
|
||||||
prepend-icon="mdi-download"
|
|
||||||
@click="p.contextAction('download')"
|
|
||||||
>
|
|
||||||
<v-list-item-title>下载</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-if="p.contextFile"
|
v-if="p.contextFile"
|
||||||
prepend-icon="mdi-content-copy"
|
prepend-icon="mdi-content-copy"
|
||||||
@@ -260,26 +240,11 @@ const p = useFilesPageContext()
|
|||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 行内操作:独立小按钮、字号略大、横向可换行 */
|
|
||||||
.files-row-actions {
|
.files-row-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 2px;
|
||||||
padding: 2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-action-btn {
|
|
||||||
text-transform: none;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
min-height: 32px;
|
|
||||||
padding-inline: 10px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-action-btn :deep(.v-btn__prepend) {
|
|
||||||
margin-inline-end: 4px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -97,8 +97,7 @@ const p = useFilesPageContext()
|
|||||||
size="small"
|
size="small"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
prepend-icon="mdi-file-plus"
|
prepend-icon="mdi-file-plus"
|
||||||
:disabled="!p.selectedServer"
|
@click="p.openNewFileDialog()"
|
||||||
@click="p.showNewFile = true"
|
|
||||||
>
|
>
|
||||||
新建文件
|
新建文件
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|||||||
@@ -4,24 +4,17 @@
|
|||||||
服务器
|
服务器
|
||||||
<span class="text-caption text-medium-emphasis ml-1">({{ servers.length }})</span>
|
<span class="text-caption text-medium-emphasis ml-1">({{ servers.length }})</span>
|
||||||
</div>
|
</div>
|
||||||
<v-skeleton-loader v-if="loading" type="list-item@8" class="px-2 pt-2" />
|
<div class="terminal-server-rail__search px-2 pt-2 pb-1">
|
||||||
<v-list v-else density="compact" nav class="terminal-server-rail__list flex-grow-1 py-0">
|
<PickerSearch
|
||||||
<v-list-item
|
:search="search"
|
||||||
v-for="s in servers"
|
:search-history="searchHistory"
|
||||||
:key="s.id"
|
@update:search="$emit('update:search', $event)"
|
||||||
rounded="lg"
|
@search-commit="$emit('search-commit')"
|
||||||
class="mb-1"
|
/>
|
||||||
@click="$emit('select', s.id)"
|
</div>
|
||||||
>
|
<v-skeleton-loader v-if="loading" type="list-item@8" class="px-2 pt-1" />
|
||||||
<template #prepend>
|
<v-list v-else density="compact" nav class="terminal-server-rail__list flex-grow-1 py-0 px-2">
|
||||||
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
|
<PickerListItems :servers="servers" @select="$emit('select', $event)" />
|
||||||
</template>
|
|
||||||
<v-list-item-title class="text-body-2 text-truncate" :title="s.name">{{ s.name }}</v-list-item-title>
|
|
||||||
<v-list-item-subtitle class="text-truncate" :title="s.domain">{{ s.domain }}</v-list-item-subtitle>
|
|
||||||
</v-list-item>
|
|
||||||
<v-list-item v-if="servers.length === 0" class="text-medium-emphasis">
|
|
||||||
暂无匹配的服务器
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
</v-list>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,20 @@
|
|||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="s in servers"
|
v-for="s in servers"
|
||||||
:key="s.id"
|
:key="s.id"
|
||||||
:title="s.name"
|
|
||||||
:subtitle="s.domain"
|
|
||||||
rounded="lg"
|
rounded="lg"
|
||||||
|
density="compact"
|
||||||
|
class="terminal-server-item"
|
||||||
@click="$emit('select', s.id)"
|
@click="$emit('select', s.id)"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
|
<v-icon :color="s.is_online ? 'success' : 'grey'" size="8">mdi-circle</v-icon>
|
||||||
</template>
|
</template>
|
||||||
|
<v-list-item-title class="text-body-2 text-truncate" :title="s.name">{{ s.name }}</v-list-item-title>
|
||||||
|
<v-list-item-subtitle class="text-caption text-truncate text-medium-emphasis" :title="s.domain">
|
||||||
|
{{ s.domain }}
|
||||||
|
</v-list-item-subtitle>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-item v-if="servers.length === 0" class="text-medium-emphasis">
|
<v-list-item v-if="servers.length === 0" density="compact" class="text-medium-emphasis terminal-server-item">
|
||||||
暂无匹配的服务器
|
暂无匹配的服务器
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</template>
|
</template>
|
||||||
@@ -27,3 +31,13 @@ defineEmits<{
|
|||||||
select: [serverId: number]
|
select: [serverId: number]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.terminal-server-item {
|
||||||
|
min-height: 40px !important;
|
||||||
|
padding-block: 2px;
|
||||||
|
}
|
||||||
|
.terminal-server-item :deep(.v-list-item__prepend) {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
label: string
|
||||||
|
value: number | null | undefined
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function barColor(v: number): string {
|
||||||
|
if (v >= 90) return 'error'
|
||||||
|
if (v >= 70) return 'warning'
|
||||||
|
return 'success'
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayValue() {
|
||||||
|
return props.value != null ? `${props.value}%` : '—'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="watch-metric-bar mb-1">
|
||||||
|
<div class="d-flex justify-space-between text-caption mb-0">
|
||||||
|
<span class="text-medium-emphasis">{{ label }}</span>
|
||||||
|
<span class="font-weight-medium">{{ displayValue() }}</span>
|
||||||
|
</div>
|
||||||
|
<v-progress-linear
|
||||||
|
:model-value="value ?? 0"
|
||||||
|
:color="value != null ? barColor(value) : 'grey'"
|
||||||
|
bg-opacity="0.2"
|
||||||
|
height="6"
|
||||||
|
rounded
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -37,8 +37,8 @@ const headers = [
|
|||||||
{ title: '状态', key: 'probe_status', width: 100 },
|
{ title: '状态', key: 'probe_status', width: 100 },
|
||||||
{ title: '来源', key: 'source', width: 72 },
|
{ title: '来源', key: 'source', width: 72 },
|
||||||
{ title: 'CPU', key: 'cpu_pct', width: 64 },
|
{ title: 'CPU', key: 'cpu_pct', width: 64 },
|
||||||
{ title: 'MEM', key: 'mem_pct', width: 64 },
|
{ title: '内存', key: 'mem_pct', width: 64 },
|
||||||
{ title: 'DISK', key: 'disk_pct', width: 64 },
|
{ title: '硬盘', key: 'disk_pct', width: 64 },
|
||||||
{ title: '↑', key: 'net_up_bps', width: 88 },
|
{ title: '↑', key: 'net_up_bps', width: 88 },
|
||||||
{ title: '↓', key: 'net_down_bps', width: 88 },
|
{ title: '↓', key: 'net_down_bps', width: 88 },
|
||||||
{ title: '耗时', key: 'duration_ms', width: 72 },
|
{ title: '耗时', key: 'duration_ms', width: 72 },
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { WatchSlot } from '@/composables/useWatchPins'
|
import type { WatchSlot } from '@/composables/useWatchPins'
|
||||||
import { formatBytesPerSec, formatRemaining, probeStatusColor, probeStatusLabel, formatProbeError, isPsutilMissingError } from '@/utils/watchFormat'
|
import {
|
||||||
|
WATCH_TTL_OPTIONS,
|
||||||
|
normalizeWatchTtlMinutes,
|
||||||
|
watchTtlLabel,
|
||||||
|
watchTtlShortLabel,
|
||||||
|
type WatchTtlMinutes,
|
||||||
|
} from '@/constants/watchTtl'
|
||||||
|
import { formatBytesPerSec, formatRemaining, probeStatusColor, probeStatusLabel, formatProbeError, isPsutilMissingError, isWatchMetricsPending, loadAvgColor, formatLoadAvg } from '@/utils/watchFormat'
|
||||||
import WatchSparkline from '@/components/watch/WatchSparkline.vue'
|
import WatchSparkline from '@/components/watch/WatchSparkline.vue'
|
||||||
|
import WatchMetricBar from '@/components/watch/WatchMetricBar.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
slot: WatchSlot
|
slot: WatchSlot
|
||||||
@@ -15,21 +23,22 @@ const emit = defineEmits<{
|
|||||||
history: [slot: WatchSlot]
|
history: [slot: WatchSlot]
|
||||||
pick: [slotIndex: number]
|
pick: [slotIndex: number]
|
||||||
monitoring: [slotIndex: number, enabled: boolean]
|
monitoring: [slotIndex: number, enabled: boolean]
|
||||||
ttl: [slotIndex: number, hours: 8 | 24]
|
ttl: [slotIndex: number, minutes: WatchTtlMinutes]
|
||||||
installPsutil: [slotIndex: number]
|
installPsutil: [slotIndex: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const monitoringOn = computed(() => props.slot.monitoring_enabled !== false)
|
const monitoringOn = computed(() => props.slot.monitoring_enabled !== false)
|
||||||
const slotTtl = computed(() => (props.slot.ttl_hours === 24 ? 24 : 8) as 8 | 24)
|
const slotTtlMinutes = computed(() =>
|
||||||
|
normalizeWatchTtlMinutes(props.slot.ttl_minutes ?? (props.slot.ttl_hours === 24 ? 1440 : props.slot.ttl_hours === 8 ? 480 : undefined)),
|
||||||
|
)
|
||||||
const showPsutilInstall = computed(() =>
|
const showPsutilInstall = computed(() =>
|
||||||
monitoringOn.value && isPsutilMissingError(props.slot.metrics?.error, props.slot.metrics?.probe_status),
|
monitoringOn.value && isPsutilMissingError(props.slot.metrics?.error, props.slot.metrics?.probe_status),
|
||||||
)
|
)
|
||||||
|
|
||||||
const chartMetric = ref<'cpu_pct' | 'mem_pct' | 'disk_pct'>('cpu_pct')
|
const chartMetric = ref<'cpu_pct' | 'mem_pct' | 'disk_pct'>('cpu_pct')
|
||||||
|
|
||||||
function pct(v: number | null | undefined) {
|
const load1 = computed(() => props.slot.metrics?.load_1)
|
||||||
return v != null ? `${v}%` : '—'
|
const metricsPending = computed(() => monitoringOn.value && isWatchMetricsPending(props.slot.metrics))
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -38,6 +47,8 @@ function pct(v: number | null | undefined) {
|
|||||||
elevation="0"
|
elevation="0"
|
||||||
border
|
border
|
||||||
rounded="lg"
|
rounded="lg"
|
||||||
|
:link="false"
|
||||||
|
:ripple="false"
|
||||||
class="watch-slot-card pa-3 watch-slot-card--filled"
|
class="watch-slot-card pa-3 watch-slot-card--filled"
|
||||||
:class="{
|
:class="{
|
||||||
'watch-slot-card--error': monitoringOn && slot.metrics?.probe_status && slot.metrics.probe_status !== 'ok',
|
'watch-slot-card--error': monitoringOn && slot.metrics?.probe_status && slot.metrics.probe_status !== 'ok',
|
||||||
@@ -67,19 +78,22 @@ function pct(v: number | null | undefined) {
|
|||||||
<v-btn icon="mdi-close" size="x-small" variant="text" @click="emit('remove', slot.slot_index)" />
|
<v-btn icon="mdi-close" size="x-small" variant="text" @click="emit('remove', slot.slot_index)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex align-center mb-2 ga-1" @click.stop>
|
<div class="watch-ttl-row mb-2" @click.stop>
|
||||||
<span class="text-caption text-medium-emphasis mr-1">时长</span>
|
<span class="text-caption text-medium-emphasis watch-ttl-label">时长</span>
|
||||||
<v-btn-toggle
|
<div class="watch-ttl-chips d-flex flex-wrap ga-1">
|
||||||
:model-value="slotTtl"
|
<v-chip
|
||||||
density="compact"
|
v-for="opt in WATCH_TTL_OPTIONS"
|
||||||
variant="outlined"
|
:key="opt.minutes"
|
||||||
divided
|
:color="slotTtlMinutes === opt.minutes ? 'primary' : undefined"
|
||||||
mandatory
|
:variant="slotTtlMinutes === opt.minutes ? 'flat' : 'outlined'"
|
||||||
@update:model-value="emit('ttl', slot.slot_index, ($event ?? 8) as 8 | 24)"
|
size="x-small"
|
||||||
>
|
label
|
||||||
<v-btn :value="8" size="x-small">8h</v-btn>
|
class="watch-ttl-chip"
|
||||||
<v-btn :value="24" size="x-small">24h</v-btn>
|
@click="emit('ttl', slot.slot_index, opt.minutes)"
|
||||||
</v-btn-toggle>
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!monitoringOn" class="text-caption text-medium-emphasis mb-2" @click.stop>
|
<div v-if="!monitoringOn" class="text-caption text-medium-emphasis mb-2" @click.stop>
|
||||||
@@ -88,6 +102,18 @@ function pct(v: number | null | undefined) {
|
|||||||
|
|
||||||
<template v-if="monitoringOn">
|
<template v-if="monitoringOn">
|
||||||
<div @click.stop>
|
<div @click.stop>
|
||||||
|
<div
|
||||||
|
v-if="metricsPending"
|
||||||
|
class="watch-slot-waiting d-flex flex-column align-center justify-center text-center py-6"
|
||||||
|
>
|
||||||
|
<v-progress-circular indeterminate color="primary" size="36" width="3" />
|
||||||
|
<div class="text-body-2 mt-3">等待服务器回传信息…</div>
|
||||||
|
<div class="text-caption text-medium-emphasis mt-1 px-2">
|
||||||
|
探针约每 5 秒轮询,首次通常 5–15 秒
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
<v-chip
|
<v-chip
|
||||||
v-if="slot.metrics?.probe_status"
|
v-if="slot.metrics?.probe_status"
|
||||||
size="x-small"
|
size="x-small"
|
||||||
@@ -99,10 +125,25 @@ function pct(v: number | null | undefined) {
|
|||||||
{{ probeStatusLabel(slot.metrics.probe_status) }}
|
{{ probeStatusLabel(slot.metrics.probe_status) }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
|
|
||||||
<div class="d-flex justify-space-between text-caption mb-1">
|
<WatchMetricBar label="CPU" :value="slot.metrics?.cpu_pct" />
|
||||||
<span>CPU {{ pct(slot.metrics?.cpu_pct) }}</span>
|
<WatchMetricBar label="内存" :value="slot.metrics?.mem_pct" />
|
||||||
<span>MEM {{ pct(slot.metrics?.mem_pct) }}</span>
|
<WatchMetricBar label="硬盘" :value="slot.metrics?.disk_pct" />
|
||||||
<span>DISK {{ pct(slot.metrics?.disk_pct) }}</span>
|
|
||||||
|
<div class="d-flex align-center justify-space-between text-caption mb-2 mt-1">
|
||||||
|
<span class="text-medium-emphasis">负载</span>
|
||||||
|
<v-tooltip location="top" text="1 分钟平均负载,与宝塔面板「负载」相同(非 CPU 百分比)">
|
||||||
|
<template #activator="{ props: tipProps }">
|
||||||
|
<v-chip
|
||||||
|
v-bind="tipProps"
|
||||||
|
size="x-small"
|
||||||
|
:color="loadAvgColor(load1)"
|
||||||
|
variant="tonal"
|
||||||
|
label
|
||||||
|
>
|
||||||
|
{{ formatLoadAvg(load1) }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-caption text-medium-emphasis mb-1">
|
<div class="text-caption text-medium-emphasis mb-1">
|
||||||
@@ -116,8 +157,8 @@ function pct(v: number | null | undefined) {
|
|||||||
|
|
||||||
<v-btn-toggle v-model="chartMetric" density="compact" variant="outlined" divided class="mb-1">
|
<v-btn-toggle v-model="chartMetric" density="compact" variant="outlined" divided class="mb-1">
|
||||||
<v-btn value="cpu_pct" size="x-small">CPU</v-btn>
|
<v-btn value="cpu_pct" size="x-small">CPU</v-btn>
|
||||||
<v-btn value="mem_pct" size="x-small">MEM</v-btn>
|
<v-btn value="mem_pct" size="x-small">内存</v-btn>
|
||||||
<v-btn value="disk_pct" size="x-small">DISK</v-btn>
|
<v-btn value="disk_pct" size="x-small">硬盘</v-btn>
|
||||||
</v-btn-toggle>
|
</v-btn-toggle>
|
||||||
|
|
||||||
<WatchSparkline :points="slot.sparkline || []" :metric="chartMetric" />
|
<WatchSparkline :points="slot.sparkline || []" :metric="chartMetric" />
|
||||||
@@ -145,18 +186,26 @@ function pct(v: number | null | undefined) {
|
|||||||
|
|
||||||
<div class="d-flex align-center justify-space-between mt-2">
|
<div class="d-flex align-center justify-space-between mt-2">
|
||||||
<span class="text-caption text-medium-emphasis">
|
<span class="text-caption text-medium-emphasis">
|
||||||
剩余 {{ formatRemaining(slot.remaining_sec) }} · {{ slotTtl }}h
|
剩余 {{ formatRemaining(slot.remaining_sec) }} · {{ watchTtlShortLabel(slotTtlMinutes) }}
|
||||||
</span>
|
</span>
|
||||||
<div class="d-flex ga-1">
|
<div class="d-flex ga-1">
|
||||||
<v-btn size="x-small" variant="text" @click.stop="emit('processes', slot)">进程</v-btn>
|
<v-btn size="x-small" variant="text" @click.stop="emit('processes', slot)">进程</v-btn>
|
||||||
<v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn>
|
<v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="metricsPending" class="d-flex align-center justify-space-between mt-2">
|
||||||
|
<span class="text-caption text-medium-emphasis">
|
||||||
|
剩余 {{ formatRemaining(slot.remaining_sec) }} · {{ watchTtlShortLabel(slotTtlMinutes) }}
|
||||||
|
</span>
|
||||||
|
<v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-else class="d-flex align-center justify-space-between mt-2" @click.stop>
|
<div v-else class="d-flex align-center justify-space-between mt-2" @click.stop>
|
||||||
<span class="text-caption text-medium-emphasis">已暂停 · {{ slotTtl }}h</span>
|
<span class="text-caption text-medium-emphasis">已暂停 · {{ watchTtlLabel(slotTtlMinutes) }}</span>
|
||||||
<div class="d-flex ga-1">
|
<div class="d-flex ga-1">
|
||||||
<v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn>
|
<v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,6 +217,8 @@ function pct(v: number | null | undefined) {
|
|||||||
elevation="0"
|
elevation="0"
|
||||||
border
|
border
|
||||||
rounded="lg"
|
rounded="lg"
|
||||||
|
:link="false"
|
||||||
|
:ripple="false"
|
||||||
class="watch-slot-card watch-slot-card--empty d-flex align-center justify-center pa-4"
|
class="watch-slot-card watch-slot-card--empty d-flex align-center justify-center pa-4"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -184,22 +235,27 @@ function pct(v: number | null | undefined) {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.watch-slot-card {
|
.watch-slot-card {
|
||||||
min-height: 220px;
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
/* Vuetify v-card--link 会在 hover 时铺 v-card__overlay 灰层,此处仅保留外框高亮 */
|
||||||
|
.watch-slot-card :deep(.v-card__overlay) {
|
||||||
|
opacity: 0 !important;
|
||||||
}
|
}
|
||||||
.watch-slot-card--empty {
|
.watch-slot-card--empty {
|
||||||
min-height: 220px;
|
min-height: 220px;
|
||||||
border-style: dashed !important;
|
border-style: dashed !important;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.15s ease;
|
transition: border-color 0.15s ease;
|
||||||
}
|
}
|
||||||
.watch-slot-card--empty:hover {
|
.watch-slot-card--empty:hover {
|
||||||
background: rgba(var(--v-theme-primary), 0.04);
|
border-color: rgb(var(--v-theme-primary)) !important;
|
||||||
}
|
}
|
||||||
.watch-slot-card--filled {
|
.watch-slot-card--filled {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
}
|
}
|
||||||
.watch-slot-card--filled:hover {
|
.watch-slot-card--filled:hover {
|
||||||
background: rgba(var(--v-theme-surface-variant), 0.3);
|
border-color: rgb(var(--v-theme-primary)) !important;
|
||||||
}
|
}
|
||||||
.watch-slot-card--error {
|
.watch-slot-card--error {
|
||||||
border-color: rgb(var(--v-theme-error)) !important;
|
border-color: rgb(var(--v-theme-error)) !important;
|
||||||
@@ -212,4 +268,24 @@ function pct(v: number | null | undefined) {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
transform: scale(0.85);
|
transform: scale(0.85);
|
||||||
}
|
}
|
||||||
|
.watch-ttl-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.watch-ttl-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
.watch-ttl-chips {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.watch-ttl-chip {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.watch-slot-waiting {
|
||||||
|
min-height: 160px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useWatchPins, type WatchSlot } from '@/composables/useWatchPins'
|
|||||||
import { useSnackbar } from '@/composables/useSnackbar'
|
import { useSnackbar } from '@/composables/useSnackbar'
|
||||||
import { formatApiError } from '@/utils/apiError'
|
import { formatApiError } from '@/utils/apiError'
|
||||||
import { http } from '@/api'
|
import { http } from '@/api'
|
||||||
|
import { watchTtlShortLabel, type WatchTtlMinutes } from '@/constants/watchTtl'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const snackbar = useSnackbar()
|
const snackbar = useSnackbar()
|
||||||
@@ -42,12 +43,12 @@ async function onMonitoring(slotIndex: number, enabled: boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onTtl(slotIndex: number, hours: 8 | 24) {
|
async function onTtl(slotIndex: number, minutes: WatchTtlMinutes) {
|
||||||
const slot = slots.value.find((s) => s.slot_index === slotIndex)
|
const slot = slots.value.find((s) => s.slot_index === slotIndex)
|
||||||
if (!slot || slot.empty || slot.ttl_hours === hours) return
|
if (!slot || slot.empty || slot.ttl_minutes === minutes) return
|
||||||
try {
|
try {
|
||||||
await setSlotTtl(slotIndex, hours)
|
await setSlotTtl(slotIndex, minutes)
|
||||||
snackbar(`监测时长已设为 ${hours} 小时 · 槽 ${slotIndex + 1}`, 'success')
|
snackbar(`监测时长已设为 ${watchTtlShortLabel(minutes)} · 槽 ${slotIndex + 1}`, 'success')
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
snackbar(formatApiError(e, '更新监测时长失败'), 'error')
|
snackbar(formatApiError(e, '更新监测时长失败'), 'error')
|
||||||
}
|
}
|
||||||
@@ -114,7 +115,7 @@ async function onPickServer(serverId: number) {
|
|||||||
<div class="watch-slot-row mb-4">
|
<div class="watch-slot-row mb-4">
|
||||||
<div class="d-flex align-center mb-2">
|
<div class="d-flex align-center mb-2">
|
||||||
<span class="text-subtitle-2">实时监测</span>
|
<span class="text-subtitle-2">实时监测</span>
|
||||||
<v-chip size="x-small" variant="tonal" class="ml-2" label>最多 4 台 · 默认 8h · 暂停不占 TTL</v-chip>
|
<v-chip size="x-small" variant="tonal" class="ml-2" label>最多 4 台 · 默认 30 分 · 暂停不占 TTL</v-chip>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn size="small" variant="text" prepend-icon="mdi-history" :to="{ name: 'WatchMetrics' }">
|
<v-btn size="small" variant="text" prepend-icon="mdi-history" :to="{ name: 'WatchMetrics' }">
|
||||||
监测历史
|
监测历史
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useSnackbar } from '@/composables/useSnackbar'
|
|||||||
import { useFilesStore } from '@/stores/files'
|
import { useFilesStore } from '@/stores/files'
|
||||||
import type { FileEntry } from '@/types/api'
|
import type { FileEntry } from '@/types/api'
|
||||||
import { isArchiveFile } from '@/utils/fileSort'
|
import { isArchiveFile } from '@/utils/fileSort'
|
||||||
import { saveBlobAsFile } from '@/utils/fileDownload'
|
|
||||||
import { invalidateCachedFile } from '@/utils/filePreload'
|
import { invalidateCachedFile } from '@/utils/filePreload'
|
||||||
import {
|
import {
|
||||||
joinRemotePath,
|
joinRemotePath,
|
||||||
@@ -27,6 +26,7 @@ export function useFilesActions(options: {
|
|||||||
previewFile: (item: FileEntry) => Promise<void>
|
previewFile: (item: FileEntry) => Promise<void>
|
||||||
openInTerminal: () => void
|
openInTerminal: () => void
|
||||||
editorWorkbench: Ref<{ openFile: (p: OpenFilePayload) => void } | null>
|
editorWorkbench: Ref<{ openFile: (p: OpenFilePayload) => void } | null>
|
||||||
|
waitForEditorWorkbench?: () => Promise<{ openFile: (p: OpenFilePayload) => void } | null>
|
||||||
actionLoading?: Ref<boolean>
|
actionLoading?: Ref<boolean>
|
||||||
}) {
|
}) {
|
||||||
const snackbar = useSnackbar()
|
const snackbar = useSnackbar()
|
||||||
@@ -172,9 +172,6 @@ export function useFilesActions(options: {
|
|||||||
case 'preview':
|
case 'preview':
|
||||||
void options.previewFile(item)
|
void options.previewFile(item)
|
||||||
break
|
break
|
||||||
case 'download':
|
|
||||||
void downloadFile(item)
|
|
||||||
break
|
|
||||||
case 'copyPath':
|
case 'copyPath':
|
||||||
void copyRemotePath(item)
|
void copyRemotePath(item)
|
||||||
break
|
break
|
||||||
@@ -293,7 +290,11 @@ export function useFilesActions(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function doNewFile() {
|
async function doNewFile() {
|
||||||
if (!selectedServer.value || !newFileName.value) return
|
if (!selectedServer.value) {
|
||||||
|
snackbar('请先选择服务器', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!newFileName.value) return
|
||||||
const check = validatePathSegment(newFileName.value)
|
const check = validatePathSegment(newFileName.value)
|
||||||
if (check !== true) {
|
if (check !== true) {
|
||||||
snackbar(check, 'warning')
|
snackbar(check, 'warning')
|
||||||
@@ -315,7 +316,13 @@ export function useFilesActions(options: {
|
|||||||
await options.browse({ force: true })
|
await options.browse({ force: true })
|
||||||
snackbar('文件已创建')
|
snackbar('文件已创建')
|
||||||
await nextTick()
|
await nextTick()
|
||||||
options.editorWorkbench.value?.openFile({
|
const wb =
|
||||||
|
(await options.waitForEditorWorkbench?.()) ?? options.editorWorkbench.value
|
||||||
|
if (!wb?.openFile) {
|
||||||
|
snackbar('文件已创建;编辑器未就绪,请从列表双击打开', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wb.openFile({
|
||||||
path,
|
path,
|
||||||
name,
|
name,
|
||||||
content: '',
|
content: '',
|
||||||
@@ -414,22 +421,6 @@ export function useFilesActions(options: {
|
|||||||
else snackbar(`删除完成:成功 ${ok},失败 ${fail}`, fail > 0 ? 'warning' : 'success')
|
else snackbar(`删除完成:成功 ${ok},失败 ${fail}`, fail > 0 ? 'warning' : 'success')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadFile(item: FileEntry) {
|
|
||||||
if (!selectedServer.value) return
|
|
||||||
try {
|
|
||||||
const remotePath = joinRemotePath(currentPath.value, item.name)
|
|
||||||
const { blob, filename } = await http.download('/sync/download', {
|
|
||||||
server_id: selectedServer.value,
|
|
||||||
path: remotePath,
|
|
||||||
})
|
|
||||||
saveBlobAsFile(blob, filename)
|
|
||||||
snackbar(`已下载 ${filename}`)
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const msg = e instanceof Error ? e.message : '下载失败'
|
|
||||||
snackbar(msg, 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmDeleteFile(item: FileEntry) {
|
async function confirmDeleteFile(item: FileEntry) {
|
||||||
deletingFile.value = item
|
deletingFile.value = item
|
||||||
showFileDelete.value = true
|
showFileDelete.value = true
|
||||||
@@ -561,6 +552,14 @@ export function useFilesActions(options: {
|
|||||||
showContextMenu.value = true
|
showContextMenu.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openNewFileDialog() {
|
||||||
|
if (!selectedServer.value) {
|
||||||
|
snackbar('请先选择服务器', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showNewFile.value = true
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dropActive,
|
dropActive,
|
||||||
showUpload,
|
showUpload,
|
||||||
@@ -600,12 +599,12 @@ export function useFilesActions(options: {
|
|||||||
doCompress,
|
doCompress,
|
||||||
doDecompress,
|
doDecompress,
|
||||||
doNewFile,
|
doNewFile,
|
||||||
|
openNewFileDialog,
|
||||||
startRename,
|
startRename,
|
||||||
doRename,
|
doRename,
|
||||||
startChmod,
|
startChmod,
|
||||||
doChmod,
|
doChmod,
|
||||||
batchDelete,
|
batchDelete,
|
||||||
downloadFile,
|
|
||||||
confirmDeleteFile,
|
confirmDeleteFile,
|
||||||
doDeleteFile,
|
doDeleteFile,
|
||||||
doMkdir,
|
doMkdir,
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function useFilesEditor(options: {
|
|||||||
function rejectOversizedFile(item: FileEntry, action: '编辑' | '查看'): boolean {
|
function rejectOversizedFile(item: FileEntry, action: '编辑' | '查看'): boolean {
|
||||||
if (exceedsEditorMaxSize(item.size)) {
|
if (exceedsEditorMaxSize(item.size)) {
|
||||||
snackbar(
|
snackbar(
|
||||||
`文件超过 2MB(${formatSize(item.size)}),无法${action},请下载或使用终端`,
|
`文件超过 2MB(${formatSize(item.size)}),无法${action},请使用终端`,
|
||||||
'warning',
|
'warning',
|
||||||
)
|
)
|
||||||
return true
|
return true
|
||||||
@@ -155,6 +155,7 @@ export function useFilesEditor(options: {
|
|||||||
previewContent,
|
previewContent,
|
||||||
previewLoading,
|
previewLoading,
|
||||||
editorWorkbench,
|
editorWorkbench,
|
||||||
|
waitForEditorWorkbench,
|
||||||
previewFile,
|
previewFile,
|
||||||
editFile,
|
editFile,
|
||||||
onEditorSaved,
|
onEditorSaved,
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export function useFilesPage() {
|
|||||||
previewFile: editor.previewFile,
|
previewFile: editor.previewFile,
|
||||||
openInTerminal: nav.openInTerminal,
|
openInTerminal: nav.openInTerminal,
|
||||||
editorWorkbench: editor.editorWorkbench,
|
editorWorkbench: editor.editorWorkbench,
|
||||||
|
waitForEditorWorkbench: editor.waitForEditorWorkbench,
|
||||||
actionLoading,
|
actionLoading,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -140,7 +141,7 @@ export function useFilesPage() {
|
|||||||
{ title: '归属', key: 'owner', width: 110, sortable: false },
|
{ title: '归属', key: 'owner', width: 110, sortable: false },
|
||||||
{ title: '大小', key: 'size', width: 120 },
|
{ title: '大小', key: 'size', width: 120 },
|
||||||
{ title: '修改时间', key: 'modified', width: 180 },
|
{ title: '修改时间', key: 'modified', width: 180 },
|
||||||
{ title: '操作', key: 'actions', width: 300, sortable: false, align: 'end' as const },
|
{ title: '操作', key: 'actions', width: 160, sortable: false, align: 'end' as const },
|
||||||
]
|
]
|
||||||
|
|
||||||
useFilesHotkeys(
|
useFilesHotkeys(
|
||||||
@@ -228,6 +229,7 @@ export function useFilesPage() {
|
|||||||
showNewFile: actions.showNewFile,
|
showNewFile: actions.showNewFile,
|
||||||
newFileName: actions.newFileName,
|
newFileName: actions.newFileName,
|
||||||
doNewFile: actions.doNewFile,
|
doNewFile: actions.doNewFile,
|
||||||
|
openNewFileDialog: actions.openNewFileDialog,
|
||||||
showMkdir: actions.showMkdir,
|
showMkdir: actions.showMkdir,
|
||||||
mkdirName: actions.mkdirName,
|
mkdirName: actions.mkdirName,
|
||||||
doMkdir: actions.doMkdir,
|
doMkdir: actions.doMkdir,
|
||||||
@@ -268,7 +270,6 @@ export function useFilesPage() {
|
|||||||
pasteClipboard: actions.pasteClipboard,
|
pasteClipboard: actions.pasteClipboard,
|
||||||
editFile: editor.editFile,
|
editFile: editor.editFile,
|
||||||
previewFile: editor.previewFile,
|
previewFile: editor.previewFile,
|
||||||
downloadFile: actions.downloadFile,
|
|
||||||
confirmDeleteFile: actions.confirmDeleteFile,
|
confirmDeleteFile: actions.confirmDeleteFile,
|
||||||
openInTerminal: nav.openInTerminal,
|
openInTerminal: nav.openInTerminal,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import { FitAddon } from '@xterm/addon-fit'
|
|||||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||||
import { http } from '@/api'
|
import { http } from '@/api'
|
||||||
import { useSnackbar } from '@/composables/useSnackbar'
|
import { useSnackbar } from '@/composables/useSnackbar'
|
||||||
import { sortQuickCommands, type QuickCmdItem } from '@/composables/useTerminalQuickCommands'
|
import {
|
||||||
|
formatQuickCmdForSend,
|
||||||
|
sortQuickCommands,
|
||||||
|
type QuickCmdItem,
|
||||||
|
} from '@/composables/useTerminalQuickCommands'
|
||||||
import { buildWebSocketUrl } from '@/utils/wsUrl'
|
import { buildWebSocketUrl } from '@/utils/wsUrl'
|
||||||
import {
|
import {
|
||||||
createNative,
|
createNative,
|
||||||
@@ -704,7 +708,7 @@ export function useTerminalSessions(nexusDrawer: Ref<boolean>) {
|
|||||||
snackbar(s?.status === 'connecting' ? '终端连接中,请稍候' : '终端未连接', 'warning')
|
snackbar(s?.status === 'connecting' ? '终端连接中,请稍候' : '终端未连接', 'warning')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWsData(s.id, cmd)
|
sendWsData(s.id, formatQuickCmdForSend(cmd))
|
||||||
getNative(s.id)?.term?.focus()
|
getNative(s.id)?.term?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,382 @@
|
|||||||
|
/**
|
||||||
|
* Global floating browser — persists tabs/history to MySQL via /api/browser/state.
|
||||||
|
*/
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { http } from '@/api'
|
||||||
|
import { normalizeBrowserUrl } from '@/utils/browserUrl'
|
||||||
|
import type { FloatRect } from '@/composables/useFloatingPanel'
|
||||||
|
|
||||||
|
const LEGACY_HISTORY_KEY = 'nexus_browser_history_v1'
|
||||||
|
const SAVE_DEBOUNCE_MS = 700
|
||||||
|
|
||||||
|
export interface BrowserVisit {
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserTab {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
stack: string[]
|
||||||
|
stackIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserStateResponse {
|
||||||
|
url_history: string[]
|
||||||
|
visits: BrowserVisit[]
|
||||||
|
tabs: Array<{
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
title?: string
|
||||||
|
stack?: string[]
|
||||||
|
stack_index?: number
|
||||||
|
}>
|
||||||
|
active_tab_id: string | null
|
||||||
|
minimized: boolean
|
||||||
|
rect: FloatRect | null
|
||||||
|
minimized_rect: FloatRect | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const bootstrapped = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const panelOpen = ref(false)
|
||||||
|
const minimized = ref(false)
|
||||||
|
const maximized = ref(false)
|
||||||
|
const lastError = ref<string | null>(null)
|
||||||
|
const addressDraft = ref('')
|
||||||
|
const urlHistory = ref<string[]>([])
|
||||||
|
const visits = ref<BrowserVisit[]>([])
|
||||||
|
const tabs = ref<BrowserTab[]>([])
|
||||||
|
const activeTabId = ref<string | null>(null)
|
||||||
|
const panelRect = ref<FloatRect | null>(null)
|
||||||
|
const minimizedRect = ref<FloatRect | null>(null)
|
||||||
|
|
||||||
|
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function tabLabel(tab: BrowserTab): string {
|
||||||
|
try {
|
||||||
|
return tab.title || new URL(tab.url).hostname
|
||||||
|
} catch {
|
||||||
|
return tab.title || tab.url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function newTabId(): string {
|
||||||
|
return crypto.randomUUID()
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeTabs(): BrowserStateResponse['tabs'] {
|
||||||
|
return tabs.value.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
url: t.url,
|
||||||
|
title: t.title,
|
||||||
|
stack: t.stack,
|
||||||
|
stack_index: t.stackIndex,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyServerState(data: BrowserStateResponse) {
|
||||||
|
urlHistory.value = data.url_history || []
|
||||||
|
visits.value = (data.visits || []).map((v) => ({
|
||||||
|
url: v.url,
|
||||||
|
title: v.title || v.url,
|
||||||
|
at: v.at,
|
||||||
|
}))
|
||||||
|
tabs.value = (data.tabs || []).map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
url: t.url,
|
||||||
|
title: t.title || t.url,
|
||||||
|
stack: t.stack?.length ? t.stack : (t.url ? [t.url] : []),
|
||||||
|
stackIndex: typeof t.stack_index === 'number' ? t.stack_index : (t.stack?.length ?? 1) - 1,
|
||||||
|
}))
|
||||||
|
activeTabId.value = data.active_tab_id
|
||||||
|
if (!activeTabId.value && tabs.value.length) {
|
||||||
|
activeTabId.value = tabs.value[0]!.id
|
||||||
|
}
|
||||||
|
minimized.value = Boolean(data.minimized)
|
||||||
|
panelRect.value = data.rect
|
||||||
|
minimizedRect.value = data.minimized_rect
|
||||||
|
panelOpen.value = tabs.value.length > 0 && !minimized.value
|
||||||
|
syncAddressFromActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncAddressFromActive() {
|
||||||
|
const tab = activeTab.value
|
||||||
|
addressDraft.value = tab?.url ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTab = computed(() => tabs.value.find((t) => t.id === activeTabId.value) ?? null)
|
||||||
|
|
||||||
|
const isActive = computed(() => tabs.value.length > 0 || panelOpen.value)
|
||||||
|
|
||||||
|
const canGoBack = computed(() => {
|
||||||
|
const tab = activeTab.value
|
||||||
|
return tab ? tab.stackIndex > 0 : false
|
||||||
|
})
|
||||||
|
|
||||||
|
const canGoForward = computed(() => {
|
||||||
|
const tab = activeTab.value
|
||||||
|
return tab ? tab.stackIndex < tab.stack.length - 1 : false
|
||||||
|
})
|
||||||
|
|
||||||
|
function scheduleSave(extra?: { recordUrl?: string; recordTitle?: string }) {
|
||||||
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
|
saveTimer = setTimeout(() => {
|
||||||
|
void persistState(extra)
|
||||||
|
}, SAVE_DEBOUNCE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistState(extra?: { recordUrl?: string; recordTitle?: string }) {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
tabs: serializeTabs(),
|
||||||
|
active_tab_id: activeTabId.value,
|
||||||
|
minimized: minimized.value,
|
||||||
|
rect: panelRect.value,
|
||||||
|
minimized_rect: minimizedRect.value,
|
||||||
|
}
|
||||||
|
if (extra?.recordUrl) {
|
||||||
|
body.record_url = extra.recordUrl
|
||||||
|
if (extra.recordTitle) body.record_title = extra.recordTitle
|
||||||
|
} else {
|
||||||
|
body.url_history = urlHistory.value
|
||||||
|
body.visits = visits.value
|
||||||
|
}
|
||||||
|
const res = await http.put<BrowserStateResponse>('/browser/state', body)
|
||||||
|
applyServerState(res)
|
||||||
|
} catch {
|
||||||
|
/* keep local state */
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateLegacyHistory() {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(LEGACY_HISTORY_KEY)
|
||||||
|
if (!raw) return
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
if (!Array.isArray(parsed)) return
|
||||||
|
const urls = parsed.filter((u): u is string => typeof u === 'string' && !!normalizeBrowserUrl(u))
|
||||||
|
if (!urls.length) return
|
||||||
|
for (const url of [...urls].reverse()) {
|
||||||
|
const normalized = normalizeBrowserUrl(url)
|
||||||
|
if (normalized) {
|
||||||
|
await persistState({ recordUrl: normalized, recordTitle: normalized })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sessionStorage.removeItem(LEGACY_HISTORY_KEY)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
if (bootstrapped.value) return
|
||||||
|
try {
|
||||||
|
const res = await http.get<BrowserStateResponse>('/browser/state')
|
||||||
|
applyServerState(res)
|
||||||
|
if (!visits.value.length && !urlHistory.value.length) {
|
||||||
|
await migrateLegacyHistory()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* offline */
|
||||||
|
} finally {
|
||||||
|
bootstrapped.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPanel() {
|
||||||
|
panelOpen.value = true
|
||||||
|
minimized.value = false
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
function minimizePanel() {
|
||||||
|
minimized.value = true
|
||||||
|
panelOpen.value = false
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartBrowser() {
|
||||||
|
const tab: BrowserTab = {
|
||||||
|
id: newTabId(),
|
||||||
|
url: '',
|
||||||
|
title: '新标签',
|
||||||
|
stack: [],
|
||||||
|
stackIndex: -1,
|
||||||
|
}
|
||||||
|
tabs.value = [tab]
|
||||||
|
activeTabId.value = tab.id
|
||||||
|
addressDraft.value = ''
|
||||||
|
lastError.value = null
|
||||||
|
openPanel()
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUrl(input: string, opts?: { title?: string; newTab?: boolean }) {
|
||||||
|
const normalized = normalizeBrowserUrl(input)
|
||||||
|
if (!normalized) {
|
||||||
|
lastError.value = '请输入有效的 http 或 https 地址'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lastError.value = null
|
||||||
|
|
||||||
|
if (opts?.newTab || !tabs.value.length || !activeTab.value) {
|
||||||
|
const tab: BrowserTab = {
|
||||||
|
id: newTabId(),
|
||||||
|
url: normalized,
|
||||||
|
title: opts?.title || normalized,
|
||||||
|
stack: [normalized],
|
||||||
|
stackIndex: 0,
|
||||||
|
}
|
||||||
|
tabs.value = [...tabs.value, tab].slice(-12)
|
||||||
|
activeTabId.value = tab.id
|
||||||
|
} else {
|
||||||
|
const tab = activeTab.value
|
||||||
|
const stack = tab.stack.slice(0, tab.stackIndex + 1)
|
||||||
|
if (!stack.length || stack[stack.length - 1] !== normalized) stack.push(normalized)
|
||||||
|
tab.url = normalized
|
||||||
|
if (opts?.title) tab.title = opts.title
|
||||||
|
tab.stack = stack
|
||||||
|
tab.stackIndex = stack.length - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
addressDraft.value = normalized
|
||||||
|
openPanel()
|
||||||
|
scheduleSave({ recordUrl: normalized, recordTitle: opts?.title })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigate(input: string) {
|
||||||
|
return openUrl(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateActiveFromRemote(url: string, title: string) {
|
||||||
|
const tab = activeTab.value
|
||||||
|
if (!tab || !url) return
|
||||||
|
tab.url = url
|
||||||
|
if (title) tab.title = title
|
||||||
|
addressDraft.value = url
|
||||||
|
scheduleSave({ recordUrl: url, recordTitle: title })
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshActive() {
|
||||||
|
/* remote reload handled in panel */
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
const tab = activeTab.value
|
||||||
|
if (!tab || tab.stackIndex <= 0) return
|
||||||
|
tab.stackIndex -= 1
|
||||||
|
tab.url = tab.stack[tab.stackIndex]!
|
||||||
|
addressDraft.value = tab.url
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
function goForward() {
|
||||||
|
const tab = activeTab.value
|
||||||
|
if (!tab || tab.stackIndex >= tab.stack.length - 1) return
|
||||||
|
tab.stackIndex += 1
|
||||||
|
tab.url = tab.stack[tab.stackIndex]!
|
||||||
|
addressDraft.value = tab.url
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openExternal() {
|
||||||
|
const tab = activeTab.value
|
||||||
|
if (!tab?.url) return
|
||||||
|
window.open(tab.url, '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(id: string) {
|
||||||
|
activeTabId.value = id
|
||||||
|
syncAddressFromActive()
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTab(id: string) {
|
||||||
|
if (tabs.value.length <= 1) return
|
||||||
|
const idx = tabs.value.findIndex((t) => t.id === id)
|
||||||
|
if (idx < 0) return
|
||||||
|
const next = tabs.value.filter((t) => t.id !== id)
|
||||||
|
tabs.value = next
|
||||||
|
if (activeTabId.value === id) {
|
||||||
|
activeTabId.value = next[idx]?.id ?? next[idx - 1]?.id ?? null
|
||||||
|
}
|
||||||
|
syncAddressFromActive()
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
function newEmptyTab() {
|
||||||
|
const tab: BrowserTab = {
|
||||||
|
id: newTabId(),
|
||||||
|
url: '',
|
||||||
|
title: '新标签',
|
||||||
|
stack: [],
|
||||||
|
stackIndex: -1,
|
||||||
|
}
|
||||||
|
tabs.value = [...tabs.value, tab].slice(-12)
|
||||||
|
activeTabId.value = tab.id
|
||||||
|
addressDraft.value = ''
|
||||||
|
openPanel()
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFromHistory(url: string) {
|
||||||
|
openUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePanelRect(rect: FloatRect) {
|
||||||
|
panelRect.value = rect
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveMinimizedRect(rect: FloatRect) {
|
||||||
|
minimizedRect.value = rect
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGlobalBrowser() {
|
||||||
|
return {
|
||||||
|
bootstrapped,
|
||||||
|
saving,
|
||||||
|
panelOpen,
|
||||||
|
minimized,
|
||||||
|
maximized,
|
||||||
|
lastError,
|
||||||
|
addressDraft,
|
||||||
|
urlHistory,
|
||||||
|
visits,
|
||||||
|
tabs,
|
||||||
|
activeTabId,
|
||||||
|
activeTab,
|
||||||
|
panelRect,
|
||||||
|
minimizedRect,
|
||||||
|
isActive,
|
||||||
|
canGoBack,
|
||||||
|
canGoForward,
|
||||||
|
tabLabel,
|
||||||
|
bootstrap,
|
||||||
|
openPanel,
|
||||||
|
minimizePanel,
|
||||||
|
restartBrowser,
|
||||||
|
openUrl,
|
||||||
|
navigate,
|
||||||
|
updateActiveFromRemote,
|
||||||
|
refreshActive,
|
||||||
|
goBack,
|
||||||
|
goForward,
|
||||||
|
openExternal,
|
||||||
|
switchTab,
|
||||||
|
closeTab,
|
||||||
|
newEmptyTab,
|
||||||
|
openFromHistory,
|
||||||
|
savePanelRect,
|
||||||
|
saveMinimizedRect,
|
||||||
|
scheduleSave,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
/**
|
||||||
|
* Remote browser session — canvas screencast over /ws/browser/{session_id}.
|
||||||
|
*/
|
||||||
|
import { onUnmounted, ref, shallowRef } from 'vue'
|
||||||
|
import { http } from '@/api'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { buildWebSocketUrl } from '@/utils/wsUrl'
|
||||||
|
|
||||||
|
export interface BrowserSessionInfo {
|
||||||
|
session_id: string
|
||||||
|
viewport_w: number
|
||||||
|
viewport_h: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoteBrowserSession() {
|
||||||
|
const connected = ref(false)
|
||||||
|
const connecting = ref(false)
|
||||||
|
const remoteError = ref<string | null>(null)
|
||||||
|
const sessionId = shallowRef<string | null>(null)
|
||||||
|
const viewportW = ref(1280)
|
||||||
|
const viewportH = ref(720)
|
||||||
|
|
||||||
|
let ws: WebSocket | null = null
|
||||||
|
let canvasEl: HTMLCanvasElement | null = null
|
||||||
|
let onPageInfo: ((url: string, title: string) => void) | null = null
|
||||||
|
let dragActive = false
|
||||||
|
let lastPointerX = 0
|
||||||
|
let lastPointerY = 0
|
||||||
|
|
||||||
|
function setCanvas(el: HTMLCanvasElement | null) {
|
||||||
|
canvasEl = el
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPageInfoHandler(fn: (url: string, title: string) => void) {
|
||||||
|
onPageInfo = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(msg: Record<string, unknown>) {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||||
|
ws.send(JSON.stringify(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawFrame(dataB64: string, metadata?: { deviceWidth?: number; deviceHeight?: number }) {
|
||||||
|
if (!canvasEl || !dataB64) return
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
if (!canvasEl) return
|
||||||
|
const w = metadata?.deviceWidth || img.width
|
||||||
|
const h = metadata?.deviceHeight || img.height
|
||||||
|
if (canvasEl.width !== w) canvasEl.width = w
|
||||||
|
if (canvasEl.height !== h) canvasEl.height = h
|
||||||
|
const ctx = canvasEl.getContext('2d')
|
||||||
|
ctx?.drawImage(img, 0, 0, w, h)
|
||||||
|
}
|
||||||
|
img.src = `data:image/jpeg;base64,${dataB64}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasCoords(event: MouseEvent): { x: number; y: number } {
|
||||||
|
if (!canvasEl) return { x: 0, y: 0 }
|
||||||
|
const rect = canvasEl.getBoundingClientRect()
|
||||||
|
const scaleX = canvasEl.width / rect.width
|
||||||
|
const scaleY = canvasEl.height / rect.height
|
||||||
|
return {
|
||||||
|
x: (event.clientX - rect.left) * scaleX,
|
||||||
|
y: (event.clientY - rect.top) * scaleY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendPointerMove(x: number, y: number) {
|
||||||
|
const dx = x - lastPointerX
|
||||||
|
const dy = y - lastPointerY
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
const steps = Math.max(1, Math.min(25, Math.ceil(dist / 6)))
|
||||||
|
send({ type: 'MOUSE', event: 'move', x, y, steps })
|
||||||
|
lastPointerX = x
|
||||||
|
lastPointerY = y
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWindowMouseMove(e: MouseEvent) {
|
||||||
|
if (!dragActive || !canvasEl) return
|
||||||
|
const { x, y } = canvasCoords(e)
|
||||||
|
sendPointerMove(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
function endPointerDrag(e: MouseEvent) {
|
||||||
|
if (!dragActive) return
|
||||||
|
dragActive = false
|
||||||
|
window.removeEventListener('mousemove', onWindowMouseMove)
|
||||||
|
window.removeEventListener('mouseup', endPointerDrag)
|
||||||
|
const { x, y } = canvasCoords(e)
|
||||||
|
const button = e.button === 2 ? 'right' : e.button === 1 ? 'middle' : 'left'
|
||||||
|
send({ type: 'MOUSE', event: 'up', x, y, button })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCanvasMouseMove(e: MouseEvent) {
|
||||||
|
if (dragActive) return
|
||||||
|
const { x, y } = canvasCoords(e)
|
||||||
|
sendPointerMove(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCanvasMouseDown(e: MouseEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
const { x, y } = canvasCoords(e)
|
||||||
|
lastPointerX = x
|
||||||
|
lastPointerY = y
|
||||||
|
const button = e.button === 2 ? 'right' : e.button === 1 ? 'middle' : 'left'
|
||||||
|
send({ type: 'MOUSE', event: 'down', x, y, button })
|
||||||
|
dragActive = true
|
||||||
|
window.addEventListener('mousemove', onWindowMouseMove)
|
||||||
|
window.addEventListener('mouseup', endPointerDrag)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCanvasMouseUp(e: MouseEvent) {
|
||||||
|
endPointerDrag(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCanvasWheel(e: WheelEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
const { x, y } = canvasCoords(e)
|
||||||
|
send({ type: 'MOUSE', event: 'wheel', x, y, delta_y: e.deltaY })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCanvasKeyDown(e: KeyboardEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
send({ type: 'KEY', event: 'down', key: e.key, code: e.code })
|
||||||
|
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||||
|
send({ type: 'KEY', event: 'type', text: e.key })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
connected.value = false
|
||||||
|
if (ws) {
|
||||||
|
ws.close()
|
||||||
|
ws = null
|
||||||
|
}
|
||||||
|
sessionId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect(viewportWIn = 1280, viewportHIn = 720) {
|
||||||
|
if (connecting.value || connected.value) return
|
||||||
|
connecting.value = true
|
||||||
|
remoteError.value = null
|
||||||
|
try {
|
||||||
|
const status = await http.get<{ enabled: boolean }>('/browser/status')
|
||||||
|
if (!status.enabled) {
|
||||||
|
remoteError.value = '服务端浏览器未启用'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const created = await http.post<BrowserSessionInfo>('/browser/sessions', {
|
||||||
|
viewport_w: viewportWIn,
|
||||||
|
viewport_h: viewportHIn,
|
||||||
|
})
|
||||||
|
sessionId.value = created.session_id
|
||||||
|
viewportW.value = created.viewport_w
|
||||||
|
viewportH.value = created.viewport_h
|
||||||
|
|
||||||
|
const token = useAuthStore().token
|
||||||
|
if (!token) {
|
||||||
|
remoteError.value = '未登录'
|
||||||
|
await http.delete(`/browser/sessions/${created.session_id}`).catch(() => {})
|
||||||
|
sessionId.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const url = buildWebSocketUrl(`/ws/browser/${created.session_id}`, { token })
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const socket = new WebSocket(url)
|
||||||
|
ws = socket
|
||||||
|
socket.onopen = () => {
|
||||||
|
connected.value = true
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
socket.onerror = () => reject(new Error('WebSocket 连接失败'))
|
||||||
|
socket.onclose = () => {
|
||||||
|
connected.value = false
|
||||||
|
}
|
||||||
|
socket.onmessage = (ev) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(String(ev.data)) as Record<string, unknown>
|
||||||
|
const type = msg.type as string
|
||||||
|
if (type === 'SCREENCAST_FRAME') {
|
||||||
|
drawFrame(String(msg.data_b64 || ''), msg.metadata as { deviceWidth?: number; deviceHeight?: number })
|
||||||
|
} else if (type === 'PAGE_INFO') {
|
||||||
|
onPageInfo?.(String(msg.url || ''), String(msg.title || ''))
|
||||||
|
} else if (type === 'ERROR') {
|
||||||
|
remoteError.value = String(msg.message || '远程浏览器错误')
|
||||||
|
} else if (type === 'BROWSER_INIT') {
|
||||||
|
viewportW.value = Number(msg.viewport_w) || viewportW.value
|
||||||
|
viewportH.value = Number(msg.viewport_h) || viewportH.value
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (wsErr) {
|
||||||
|
await http.delete(`/browser/sessions/${created.session_id}`).catch(() => {})
|
||||||
|
throw wsErr
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
remoteError.value = e instanceof Error ? e.message : '无法连接远程浏览器'
|
||||||
|
disconnect()
|
||||||
|
} finally {
|
||||||
|
connecting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigate(url: string) {
|
||||||
|
send({ type: 'NAVIGATE', url })
|
||||||
|
}
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
send({ type: 'RELOAD' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
send({ type: 'GO_BACK' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goForward() {
|
||||||
|
send({ type: 'GO_FORWARD' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeViewport(width: number, height: number) {
|
||||||
|
send({ type: 'VIEWPORT', width, height })
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('mousemove', onWindowMouseMove)
|
||||||
|
window.removeEventListener('mouseup', endPointerDrag)
|
||||||
|
disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
connected,
|
||||||
|
connecting,
|
||||||
|
remoteError,
|
||||||
|
sessionId,
|
||||||
|
viewportW,
|
||||||
|
viewportH,
|
||||||
|
setCanvas,
|
||||||
|
setPageInfoHandler,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
navigate,
|
||||||
|
reload,
|
||||||
|
goBack,
|
||||||
|
goForward,
|
||||||
|
resizeViewport,
|
||||||
|
onCanvasMouseMove,
|
||||||
|
onCanvasMouseDown,
|
||||||
|
onCanvasMouseUp,
|
||||||
|
onCanvasWheel,
|
||||||
|
onCanvasKeyDown,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,12 @@ export function sortQuickCommands(items: QuickCmdItem[]): QuickCmdItem[] {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 快捷命令点击执行时追加回车(与命令栏 sendCmd 一致);已含 \\r/\\n 时不重复追加 */
|
||||||
|
export function formatQuickCmdForSend(cmd: string): string {
|
||||||
|
const body = cmd.replace(/[\r\n]+$/, '')
|
||||||
|
return body + '\r'
|
||||||
|
}
|
||||||
|
|
||||||
export function useTerminalQuickCommands() {
|
export function useTerminalQuickCommands() {
|
||||||
const commands = ref<QuickCmdItem[]>([])
|
const commands = ref<QuickCmdItem[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { buildWebSocketUrl } from '@/utils/wsUrl'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { formatApiError } from '@/utils/apiError'
|
import { formatApiError } from '@/utils/apiError'
|
||||||
|
|
||||||
|
import type { WatchTtlMinutes } from '@/constants/watchTtl'
|
||||||
|
|
||||||
export interface WatchMetrics {
|
export interface WatchMetrics {
|
||||||
probe_status?: string
|
probe_status?: string
|
||||||
source?: string
|
source?: string
|
||||||
@@ -14,6 +16,9 @@ export interface WatchMetrics {
|
|||||||
cpu_pct?: number | null
|
cpu_pct?: number | null
|
||||||
mem_pct?: number | null
|
mem_pct?: number | null
|
||||||
disk_pct?: number | null
|
disk_pct?: number | null
|
||||||
|
load_1?: number | null
|
||||||
|
load_5?: number | null
|
||||||
|
load_15?: number | null
|
||||||
net_up_bps?: number | null
|
net_up_bps?: number | null
|
||||||
net_down_bps?: number | null
|
net_down_bps?: number | null
|
||||||
disk_read_bps?: number | null
|
disk_read_bps?: number | null
|
||||||
@@ -49,7 +54,9 @@ export interface WatchSlot {
|
|||||||
expires_at?: string
|
expires_at?: string
|
||||||
remaining_sec?: number
|
remaining_sec?: number
|
||||||
monitoring_enabled?: boolean
|
monitoring_enabled?: boolean
|
||||||
ttl_hours?: 8 | 24
|
ttl_minutes?: WatchTtlMinutes
|
||||||
|
/** @deprecated 使用 ttl_minutes */
|
||||||
|
ttl_hours?: number
|
||||||
metrics?: WatchMetrics | null
|
metrics?: WatchMetrics | null
|
||||||
sparkline?: WatchSparkPoint[]
|
sparkline?: WatchSparkPoint[]
|
||||||
processes?: WatchProcess[]
|
processes?: WatchProcess[]
|
||||||
@@ -78,11 +85,14 @@ function tickCountdown() {
|
|||||||
let shouldRefresh = false
|
let shouldRefresh = false
|
||||||
for (const slot of slots.value) {
|
for (const slot of slots.value) {
|
||||||
if (slot.empty || slot.monitoring_enabled === false) continue
|
if (slot.empty || slot.monitoring_enabled === false) continue
|
||||||
if (slot.remaining_sec != null && slot.remaining_sec > 0) {
|
if (slot.remaining_sec == null) continue
|
||||||
slot.remaining_sec -= 1
|
if (slot.remaining_sec <= 0) {
|
||||||
if (slot.remaining_sec <= 0) {
|
shouldRefresh = true
|
||||||
shouldRefresh = true
|
continue
|
||||||
}
|
}
|
||||||
|
slot.remaining_sec -= 1
|
||||||
|
if (slot.remaining_sec <= 0) {
|
||||||
|
shouldRefresh = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (shouldRefresh) {
|
if (shouldRefresh) {
|
||||||
@@ -196,12 +206,10 @@ async function setSlotMonitoring(slotIndex: number, monitoringEnabled: boolean)
|
|||||||
applySlots(data)
|
applySlots(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WatchTtlHours = 8 | 24
|
async function setSlotTtl(slotIndex: number, ttlMinutes: WatchTtlMinutes) {
|
||||||
|
|
||||||
async function setSlotTtl(slotIndex: number, ttlHours: WatchTtlHours) {
|
|
||||||
const data = await http.patch<{ slots: WatchSlot[] }>(
|
const data = await http.patch<{ slots: WatchSlot[] }>(
|
||||||
`/watch/pins/${slotIndex}/ttl`,
|
`/watch/pins/${slotIndex}/ttl`,
|
||||||
{ ttl_hours: ttlHours },
|
{ ttl_minutes: ttlMinutes },
|
||||||
)
|
)
|
||||||
applySlots(data)
|
applySlots(data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }">
|
<template #item.target="{ item }">
|
||||||
<span class="text-body-2">
|
<span class="text-body-2">
|
||||||
{{ auditTargetLabel(item.resource_type) }}{{ item.resource_id ? ` #${item.resource_id}` : '' }}
|
{{ auditTargetText(item) }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ import { usePageActivateRefresh } from '@/composables/usePageActivateRefresh'
|
|||||||
import { http } from '@/api'
|
import { http } from '@/api'
|
||||||
import { useSnackbar } from '@/composables/useSnackbar'
|
import { useSnackbar } from '@/composables/useSnackbar'
|
||||||
import type { PaginatedResponse, AuditItem } from '@/types/api'
|
import type { PaginatedResponse, AuditItem } from '@/types/api'
|
||||||
import { auditActionLabel, auditTargetLabel, auditActionFilterOptions } from '@/utils/auditLabels'
|
import { auditActionLabel, auditTargetLabel, auditTargetText, auditActionFilterOptions } from '@/utils/auditLabels'
|
||||||
import {
|
import {
|
||||||
formatAuditBatchDetailText,
|
formatAuditBatchDetailText,
|
||||||
isBatchJobAuditAction,
|
isBatchJobAuditAction,
|
||||||
|
|||||||
@@ -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 { normalizeServerIds } from '@/utils/serverSelection'
|
||||||
import { formatApiError } from '@/utils/apiError'
|
import { formatApiError } from '@/utils/apiError'
|
||||||
import { guessSiteUrlFromDomain } from '@/utils/browserUrl'
|
import { guessSiteUrlFromDomain } from '@/utils/browserUrl'
|
||||||
|
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
|
||||||
import { registerServerBatchJob, onScriptExecutionComplete } from '@/composables/useScriptExecutionQueue'
|
import { registerServerBatchJob, onScriptExecutionComplete } from '@/composables/useScriptExecutionQueue'
|
||||||
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
|
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
|
||||||
import StatCardsRow from '@/components/StatCardsRow.vue'
|
import StatCardsRow from '@/components/StatCardsRow.vue'
|
||||||
@@ -875,6 +876,7 @@ async function confirmReplaceSlot(slotIndex: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const globalBrowser = useGlobalBrowser()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const showCredentialsDialog = ref(false)
|
const showCredentialsDialog = ref(false)
|
||||||
|
|
||||||
@@ -1545,7 +1547,7 @@ function openBrowser(item: ServerApiItem) {
|
|||||||
snackbar('该服务器无有效域名', 'warning')
|
snackbar('该服务器无有效域名', 'warning')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
window.open(url, '_blank', 'noopener,noreferrer')
|
globalBrowser.openUrl(url, { title: item.name || url })
|
||||||
}
|
}
|
||||||
function openFiles(item: ServerApiItem) {
|
function openFiles(item: ServerApiItem) {
|
||||||
router.push({ path: '/files', query: { server_id: String(item.id) } })
|
router.push({ path: '/files', query: { server_id: String(item.id) } })
|
||||||
|
|||||||
@@ -134,7 +134,7 @@
|
|||||||
<v-chip v-if="telegramTokenSet && !telegramTokenDraft" size="small" color="success" variant="tonal" class="mb-3">Token 已配置</v-chip>
|
<v-chip v-if="telegramTokenSet && !telegramTokenDraft" size="small" color="success" variant="tonal" class="mb-3">Token 已配置</v-chip>
|
||||||
<v-text-field v-model="settings.telegram_chat_id" label="Chat ID" variant="outlined" density="compact" class="mb-3" />
|
<v-text-field v-model="settings.telegram_chat_id" label="Chat ID" variant="outlined" density="compact" class="mb-3" />
|
||||||
<p class="text-caption text-medium-emphasis mb-3">
|
<p class="text-caption text-medium-emphasis mb-3">
|
||||||
在 Telegram 向 Bot 发送任意消息后,可点击下方按钮自动检测 Chat ID。
|
私聊:向 Bot 发送任意消息。群聊:将 Bot 拉入群后发送 <code>/start@你的Bot用户名</code>(或 @提及 Bot),再点击下方检测。
|
||||||
</p>
|
</p>
|
||||||
<div class="d-flex flex-wrap ga-2 mb-4">
|
<div class="d-flex flex-wrap ga-2 mb-4">
|
||||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-radar" @click="detectTelegramChats" :loading="detectingTgChats">检测 Chat ID</v-btn>
|
<v-btn size="small" variant="tonal" prepend-icon="mdi-radar" @click="detectTelegramChats" :loading="detectingTgChats">检测 Chat ID</v-btn>
|
||||||
@@ -165,6 +165,72 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<v-divider class="my-3" />
|
||||||
|
<div class="text-subtitle-2 mb-1">子机离线告警专用 Bot(可选)</div>
|
||||||
|
<p class="text-caption text-medium-emphasis mb-3">
|
||||||
|
与上方默认 Bot 分离:仅「子机离线告警」走此通道。Bot Token 与 Chat ID 均填写后生效;留空则离线告警仍走默认 Bot。
|
||||||
|
</p>
|
||||||
|
<v-text-field
|
||||||
|
v-model="telegramOfflineTokenDraft"
|
||||||
|
label="离线告警 Bot Token"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
class="mb-3"
|
||||||
|
:placeholder="telegramOfflineTokenSet ? '已配置(留空保持不变)' : '输入专用 Bot Token'"
|
||||||
|
:type="showOfflineToken ? 'text' : 'password'"
|
||||||
|
:append-inner-icon="showOfflineToken ? 'mdi-eye-off' : 'mdi-eye'"
|
||||||
|
@click:append-inner="toggleOfflineTelegramTokenVisibility"
|
||||||
|
/>
|
||||||
|
<v-chip
|
||||||
|
v-if="telegramOfflineTokenSet && !telegramOfflineTokenDraft"
|
||||||
|
size="small"
|
||||||
|
color="success"
|
||||||
|
variant="tonal"
|
||||||
|
class="mb-3"
|
||||||
|
>
|
||||||
|
离线 Token 已配置
|
||||||
|
</v-chip>
|
||||||
|
<v-text-field
|
||||||
|
v-model="settings.telegram_offline_chat_id"
|
||||||
|
label="离线告警 Chat ID"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
class="mb-3"
|
||||||
|
/>
|
||||||
|
<p class="text-caption text-medium-emphasis mb-3">
|
||||||
|
检测规则与默认 Bot 相同:群聊需拉入专用 Bot 后在群内发送 <code>/start@Bot用户名</code>。
|
||||||
|
</p>
|
||||||
|
<div class="d-flex flex-wrap ga-2 mb-2">
|
||||||
|
<v-btn
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
prepend-icon="mdi-radar"
|
||||||
|
:loading="detectingOfflineTgChats"
|
||||||
|
@click="detectOfflineTelegramChats"
|
||||||
|
>
|
||||||
|
检测离线 Chat ID
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
prepend-icon="mdi-message-text"
|
||||||
|
:loading="testingOfflineTg"
|
||||||
|
@click="testOfflineTelegram"
|
||||||
|
>
|
||||||
|
测试离线通道
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-content-save"
|
||||||
|
:loading="savingOfflineTelegram"
|
||||||
|
@click="saveOfflineTelegramConfig"
|
||||||
|
>
|
||||||
|
保存离线 Bot
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
<v-divider class="my-4" />
|
<v-divider class="my-4" />
|
||||||
<div class="text-subtitle-2 mb-1">Layer 3 宿主机巡检</div>
|
<div class="text-subtitle-2 mb-1">Layer 3 宿主机巡检</div>
|
||||||
<p class="text-caption text-medium-emphasis mb-3">
|
<p class="text-caption text-medium-emphasis mb-3">
|
||||||
@@ -387,14 +453,14 @@
|
|||||||
<v-card-title>选择 Chat ID</v-card-title>
|
<v-card-title>选择 Chat ID</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<div v-if="!tgChats.length" class="text-body-2 text-medium-emphasis">
|
<div v-if="!tgChats.length" class="text-body-2 text-medium-emphasis">
|
||||||
未检测到对话。请先在 Telegram 向 Bot 发送一条消息,然后重新检测。
|
未检测到对话。私聊请向 Bot 发消息;群聊请将 Bot 拉入群后在群内发送 <code>/start@Bot用户名</code>,然后重新检测。
|
||||||
</div>
|
</div>
|
||||||
<v-list v-else density="compact" lines="two">
|
<v-list v-else density="compact" lines="two">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="chat in tgChats"
|
v-for="chat in tgChats"
|
||||||
:key="chat.id"
|
:key="chat.id"
|
||||||
:title="chat.title || chat.username || `Chat ${chat.id}`"
|
:title="chat.title || chat.username || `Chat ${chat.id}`"
|
||||||
:subtitle="`${chat.type}${chat.username ? ' · @' + chat.username : ''} · ID ${chat.id}`"
|
:subtitle="`${formatTelegramChatType(chat.type)}${chat.username ? ' · @' + chat.username : ''} · ID ${chat.id}`"
|
||||||
@click="applyTelegramChat(chat)"
|
@click="applyTelegramChat(chat)"
|
||||||
/>
|
/>
|
||||||
</v-list>
|
</v-list>
|
||||||
@@ -475,9 +541,17 @@ const settings = ref({
|
|||||||
cpu_alert_threshold: 80, mem_alert_threshold: 80, disk_alert_threshold: 80,
|
cpu_alert_threshold: 80, mem_alert_threshold: 80, disk_alert_threshold: 80,
|
||||||
db_pool_size: 10, db_max_overflow: 20,
|
db_pool_size: 10, db_max_overflow: 20,
|
||||||
telegram_bot_token: '', telegram_chat_id: '',
|
telegram_bot_token: '', telegram_chat_id: '',
|
||||||
|
telegram_offline_chat_id: '',
|
||||||
})
|
})
|
||||||
const telegramTokenDraft = ref('')
|
const telegramTokenDraft = ref('')
|
||||||
const telegramTokenSet = ref(false)
|
const telegramTokenSet = ref(false)
|
||||||
|
const telegramOfflineTokenDraft = ref('')
|
||||||
|
const telegramOfflineTokenSet = ref(false)
|
||||||
|
const showOfflineToken = ref(false)
|
||||||
|
const savingOfflineTelegram = ref(false)
|
||||||
|
const testingOfflineTg = ref(false)
|
||||||
|
const detectingOfflineTgChats = ref(false)
|
||||||
|
const tgChatTarget = ref<'default' | 'offline'>('default')
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const systemSettingsFormRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null)
|
const systemSettingsFormRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null)
|
||||||
const testingTg = ref(false)
|
const testingTg = ref(false)
|
||||||
@@ -501,6 +575,16 @@ const notifyToggleGroups = computed(() => {
|
|||||||
return groups
|
return groups
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function formatTelegramChatType(type: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
private: '私聊',
|
||||||
|
group: '群组',
|
||||||
|
supergroup: '群组',
|
||||||
|
channel: '频道',
|
||||||
|
}
|
||||||
|
return labels[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
function parseNotifyEnabled(val: unknown): boolean {
|
function parseNotifyEnabled(val: unknown): boolean {
|
||||||
const s = String(val ?? 'true').trim().toLowerCase()
|
const s = String(val ?? 'true').trim().toLowerCase()
|
||||||
return !['false', '0', 'no', 'off'].includes(s)
|
return !['false', '0', 'no', 'off'].includes(s)
|
||||||
@@ -554,6 +638,11 @@ async function loadSettings(silent = false) {
|
|||||||
telegramTokenDraft.value = ''
|
telegramTokenDraft.value = ''
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (key === 'telegram_offline_bot_token') {
|
||||||
|
telegramOfflineTokenSet.value = Boolean(item.value_set)
|
||||||
|
telegramOfflineTokenDraft.value = ''
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (key === 'script_exec_complete_sound') {
|
if (key === 'script_exec_complete_sound') {
|
||||||
scriptExecCompleteSound.value = normalizeScriptCompleteSound(item.value)
|
scriptExecCompleteSound.value = normalizeScriptCompleteSound(item.value)
|
||||||
continue
|
continue
|
||||||
@@ -697,6 +786,109 @@ async function saveTelegramConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function persistOfflineTelegramConfig(options?: { requireToken?: boolean; requireChatId?: boolean }): Promise<boolean> {
|
||||||
|
const tokenUpdate = telegramOfflineTokenDraft.value.trim()
|
||||||
|
const chatId = String(settings.value.telegram_offline_chat_id || '').trim()
|
||||||
|
|
||||||
|
if (options?.requireToken && !tokenUpdate && !telegramOfflineTokenSet.value) {
|
||||||
|
snackbar('请先填写离线告警 Bot Token', 'error')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (options?.requireChatId && !chatId) {
|
||||||
|
snackbar('请先填写或检测离线告警 Chat ID', 'error')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenUpdate) {
|
||||||
|
await http.put('/settings/telegram_offline_bot_token', { value: tokenUpdate })
|
||||||
|
telegramOfflineTokenSet.value = true
|
||||||
|
telegramOfflineTokenDraft.value = ''
|
||||||
|
showOfflineToken.value = false
|
||||||
|
}
|
||||||
|
if (chatId) {
|
||||||
|
await http.put('/settings/telegram_offline_chat_id', { value: chatId })
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveOfflineTelegramConfig() {
|
||||||
|
savingOfflineTelegram.value = true
|
||||||
|
try {
|
||||||
|
const tokenUpdate = telegramOfflineTokenDraft.value.trim()
|
||||||
|
const chatId = String(settings.value.telegram_offline_chat_id || '').trim()
|
||||||
|
if (!tokenUpdate && !chatId && !telegramOfflineTokenSet.value) {
|
||||||
|
snackbar('请先填写离线 Bot Token 或 Chat ID', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await persistOfflineTelegramConfig()
|
||||||
|
snackbar('离线告警 Bot 已保存')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
snackbar(e instanceof Error ? e.message : '保存失败', 'error')
|
||||||
|
} finally {
|
||||||
|
savingOfflineTelegram.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testOfflineTelegram() {
|
||||||
|
testingOfflineTg.value = true
|
||||||
|
try {
|
||||||
|
if (!await persistOfflineTelegramConfig({ requireToken: true, requireChatId: true })) return
|
||||||
|
await http.post('/settings/telegram/offline/test')
|
||||||
|
snackbar('离线通道测试消息已发送')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
snackbar(e instanceof Error ? e.message : '发送失败', 'error')
|
||||||
|
} finally {
|
||||||
|
testingOfflineTg.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectOfflineTelegramChats() {
|
||||||
|
detectingOfflineTgChats.value = true
|
||||||
|
try {
|
||||||
|
if (!await persistOfflineTelegramConfig({ requireToken: true })) return
|
||||||
|
const res = await http.get<TelegramChatsResponse>('/settings/telegram/offline/chats')
|
||||||
|
tgChats.value = res.chats || []
|
||||||
|
if (!tgChats.value.length) {
|
||||||
|
snackbar('未检测到对话:私聊发消息,或群聊拉入 Bot 后发送 /start@Bot用户名', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tgChatTarget.value = 'offline'
|
||||||
|
tgChatDialog.value = true
|
||||||
|
} catch (e: unknown) {
|
||||||
|
snackbar(e instanceof Error ? e.message : '检测失败', 'error')
|
||||||
|
} finally {
|
||||||
|
detectingOfflineTgChats.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleOfflineTelegramTokenVisibility() {
|
||||||
|
if (showOfflineToken.value) {
|
||||||
|
showOfflineToken.value = false
|
||||||
|
if (telegramOfflineTokenSet.value) {
|
||||||
|
telegramOfflineTokenDraft.value = ''
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (telegramOfflineTokenDraft.value.trim()) {
|
||||||
|
showOfflineToken.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!telegramOfflineTokenSet.value) {
|
||||||
|
showOfflineToken.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
revealingTelegramToken.value = true
|
||||||
|
try {
|
||||||
|
const res = await http.post<{ value: string }>('/settings/telegram/offline/reveal-token', {})
|
||||||
|
telegramOfflineTokenDraft.value = res.value
|
||||||
|
showOfflineToken.value = true
|
||||||
|
} catch (e: unknown) {
|
||||||
|
snackbar(e instanceof Error ? e.message : '获取离线 Bot Token 失败', 'error')
|
||||||
|
} finally {
|
||||||
|
revealingTelegramToken.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onScriptExecSoundChange(value: ScriptCompleteSoundId | null) {
|
async function onScriptExecSoundChange(value: ScriptCompleteSoundId | null) {
|
||||||
if (!value || scriptSoundSaving.value) return
|
if (!value || scriptSoundSaving.value) return
|
||||||
const prev = scriptExecCompleteSound.value
|
const prev = scriptExecCompleteSound.value
|
||||||
@@ -769,9 +961,10 @@ async function detectTelegramChats() {
|
|||||||
const res = await http.get<TelegramChatsResponse>('/settings/telegram/chats')
|
const res = await http.get<TelegramChatsResponse>('/settings/telegram/chats')
|
||||||
tgChats.value = res.chats || []
|
tgChats.value = res.chats || []
|
||||||
if (!tgChats.value.length) {
|
if (!tgChats.value.length) {
|
||||||
snackbar('未检测到对话,请先向 Bot 发送一条消息后再试', 'warning')
|
snackbar('未检测到对话:私聊发消息,或群聊拉入 Bot 后发送 /start@Bot用户名', 'warning')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
tgChatTarget.value = 'default'
|
||||||
tgChatDialog.value = true
|
tgChatDialog.value = true
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
snackbar(e instanceof Error ? e.message : '检测失败', 'error')
|
snackbar(e instanceof Error ? e.message : '检测失败', 'error')
|
||||||
@@ -782,10 +975,16 @@ async function detectTelegramChats() {
|
|||||||
|
|
||||||
async function applyTelegramChat(chat: TelegramChatItem) {
|
async function applyTelegramChat(chat: TelegramChatItem) {
|
||||||
const chatId = String(chat.id)
|
const chatId = String(chat.id)
|
||||||
settings.value.telegram_chat_id = chatId
|
const isOffline = tgChatTarget.value === 'offline'
|
||||||
|
if (isOffline) {
|
||||||
|
settings.value.telegram_offline_chat_id = chatId
|
||||||
|
} else {
|
||||||
|
settings.value.telegram_chat_id = chatId
|
||||||
|
}
|
||||||
tgChatDialog.value = false
|
tgChatDialog.value = false
|
||||||
try {
|
try {
|
||||||
await http.put('/settings/telegram_chat_id', { value: chatId })
|
const key = isOffline ? 'telegram_offline_chat_id' : 'telegram_chat_id'
|
||||||
|
await http.put(`/settings/${key}`, { value: chatId })
|
||||||
snackbar(`已选择 Chat ID: ${chatId}`)
|
snackbar(`已选择 Chat ID: ${chatId}`)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
snackbar(e instanceof Error ? e.message : '保存 Chat ID 失败', 'error')
|
snackbar(e instanceof Error ? e.message : '保存 Chat ID 失败', 'error')
|
||||||
|
|||||||
@@ -101,7 +101,11 @@
|
|||||||
<TerminalServerPicker
|
<TerminalServerPicker
|
||||||
mode="sidebar"
|
mode="sidebar"
|
||||||
:servers="filteredServers"
|
:servers="filteredServers"
|
||||||
|
:search="serverSearch"
|
||||||
|
:search-history="terminalSearchHistory"
|
||||||
:loading="serversLoading"
|
:loading="serversLoading"
|
||||||
|
@update:search="onServerSearchUpdate"
|
||||||
|
@search-commit="commitServerSearch"
|
||||||
@select="(id) => newSession(id)"
|
@select="(id) => newSession(id)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import { useWatchPins } from '@/composables/useWatchPins'
|
|||||||
|
|
||||||
defineOptions({ name: 'WatchMetricsPage' })
|
defineOptions({ name: 'WatchMetricsPage' })
|
||||||
|
|
||||||
|
const WATCH_SLOT_COUNT = 4
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const tab = ref('trend')
|
const tab = ref('trend')
|
||||||
const hours = ref(24)
|
const hours = ref(24)
|
||||||
const serverId = ref<number | null>(null)
|
|
||||||
const sessionId = ref<number | null>(null)
|
|
||||||
const points = ref<WatchTrendPoint[]>([])
|
const points = ref<WatchTrendPoint[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const selectedSeries = ref<Array<'cpu_pct' | 'mem_pct' | 'disk_pct' | 'net_up_bps' | 'net_down_bps'>>([
|
const selectedSeries = ref<Array<'cpu_pct' | 'mem_pct' | 'disk_pct' | 'net_up_bps' | 'net_down_bps'>>([
|
||||||
@@ -21,21 +21,47 @@ const selectedSeries = ref<Array<'cpu_pct' | 'mem_pct' | 'disk_pct' | 'net_up_bp
|
|||||||
|
|
||||||
const { slots, refreshPins } = useWatchPins()
|
const { slots, refreshPins } = useWatchPins()
|
||||||
|
|
||||||
const serverOptions = computed(() =>
|
const slotPanels = computed(() =>
|
||||||
slots.value
|
Array.from({ length: WATCH_SLOT_COUNT }, (_, slotIndex) => {
|
||||||
.filter((s) => !s.empty && s.server_id)
|
const slot = slots.value.find((s) => s.slot_index === slotIndex)
|
||||||
.map((s) => ({ title: s.server_name || `ID ${s.server_id}`, value: s.server_id! })),
|
const empty = !slot || slot.empty
|
||||||
|
return {
|
||||||
|
slotIndex,
|
||||||
|
empty,
|
||||||
|
serverId: empty ? null : (slot.server_id ?? null),
|
||||||
|
serverName: empty ? '' : (slot.server_name || `ID ${slot.server_id}`),
|
||||||
|
}
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const pinnedServerIds = computed(() =>
|
||||||
|
slotPanels.value
|
||||||
|
.map((p) => p.serverId)
|
||||||
|
.filter((id): id is number => id != null),
|
||||||
|
)
|
||||||
|
|
||||||
|
const pointsByServer = computed(() => {
|
||||||
|
const map = new Map<number, WatchTrendPoint[]>()
|
||||||
|
for (const p of points.value) {
|
||||||
|
const sid = p.server_id
|
||||||
|
if (sid == null) continue
|
||||||
|
const list = map.get(sid) ?? []
|
||||||
|
list.push(p)
|
||||||
|
map.set(sid, list)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
async function loadTrend() {
|
async function loadTrend() {
|
||||||
if (!serverId.value) {
|
const ids = pinnedServerIds.value
|
||||||
|
if (!ids.length) {
|
||||||
points.value = []
|
points.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await http.get<{ points: WatchTrendPoint[] }>('/watch/metrics', {
|
const data = await http.get<{ points: WatchTrendPoint[] }>('/watch/metrics', {
|
||||||
server_ids: String(serverId.value),
|
server_ids: ids.join(','),
|
||||||
hours: hours.value,
|
hours: hours.value,
|
||||||
})
|
})
|
||||||
points.value = data.points || []
|
points.value = data.points || []
|
||||||
@@ -46,22 +72,18 @@ async function loadTrend() {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await refreshPins()
|
await refreshPins()
|
||||||
const qSid = route.query.server_id
|
const qTab = route.query.tab
|
||||||
const qSess = route.query.session_id
|
if (qTab === 'records') tab.value = 'records'
|
||||||
if (qSid) serverId.value = Number(qSid)
|
|
||||||
if (qSess) sessionId.value = Number(qSess)
|
|
||||||
if (!serverId.value && serverOptions.value.length) {
|
|
||||||
serverId.value = serverOptions.value[0].value
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch([serverId, hours], loadTrend, { immediate: true })
|
watch([pinnedServerIds, hours], loadTrend, { immediate: true, deep: true })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-container fluid class="pa-4 pa-md-6">
|
<v-container fluid class="pa-4 pa-md-6">
|
||||||
<div class="d-flex align-center mb-4">
|
<div class="d-flex align-center mb-4">
|
||||||
<h1 class="text-h6">监测历史</h1>
|
<h1 class="text-h6">监测历史</h1>
|
||||||
|
<v-chip size="x-small" variant="tonal" class="ml-2" label>4 槽同时展示</v-chip>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn variant="text" prepend-icon="mdi-arrow-left" :to="{ name: 'Servers' }">返回服务器</v-btn>
|
<v-btn variant="text" prepend-icon="mdi-arrow-left" :to="{ name: 'Servers' }">返回服务器</v-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,17 +98,6 @@ watch([serverId, hours], loadTrend, { immediate: true })
|
|||||||
<v-window v-model="tab">
|
<v-window v-model="tab">
|
||||||
<v-window-item value="trend">
|
<v-window-item value="trend">
|
||||||
<div class="d-flex flex-wrap ga-2 mb-4">
|
<div class="d-flex flex-wrap ga-2 mb-4">
|
||||||
<v-select
|
|
||||||
v-model="serverId"
|
|
||||||
:items="serverOptions"
|
|
||||||
item-title="title"
|
|
||||||
item-value="value"
|
|
||||||
label="服务器"
|
|
||||||
density="compact"
|
|
||||||
hide-details
|
|
||||||
variant="outlined"
|
|
||||||
style="min-width: 200px"
|
|
||||||
/>
|
|
||||||
<v-btn-toggle v-model="hours" mandatory density="compact" variant="outlined" divided>
|
<v-btn-toggle v-model="hours" mandatory density="compact" variant="outlined" divided>
|
||||||
<v-btn :value="1" size="small">1h</v-btn>
|
<v-btn :value="1" size="small">1h</v-btn>
|
||||||
<v-btn :value="6" size="small">6h</v-btn>
|
<v-btn :value="6" size="small">6h</v-btn>
|
||||||
@@ -104,7 +115,7 @@ watch([serverId, hours], loadTrend, { immediate: true })
|
|||||||
]"
|
]"
|
||||||
item-title="title"
|
item-title="title"
|
||||||
item-value="value"
|
item-value="value"
|
||||||
label="曲线"
|
label="曲线(各槽共用)"
|
||||||
multiple
|
multiple
|
||||||
chips
|
chips
|
||||||
density="compact"
|
density="compact"
|
||||||
@@ -114,20 +125,62 @@ watch([serverId, hours], loadTrend, { immediate: true })
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<v-progress-linear v-if="loading" indeterminate class="mb-2" />
|
<v-progress-linear v-if="loading" indeterminate class="mb-2" />
|
||||||
<WatchTrendChart v-if="points.length" :points="points" :series="selectedSeries" />
|
<v-row dense>
|
||||||
<div v-else class="text-center text-medium-emphasis py-8">
|
<v-col
|
||||||
暂无趋势数据(需先 Pin 监测并等待探针采样)
|
v-for="panel in slotPanels"
|
||||||
</div>
|
:key="panel.slotIndex"
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-card variant="outlined" rounded="lg" class="watch-metrics-slot pa-3">
|
||||||
|
<div class="d-flex align-center mb-2">
|
||||||
|
<v-chip size="x-small" variant="tonal" color="info" label>
|
||||||
|
槽 {{ panel.slotIndex + 1 }}
|
||||||
|
</v-chip>
|
||||||
|
<span class="text-subtitle-2 text-truncate ml-2" :title="panel.serverName">
|
||||||
|
{{ panel.empty ? '未占用' : panel.serverName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<WatchTrendChart
|
||||||
|
v-if="!panel.empty && panel.serverId != null && (pointsByServer.get(panel.serverId)?.length ?? 0) > 0"
|
||||||
|
:points="pointsByServer.get(panel.serverId!) ?? []"
|
||||||
|
:series="selectedSeries"
|
||||||
|
:height="280"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else-if="panel.empty"
|
||||||
|
class="text-center text-medium-emphasis watch-metrics-slot__empty"
|
||||||
|
>
|
||||||
|
未添加监测
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="text-center text-medium-emphasis watch-metrics-slot__empty"
|
||||||
|
>
|
||||||
|
暂无趋势数据(需开启监测并等待探针采样)
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
</v-window-item>
|
</v-window-item>
|
||||||
<v-window-item value="records">
|
<v-window-item value="records">
|
||||||
<WatchProbeRecordsTable
|
<WatchProbeRecordsTable :hours="hours" />
|
||||||
:server-id="serverId"
|
|
||||||
:session-id="sessionId"
|
|
||||||
:hours="hours"
|
|
||||||
/>
|
|
||||||
</v-window-item>
|
</v-window-item>
|
||||||
</v-window>
|
</v-window>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.watch-metrics-slot {
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
.watch-metrics-slot__empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 280px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const routes = [
|
|||||||
{ path: '/servers', name: 'Servers', component: () => import('@/pages/ServersPage.vue') },
|
{ path: '/servers', name: 'Servers', component: () => import('@/pages/ServersPage.vue') },
|
||||||
{ path: '/watch-metrics', name: 'WatchMetrics', component: () => import('@/pages/WatchMetricsPage.vue') },
|
{ path: '/watch-metrics', name: 'WatchMetrics', component: () => import('@/pages/WatchMetricsPage.vue') },
|
||||||
{ path: '/terminal', name: 'Terminal', component: () => import('@/pages/TerminalPage.vue') },
|
{ path: '/terminal', name: 'Terminal', component: () => import('@/pages/TerminalPage.vue') },
|
||||||
|
{ path: '/browser', name: 'Browser', component: () => import('@/pages/BrowserPage.vue') },
|
||||||
{ path: '/files', name: 'Files', component: () => import('@/pages/FilesPage.vue') },
|
{ path: '/files', name: 'Files', component: () => import('@/pages/FilesPage.vue') },
|
||||||
{ path: '/push', name: 'Push', component: () => import('@/pages/PushPage.vue') },
|
{ path: '/push', name: 'Push', component: () => import('@/pages/PushPage.vue') },
|
||||||
{ path: '/scripts', name: 'Scripts', component: () => import('@/pages/ScriptsPage.vue') },
|
{ path: '/scripts', name: 'Scripts', component: () => import('@/pages/ScriptsPage.vue') },
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export const NOTIFY_TOGGLE_ITEMS: NotifyToggleItem[] = [
|
|||||||
{ key: 'notify_alert_mem', label: '内存超阈值告警', desc: '内存超阈值或恢复时推送;关闭则该类告警与恢复均不通知', group: '服务器资源' },
|
{ key: 'notify_alert_mem', label: '内存超阈值告警', desc: '内存超阈值或恢复时推送;关闭则该类告警与恢复均不通知', group: '服务器资源' },
|
||||||
{ key: 'notify_alert_disk', label: '磁盘超阈值告警', desc: '磁盘超阈值或恢复时推送;关闭则该类告警与恢复均不通知', group: '服务器资源' },
|
{ key: 'notify_alert_disk', label: '磁盘超阈值告警', desc: '磁盘超阈值或恢复时推送;关闭则该类告警与恢复均不通知', group: '服务器资源' },
|
||||||
{ key: 'notify_recovery', label: '资源恢复正常', desc: 'CPU/内存/磁盘回落正常时推送(须总开关开启且对应资源开关未关)', group: '服务器资源' },
|
{ key: 'notify_recovery', label: '资源恢复正常', desc: 'CPU/内存/磁盘回落正常时推送(须总开关开启且对应资源开关未关)', group: '服务器资源' },
|
||||||
{ key: 'notify_alert_offline', label: '子机离线告警', desc: 'Agent 心跳丢失、子机由在线变为离线时推送一次', group: '服务器' },
|
{ key: 'notify_alert_offline', label: '子机离线告警', desc: 'Agent 心跳丢失、子机由在线变为离线时推送一次;可在下方配置专用 Telegram Bot', group: '服务器' },
|
||||||
{ key: 'notify_time_drift', label: '时钟偏差严重 (≥60s)', desc: '子机与主机时间偏差过大影响 TOTP/cron', group: '服务器' },
|
{ key: 'notify_time_drift', label: '时钟偏差严重 (≥60s)', desc: '子机与主机时间偏差过大影响 TOTP/cron', group: '服务器' },
|
||||||
{ key: 'notify_system_redis', label: 'Redis 连接异常 / 恢复', desc: 'Redis 不可达或恢复时推送', group: '系统' },
|
{ key: 'notify_system_redis', label: 'Redis 连接异常 / 恢复', desc: 'Redis 不可达或恢复时推送', group: '系统' },
|
||||||
{ key: 'notify_system_mysql', label: 'MySQL 连接异常 / 恢复', desc: 'MySQL 不可达或恢复时推送', group: '系统' },
|
{ key: 'notify_system_mysql', label: 'MySQL 连接异常 / 恢复', desc: 'MySQL 不可达或恢复时推送', group: '系统' },
|
||||||
@@ -419,6 +419,8 @@ export interface AuditItem {
|
|||||||
detail: string | null
|
detail: string | null
|
||||||
ip_address: string | null
|
ip_address: string | null
|
||||||
created_at: string | null
|
created_at: string | null
|
||||||
|
/** 目标为 server 时由 API 解析的服务器名称 */
|
||||||
|
target_name?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** DB credential from /api/scripts/credentials (password never returned) */
|
/** DB credential from /api/scripts/credentials (password never returned) */
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ const ACTION_LABELS: Record<string, string> = {
|
|||||||
delete_ssh_key_preset: '删除 SSH 密钥预设',
|
delete_ssh_key_preset: '删除 SSH 密钥预设',
|
||||||
reveal_ssh_key_preset: '查看 SSH 密钥预设',
|
reveal_ssh_key_preset: '查看 SSH 密钥预设',
|
||||||
reveal_telegram_token: '查看 Telegram Token',
|
reveal_telegram_token: '查看 Telegram Token',
|
||||||
|
reveal_offline_telegram_token: '查看离线告警 Telegram Token',
|
||||||
parse_subscription: '解析订阅节点',
|
parse_subscription: '解析订阅节点',
|
||||||
update_ip_allowlist: '更新登录 IP 白名单',
|
update_ip_allowlist: '更新登录 IP 白名单',
|
||||||
toggle_ip_allowlist: '开关登录 IP 白名单',
|
toggle_ip_allowlist: '开关登录 IP 白名单',
|
||||||
@@ -267,6 +268,27 @@ export function auditTargetLabel(type: string): string {
|
|||||||
return fallbackActionLabel(key)
|
return fallbackActionLabel(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 审计列表「目标」列:服务器显示名称,其它类型保留 #id。 */
|
||||||
|
export function auditTargetText(item: {
|
||||||
|
resource_type?: string
|
||||||
|
target_type?: string
|
||||||
|
resource_id?: number | null
|
||||||
|
target_id?: number | null
|
||||||
|
target_name?: string | null
|
||||||
|
}): string {
|
||||||
|
const type = (item.resource_type || item.target_type || '').trim()
|
||||||
|
const id = item.resource_id ?? item.target_id ?? null
|
||||||
|
const label = auditTargetLabel(type)
|
||||||
|
if (type === 'server') {
|
||||||
|
const name = (item.target_name || '').trim()
|
||||||
|
if (name) return `${label} ${name}`
|
||||||
|
if (id != null && id > 0) return `${label} #${id}`
|
||||||
|
return label || '—'
|
||||||
|
}
|
||||||
|
if (id != null && id > 0) return `${label} #${id}`
|
||||||
|
return label || '—'
|
||||||
|
}
|
||||||
|
|
||||||
/** 审计页「操作类型」筛选(中文标签 + 英文 value) */
|
/** 审计页「操作类型」筛选(中文标签 + 英文 value) */
|
||||||
export const auditActionFilterOptions: { label: string; value: string }[] = [
|
export const auditActionFilterOptions: { label: string; value: string }[] = [
|
||||||
{ label: '登录成功', value: 'login_success' },
|
{ label: '登录成功', value: 'login_success' },
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export function normalizeAuditItem(raw: Record<string, unknown>): AuditItem {
|
|||||||
detail: raw.detail != null ? String(raw.detail) : null,
|
detail: raw.detail != null ? String(raw.detail) : null,
|
||||||
ip_address: raw.ip_address != null ? String(raw.ip_address) : null,
|
ip_address: raw.ip_address != null ? String(raw.ip_address) : null,
|
||||||
created_at: raw.created_at != null ? String(raw.created_at) : null,
|
created_at: raw.created_at != null ? String(raw.created_at) : null,
|
||||||
|
target_name: raw.target_name != null ? String(raw.target_name) : null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
/** Format watch probe byte rates for display. */
|
/** Format watch probe byte rates for display. */
|
||||||
|
|
||||||
|
export interface WatchMetricsLike {
|
||||||
|
probe_status?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 监测已开启但尚未收到任何探针回传(含失败态) */
|
||||||
|
export function isWatchMetricsPending(metrics: WatchMetricsLike | null | undefined): boolean {
|
||||||
|
const status = metrics?.probe_status
|
||||||
|
return status == null || status === ''
|
||||||
|
}
|
||||||
|
|
||||||
export function formatBytesPerSec(bps: number | null | undefined): string {
|
export function formatBytesPerSec(bps: number | null | undefined): string {
|
||||||
if (bps == null || bps < 0) return '—'
|
if (bps == null || bps < 0) return '—'
|
||||||
if (bps < 1024) return `${bps} B/s`
|
if (bps < 1024) return `${bps} B/s`
|
||||||
@@ -7,6 +17,19 @@ export function formatBytesPerSec(bps: number | null | undefined): string {
|
|||||||
return `${(bps / 1024 / 1024).toFixed(1)} MB/s`
|
return `${(bps / 1024 / 1024).toFixed(1)} MB/s`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Linux 1 分钟平均负载(宝塔「负载」同源) */
|
||||||
|
export function formatLoadAvg(load: number | null | undefined): string {
|
||||||
|
if (load == null || Number.isNaN(load)) return '—'
|
||||||
|
return load.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadAvgColor(load: number | null | undefined): string {
|
||||||
|
if (load == null || Number.isNaN(load)) return 'grey'
|
||||||
|
if (load >= 2) return 'error'
|
||||||
|
if (load >= 1) return 'warning'
|
||||||
|
return 'success'
|
||||||
|
}
|
||||||
|
|
||||||
export function formatRemaining(sec: number | null | undefined): string {
|
export function formatRemaining(sec: number | null | undefined): string {
|
||||||
if (sec == null || sec <= 0) return '已到期'
|
if (sec == null || sec <= 0) return '已到期'
|
||||||
const h = Math.floor(sec / 3600)
|
const h = Math.floor(sec / 3600)
|
||||||
|
|||||||
@@ -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"}
|
IMMUTABLE_KEYS = {"secret_key", "api_key", "encryption_key", "database_url"}
|
||||||
|
|
||||||
# Settings whose values are masked in GET responses
|
# Settings whose values are masked in GET responses
|
||||||
SENSITIVE_KEYS = {"secret_key", "api_key", "encryption_key", "telegram_bot_token"}
|
SENSITIVE_KEYS = {
|
||||||
|
"secret_key",
|
||||||
|
"api_key",
|
||||||
|
"encryption_key",
|
||||||
|
"telegram_bot_token",
|
||||||
|
"telegram_offline_bot_token",
|
||||||
|
}
|
||||||
|
|
||||||
# S-02: Whitelist of keys accepted by PUT /{key} (superset of DB_OVERRIDE_MAP + notify toggles)
|
# S-02: Whitelist of keys accepted by PUT /{key} (superset of DB_OVERRIDE_MAP + notify toggles)
|
||||||
MUTABLE_KEYS: set[str] = {
|
MUTABLE_KEYS: set[str] = {
|
||||||
@@ -45,6 +51,7 @@ MUTABLE_KEYS: set[str] = {
|
|||||||
"redis_url", "heartbeat_timeout", "api_base_url",
|
"redis_url", "heartbeat_timeout", "api_base_url",
|
||||||
"cpu_alert_threshold", "mem_alert_threshold", "disk_alert_threshold",
|
"cpu_alert_threshold", "mem_alert_threshold", "disk_alert_threshold",
|
||||||
"telegram_bot_token", "telegram_chat_id",
|
"telegram_bot_token", "telegram_chat_id",
|
||||||
|
"telegram_offline_bot_token", "telegram_offline_chat_id",
|
||||||
"login_allowlist_enabled", "login_subscription_url",
|
"login_allowlist_enabled", "login_subscription_url",
|
||||||
"login_subscription_ips", "login_manual_ips", "login_allowed_ips",
|
"login_subscription_ips", "login_manual_ips", "login_allowed_ips",
|
||||||
"notify_alert_cpu", "notify_alert_mem", "notify_alert_disk",
|
"notify_alert_cpu", "notify_alert_mem", "notify_alert_disk",
|
||||||
@@ -64,6 +71,13 @@ SETTING_ENUM_VALIDATORS: dict[str, frozenset[str]] = {
|
|||||||
"theme": frozenset({"dark", "light"}),
|
"theme": frozenset({"dark", "light"}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# GET /{key} when row not yet in MySQL (PUT upserts on first save)
|
||||||
|
SETTING_DEFAULTS: dict[str, str] = {
|
||||||
|
"script_exec_complete_sound": "beep_double",
|
||||||
|
"push_complete_sound": "beep_double",
|
||||||
|
"theme": "dark",
|
||||||
|
}
|
||||||
|
|
||||||
# S-02: Per-key validation rules: (type, min, max) for int settings
|
# S-02: Per-key validation rules: (type, min, max) for int settings
|
||||||
SETTING_VALIDATORS: dict[str, tuple] = {
|
SETTING_VALIDATORS: dict[str, tuple] = {
|
||||||
"db_pool_size": (int, 1, 1000),
|
"db_pool_size": (int, 1, 1000),
|
||||||
@@ -310,7 +324,10 @@ async def get_setting(key: str, admin: Admin = Depends(get_current_admin), db: A
|
|||||||
repo = SettingRepositoryImpl(db)
|
repo = SettingRepositoryImpl(db)
|
||||||
value = await repo.get(key)
|
value = await repo.get(key)
|
||||||
if value is None:
|
if value is None:
|
||||||
raise HTTPException(status_code=404, detail="Setting not found")
|
default = SETTING_DEFAULTS.get(key)
|
||||||
|
if default is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Setting not found")
|
||||||
|
return _format_setting_response(key, default, None)
|
||||||
return _format_setting_response(key, value, None)
|
return _format_setting_response(key, value, None)
|
||||||
|
|
||||||
|
|
||||||
@@ -1115,6 +1132,7 @@ async def telegram_test_send(
|
|||||||
f"具体情况:Bot Token 与 Chat ID 配置正确,Telegram 通道可用\n"
|
f"具体情况:Bot Token 与 Chat ID 配置正确,Telegram 通道可用\n"
|
||||||
f"将接收的通知类型:\n"
|
f"将接收的通知类型:\n"
|
||||||
f"• 子机 CPU/内存/磁盘超阈值告警与恢复\n"
|
f"• 子机 CPU/内存/磁盘超阈值告警与恢复\n"
|
||||||
|
f"• 子机离线告警(可另配专用 Bot,见设置页)\n"
|
||||||
f"• Redis / MySQL 连接异常与恢复\n"
|
f"• Redis / MySQL 连接异常与恢复\n"
|
||||||
f"• 子机时钟偏差、文件推送结果\n"
|
f"• 子机时钟偏差、文件推送结果\n"
|
||||||
f"• Layer 3 宿主机巡检 /health 失败与自动重启\n"
|
f"• Layer 3 宿主机巡检 /health 失败与自动重启\n"
|
||||||
@@ -1167,30 +1185,67 @@ async def reveal_telegram_token(
|
|||||||
return {"key": "telegram_bot_token", "value": value}
|
return {"key": "telegram_bot_token", "value": value}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/telegram/chats", response_model=dict)
|
def _telegram_chat_entry(chat: dict) -> dict | None:
|
||||||
async def telegram_get_chats(
|
cid = chat.get("id")
|
||||||
admin: Admin = Depends(get_current_admin),
|
if cid is None:
|
||||||
):
|
return None
|
||||||
"""Call getUpdates to detect recent chats and extract unique chat_ids.
|
return {
|
||||||
|
"id": cid,
|
||||||
|
"type": chat.get("type", ""),
|
||||||
|
"title": chat.get("title") or chat.get("first_name") or chat.get("username") or "",
|
||||||
|
"username": chat.get("username") or "",
|
||||||
|
}
|
||||||
|
|
||||||
User must send at least one message to the Bot before calling this.
|
|
||||||
Returns up to 5 distinct chats from recent messages.
|
def _extract_chats_from_telegram_updates(updates: list) -> list[dict]:
|
||||||
"""
|
"""Extract unique chats from getUpdates (DM, group join, channel, callbacks)."""
|
||||||
|
seen: set[int] = set()
|
||||||
|
chats: list[dict] = []
|
||||||
|
|
||||||
|
def add(chat: object) -> None:
|
||||||
|
if not isinstance(chat, dict):
|
||||||
|
return
|
||||||
|
entry = _telegram_chat_entry(chat)
|
||||||
|
if not entry or entry["id"] in seen:
|
||||||
|
return
|
||||||
|
seen.add(entry["id"])
|
||||||
|
chats.append(entry)
|
||||||
|
|
||||||
|
for update in updates:
|
||||||
|
if not isinstance(update, dict):
|
||||||
|
continue
|
||||||
|
for key in ("message", "edited_message", "channel_post", "edited_channel_post"):
|
||||||
|
msg = update.get(key)
|
||||||
|
if isinstance(msg, dict):
|
||||||
|
add(msg.get("chat"))
|
||||||
|
mcm = update.get("my_chat_member")
|
||||||
|
if isinstance(mcm, dict):
|
||||||
|
add(mcm.get("chat"))
|
||||||
|
cm = update.get("chat_member")
|
||||||
|
if isinstance(cm, dict):
|
||||||
|
add(cm.get("chat"))
|
||||||
|
cq = update.get("callback_query")
|
||||||
|
if isinstance(cq, dict):
|
||||||
|
msg = cq.get("message")
|
||||||
|
if isinstance(msg, dict):
|
||||||
|
add(msg.get("chat"))
|
||||||
|
|
||||||
|
type_order = {"supergroup": 0, "group": 1, "channel": 2, "private": 3}
|
||||||
|
chats.sort(key=lambda c: (type_order.get(c.get("type", ""), 9), c.get("id", 0)))
|
||||||
|
return chats
|
||||||
|
|
||||||
|
|
||||||
|
async def _telegram_fetch_chats(token: str) -> dict:
|
||||||
import httpx
|
import httpx
|
||||||
from server.config import settings as _settings
|
|
||||||
from server.infrastructure.telegram import TELEGRAM_API_BASE
|
from server.infrastructure.telegram import TELEGRAM_API_BASE
|
||||||
|
|
||||||
token = _settings.TELEGRAM_BOT_TOKEN
|
|
||||||
if not token:
|
|
||||||
raise HTTPException(status_code=400, detail="TELEGRAM_BOT_TOKEN 未配置")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
base_url = f"{TELEGRAM_API_BASE}/bot{token}"
|
base_url = f"{TELEGRAM_API_BASE}/bot{token}"
|
||||||
await _telegram_clear_webhook_if_set(client, base_url)
|
await _telegram_clear_webhook_if_set(client, base_url)
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
f"{base_url}/getUpdates",
|
f"{base_url}/getUpdates",
|
||||||
params={"limit": 50},
|
params={"limit": 100},
|
||||||
)
|
)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
raise HTTPException(status_code=502, detail=f"Telegram API 返回 {resp.status_code}")
|
raise HTTPException(status_code=502, detail=f"Telegram API 返回 {resp.status_code}")
|
||||||
@@ -1203,27 +1258,125 @@ async def telegram_get_chats(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=502, detail=f"请求失败: {e}") from e
|
raise HTTPException(status_code=502, detail=f"请求失败: {e}") from e
|
||||||
|
|
||||||
# Extract unique chats
|
chats = _extract_chats_from_telegram_updates(data.get("result", []))[:10]
|
||||||
seen: set[int] = set()
|
|
||||||
chats: list[dict] = []
|
|
||||||
for update in data.get("result", []):
|
|
||||||
msg = update.get("message") or update.get("channel_post") or {}
|
|
||||||
chat = msg.get("chat", {})
|
|
||||||
cid = chat.get("id")
|
|
||||||
if cid and cid not in seen:
|
|
||||||
seen.add(cid)
|
|
||||||
chats.append({
|
|
||||||
"id": cid,
|
|
||||||
"type": chat.get("type", ""),
|
|
||||||
"title": chat.get("title") or chat.get("first_name") or "",
|
|
||||||
"username": chat.get("username") or "",
|
|
||||||
})
|
|
||||||
if len(chats) >= 5:
|
|
||||||
break
|
|
||||||
|
|
||||||
return {"chats": chats, "count": len(chats)}
|
return {"chats": chats, "count": len(chats)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/telegram/chats", response_model=dict)
|
||||||
|
async def telegram_get_chats(
|
||||||
|
admin: Admin = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""Call getUpdates to detect recent chats and extract unique chat_ids.
|
||||||
|
|
||||||
|
For private chats: send any message to the Bot.
|
||||||
|
For groups: add the Bot to the group, then send /start@BotName (privacy mode)
|
||||||
|
or any message mentioning the Bot. Bot join events (my_chat_member) are included.
|
||||||
|
Returns up to 10 distinct chats, groups listed first.
|
||||||
|
"""
|
||||||
|
from server.config import settings as _settings
|
||||||
|
|
||||||
|
token = (_settings.TELEGRAM_BOT_TOKEN or "").strip()
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=400, detail="TELEGRAM_BOT_TOKEN 未配置")
|
||||||
|
return await _telegram_fetch_chats(token)
|
||||||
|
|
||||||
|
|
||||||
|
def _offline_telegram_token_or_400() -> str:
|
||||||
|
from server.config import settings as _settings
|
||||||
|
|
||||||
|
token = (_settings.TELEGRAM_OFFLINE_BOT_TOKEN or "").strip()
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=400, detail="telegram_offline_bot_token 未配置")
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _offline_telegram_credentials_or_400() -> tuple[str, str]:
|
||||||
|
"""Resolve offline-dedicated Bot; 400 if not fully configured."""
|
||||||
|
from server.config import settings as _settings
|
||||||
|
|
||||||
|
token = _offline_telegram_token_or_400()
|
||||||
|
chat_id = (_settings.TELEGRAM_OFFLINE_CHAT_ID or "").strip()
|
||||||
|
if not chat_id:
|
||||||
|
raise HTTPException(status_code=400, detail="telegram_offline_chat_id 未配置,请先检测并填写")
|
||||||
|
return token, chat_id
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/telegram/offline/test", response_model=dict)
|
||||||
|
async def telegram_offline_test_send(
|
||||||
|
admin: Admin = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""Send a test message via offline-dedicated Telegram Bot (not the default channel)."""
|
||||||
|
import httpx
|
||||||
|
from server.config import settings as _settings
|
||||||
|
from server.infrastructure.telegram import TELEGRAM_API_BASE
|
||||||
|
from server.utils.display_time import format_beijing_now
|
||||||
|
|
||||||
|
token, chat_id = _offline_telegram_credentials_or_400()
|
||||||
|
now = format_beijing_now()
|
||||||
|
sys_name = html.escape((_settings.SYSTEM_NAME or "Nexus").strip())
|
||||||
|
message = (
|
||||||
|
f"✅ <b>【离线告警 Telegram 测试】消息发送成功</b>\n"
|
||||||
|
f"平台:{sys_name}\n"
|
||||||
|
f"具体情况:离线专用 Bot Token 与 Chat ID 配置正确\n"
|
||||||
|
f"将接收的通知:子机由在线变为离线时的 Telegram 推送(须开启「子机离线告警」开关)\n"
|
||||||
|
f"说明:未配置离线专用 Bot 时,离线告警回退至默认 Telegram 通道\n"
|
||||||
|
f"时间:{now}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{TELEGRAM_API_BASE}/bot{token}/sendMessage",
|
||||||
|
json={
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"text": message,
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
data = resp.json()
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"请求失败: {e}") from e
|
||||||
|
|
||||||
|
if resp.status_code == 200 and data.get("ok"):
|
||||||
|
return {"success": True}
|
||||||
|
desc = data.get("description") or f"HTTP {resp.status_code}"
|
||||||
|
raise HTTPException(status_code=502, detail=f"Telegram API: {desc}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/telegram/offline/reveal-token", response_model=dict)
|
||||||
|
async def reveal_offline_telegram_token(
|
||||||
|
request: Request,
|
||||||
|
admin: Admin = Depends(get_current_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Reveal offline-dedicated Telegram Bot Token (JWT + audit)."""
|
||||||
|
repo = SettingRepositoryImpl(db)
|
||||||
|
value = await repo.get("telegram_offline_bot_token")
|
||||||
|
if value is None:
|
||||||
|
raise HTTPException(status_code=404, detail="telegram_offline_bot_token not found")
|
||||||
|
|
||||||
|
audit_repo = AuditLogRepositoryImpl(db)
|
||||||
|
await audit_repo.create(AuditLog(
|
||||||
|
admin_username=admin.username,
|
||||||
|
action="reveal_offline_telegram_token",
|
||||||
|
target_type="setting",
|
||||||
|
detail="查看离线告警 Telegram Bot Token",
|
||||||
|
ip_address=request.client.host if request.client else "",
|
||||||
|
))
|
||||||
|
return {"key": "telegram_offline_bot_token", "value": value}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/telegram/offline/chats", response_model=dict)
|
||||||
|
async def telegram_offline_get_chats(
|
||||||
|
admin: Admin = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""Detect chats for offline-dedicated Bot via getUpdates (same rules as /telegram/chats)."""
|
||||||
|
token = _offline_telegram_token_or_400()
|
||||||
|
return await _telegram_fetch_chats(token)
|
||||||
|
|
||||||
|
|
||||||
# ── IP Allowlist ──
|
# ── IP Allowlist ──
|
||||||
|
|
||||||
_IP_RE = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(/\d{1,2})?$")
|
_IP_RE = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(/\d{1,2})?$")
|
||||||
@@ -1539,10 +1692,40 @@ async def list_audit_logs(
|
|||||||
"total": total,
|
"total": total,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
"items": [_audit_to_dict(log) for log in logs],
|
"items": await _audit_items_enriched(db, logs),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _audit_items_enriched(db, logs: list[AuditLog]) -> list[dict]:
|
||||||
|
"""Attach target_name for server rows (audit UI shows name instead of #id)."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from server.domain.models import Server
|
||||||
|
|
||||||
|
server_ids = {
|
||||||
|
int(log.target_id)
|
||||||
|
for log in logs
|
||||||
|
if log.target_type == "server" and log.target_id
|
||||||
|
}
|
||||||
|
name_by_id: dict[int, str] = {}
|
||||||
|
if server_ids:
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(Server.id, Server.name).where(Server.id.in_(server_ids))
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
name_by_id = {int(sid): (name or f"server-{sid}") for sid, name in rows}
|
||||||
|
|
||||||
|
items: list[dict] = []
|
||||||
|
for log in logs:
|
||||||
|
row = _audit_to_dict(log)
|
||||||
|
if log.target_type == "server" and log.target_id:
|
||||||
|
tid = int(log.target_id)
|
||||||
|
row["target_name"] = name_by_id.get(tid)
|
||||||
|
items.append(row)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
# ── Retry Jobs ──
|
# ── Retry Jobs ──
|
||||||
|
|
||||||
retry_router = APIRouter(prefix="/api/retries", tags=["retries"])
|
retry_router = APIRouter(prefix="/api/retries", tags=["retries"])
|
||||||
|
|||||||
+23
-27
@@ -1448,12 +1448,10 @@ async def download_file(
|
|||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
conn = await ssh_pool.acquire(server)
|
conn = await ssh_pool.acquire(server)
|
||||||
# NOTE: connection is released INSIDE file_generator (after stream finishes),
|
# Pre-check in a short SFTP session; stream opens its own session so the
|
||||||
# not in a finally block here — releasing before the generator runs would close
|
# context manager is not exited before StreamingResponse consumes the body.
|
||||||
# the SFTP channel mid-download.
|
|
||||||
try:
|
try:
|
||||||
async with sftp_session(conn) as sftp:
|
async with sftp_session(conn) as sftp:
|
||||||
# Check file exists and size
|
|
||||||
try:
|
try:
|
||||||
stat = await sftp.stat(remote_path)
|
stat = await sftp.stat(remote_path)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -1467,42 +1465,40 @@ async def download_file(
|
|||||||
await ssh_pool.release(server.id)
|
await ssh_pool.release(server.id)
|
||||||
raise HTTPException(status_code=400, detail="只能下载文件,不能下载目录")
|
raise HTTPException(status_code=400, detail="只能下载文件,不能下载目录")
|
||||||
|
|
||||||
# H-15: Download file size limit (500MB)
|
|
||||||
MAX_DOWNLOAD_SIZE = 524_288_000
|
MAX_DOWNLOAD_SIZE = 524_288_000
|
||||||
if hasattr(stat, "size") and stat.size and stat.size > MAX_DOWNLOAD_SIZE:
|
if hasattr(stat, "size") and stat.size and stat.size > MAX_DOWNLOAD_SIZE:
|
||||||
await ssh_pool.release(server.id)
|
await ssh_pool.release(server.id)
|
||||||
raise HTTPException(status_code=413, detail=f"文件大小 {stat.size} 字节超过下载限制 100MB")
|
raise HTTPException(status_code=413, detail=f"文件大小 {stat.size} 字节超过下载限制 100MB")
|
||||||
|
|
||||||
# H-02: Sanitize filename for Content-Disposition (RFC 6266)
|
|
||||||
import re as _re
|
import re as _re
|
||||||
filename = _re.sub(r'[^\w.\-]', '_', posix_basename(remote_path)) or "download"
|
filename = _re.sub(r'[^\w.\-]', '_', posix_basename(remote_path)) or "download"
|
||||||
|
|
||||||
async def file_generator():
|
|
||||||
try:
|
|
||||||
async with sftp.open(remote_path, "rb") as f:
|
|
||||||
while chunk := await f.read(65536):
|
|
||||||
yield chunk
|
|
||||||
finally:
|
|
||||||
# Release after stream is fully consumed (or on client disconnect)
|
|
||||||
await ssh_pool.release(server.id)
|
|
||||||
|
|
||||||
await _audit_sync(
|
|
||||||
"file_download", "server", payload.server_id,
|
|
||||||
f"下载文件: {remote_path}",
|
|
||||||
admin.username, request,
|
|
||||||
)
|
|
||||||
|
|
||||||
return StreamingResponse(
|
|
||||||
file_generator(),
|
|
||||||
media_type="application/octet-stream",
|
|
||||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
||||||
)
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await ssh_pool.release(server.id)
|
await ssh_pool.release(server.id)
|
||||||
raise HTTPException(status_code=502, detail=f"下载失败: {e}") from e
|
raise HTTPException(status_code=502, detail=f"下载失败: {e}") from e
|
||||||
|
|
||||||
|
async def file_generator():
|
||||||
|
try:
|
||||||
|
async with sftp_session(conn) as sftp:
|
||||||
|
async with sftp.open(remote_path, "rb") as f:
|
||||||
|
while chunk := await f.read(65536):
|
||||||
|
yield chunk
|
||||||
|
finally:
|
||||||
|
await ssh_pool.release(server.id)
|
||||||
|
|
||||||
|
await _audit_sync(
|
||||||
|
"file_download", "server", payload.server_id,
|
||||||
|
f"下载文件: {remote_path}",
|
||||||
|
admin.username, request,
|
||||||
|
)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
file_generator(),
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/read-file")
|
@router.post("/read-file")
|
||||||
async def read_file(
|
async def read_file(
|
||||||
|
|||||||
+9
-7
@@ -11,7 +11,9 @@ from server.api.auth_jwt import get_current_admin
|
|||||||
from server.api.dependencies import get_db
|
from server.api.dependencies import get_db
|
||||||
from server.application.services.watch_service import SlotsFullError, WatchService
|
from server.application.services.watch_service import SlotsFullError, WatchService
|
||||||
from server.domain.models import Admin
|
from server.domain.models import Admin
|
||||||
from server.utils.watch_metrics import WATCH_PIN_TTL_HOURS
|
from server.utils.watch_metrics import WATCH_PIN_TTL_DEFAULT_MINUTES
|
||||||
|
|
||||||
|
WatchTtlMinutes = Literal[30, 60, 120, 480, 1440]
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/watch", tags=["watch"])
|
router = APIRouter(prefix="/api/watch", tags=["watch"])
|
||||||
@@ -21,9 +23,9 @@ class WatchPinCreate(BaseModel):
|
|||||||
server_id: int = Field(..., gt=0)
|
server_id: int = Field(..., gt=0)
|
||||||
slot_index: int | None = Field(None, ge=0, le=3)
|
slot_index: int | None = Field(None, ge=0, le=3)
|
||||||
replace_slot: int | None = Field(None, ge=0, le=3, description="监测已满时指定替换槽位")
|
replace_slot: int | None = Field(None, ge=0, le=3, description="监测已满时指定替换槽位")
|
||||||
ttl_hours: Literal[8, 24] = Field(
|
ttl_minutes: WatchTtlMinutes = Field(
|
||||||
WATCH_PIN_TTL_HOURS,
|
WATCH_PIN_TTL_DEFAULT_MINUTES,
|
||||||
description="监测窗口时长,默认 8 小时;24 小时需显式指定",
|
description="监测窗口:30分/1h/2h/8h/24h,默认 30 分钟",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -36,7 +38,7 @@ class WatchPinMonitoringUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class WatchPinTtlUpdate(BaseModel):
|
class WatchPinTtlUpdate(BaseModel):
|
||||||
ttl_hours: Literal[8, 24] = Field(..., description="监测窗口时长:8 或 24 小时")
|
ttl_minutes: WatchTtlMinutes = Field(..., description="监测窗口:30分/1h/2h/8h/24h")
|
||||||
|
|
||||||
|
|
||||||
def _client_ip(request: Request) -> str:
|
def _client_ip(request: Request) -> str:
|
||||||
@@ -66,7 +68,7 @@ async def create_watch_pin(
|
|||||||
server_id=payload.server_id,
|
server_id=payload.server_id,
|
||||||
slot_index=payload.slot_index,
|
slot_index=payload.slot_index,
|
||||||
replace_slot=payload.replace_slot,
|
replace_slot=payload.replace_slot,
|
||||||
ttl_hours=payload.ttl_hours,
|
ttl_minutes=payload.ttl_minutes,
|
||||||
ip_address=_client_ip(request),
|
ip_address=_client_ip(request),
|
||||||
)
|
)
|
||||||
except SlotsFullError as e:
|
except SlotsFullError as e:
|
||||||
@@ -146,7 +148,7 @@ async def update_watch_pin_ttl(
|
|||||||
return await svc.set_slot_ttl(
|
return await svc.set_slot_ttl(
|
||||||
admin=admin,
|
admin=admin,
|
||||||
slot_index=slot_index,
|
slot_index=slot_index,
|
||||||
ttl_hours=payload.ttl_hours,
|
ttl_minutes=payload.ttl_minutes,
|
||||||
ip_address=_client_ip(request),
|
ip_address=_client_ip(request),
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ from server.infrastructure.redis.client import get_redis
|
|||||||
from server.utils.watch_state import clear_watch_redis_snapshot
|
from server.utils.watch_state import clear_watch_redis_snapshot
|
||||||
from server.utils.watch_metrics import (
|
from server.utils.watch_metrics import (
|
||||||
WATCH_MAX_SLOTS,
|
WATCH_MAX_SLOTS,
|
||||||
WATCH_PIN_TTL_HOURS,
|
WATCH_PIN_TTL_DEFAULT_MINUTES,
|
||||||
normalize_watch_ttl_hours,
|
normalize_watch_ttl_minutes,
|
||||||
|
resolve_pin_ttl_minutes,
|
||||||
server_can_be_watched,
|
server_can_be_watched,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -119,7 +120,8 @@ async def _slot_payload(
|
|||||||
"expires_at": pin_row["expires_at"].isoformat() if pin_row.get("expires_at") else None,
|
"expires_at": pin_row["expires_at"].isoformat() if pin_row.get("expires_at") else None,
|
||||||
"remaining_sec": remaining,
|
"remaining_sec": remaining,
|
||||||
"monitoring_enabled": monitoring_enabled,
|
"monitoring_enabled": monitoring_enabled,
|
||||||
"ttl_hours": int(pin_row.get("ttl_hours") or WATCH_PIN_TTL_HOURS),
|
"ttl_minutes": int(pin_row.get("ttl_minutes") or WATCH_PIN_TTL_DEFAULT_MINUTES),
|
||||||
|
"ttl_hours": int(pin_row.get("ttl_hours") or max(1, (int(pin_row.get("ttl_minutes") or 30) + 59) // 60)),
|
||||||
"metrics": metrics,
|
"metrics": metrics,
|
||||||
"sparkline": sparkline,
|
"sparkline": sparkline,
|
||||||
"processes": processes,
|
"processes": processes,
|
||||||
@@ -150,11 +152,11 @@ class WatchService:
|
|||||||
server_id: int,
|
server_id: int,
|
||||||
slot_index: int | None = None,
|
slot_index: int | None = None,
|
||||||
replace_slot: int | None = None,
|
replace_slot: int | None = None,
|
||||||
ttl_hours: int = WATCH_PIN_TTL_HOURS,
|
ttl_minutes: int = WATCH_PIN_TTL_DEFAULT_MINUTES,
|
||||||
ip_address: str = "",
|
ip_address: str = "",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
await _finalize_due_pins(self.watch_repo, self.db)
|
await _finalize_due_pins(self.watch_repo, self.db)
|
||||||
hours = normalize_watch_ttl_hours(ttl_hours)
|
minutes = normalize_watch_ttl_minutes(ttl_minutes)
|
||||||
server = await self.server_repo.get_by_id(server_id)
|
server = await self.server_repo.get_by_id(server_id)
|
||||||
if not server:
|
if not server:
|
||||||
raise ValueError("服务器不存在")
|
raise ValueError("服务器不存在")
|
||||||
@@ -165,12 +167,12 @@ class WatchService:
|
|||||||
|
|
||||||
existing = await self.watch_repo.get_pin_by_server(admin.id, server_id)
|
existing = await self.watch_repo.get_pin_by_server(admin.id, server_id)
|
||||||
if existing:
|
if existing:
|
||||||
await self.watch_repo.reset_pin_ttl(admin.id, server_id, hours)
|
await self.watch_repo.reset_pin_ttl(admin.id, server_id, minutes)
|
||||||
await self._audit(
|
await self._audit(
|
||||||
admin=admin,
|
admin=admin,
|
||||||
action="watch_pin_add",
|
action="watch_pin_add",
|
||||||
server=server,
|
server=server,
|
||||||
detail=f"重置监测 {hours}h · 槽 {existing.slot_index + 1}",
|
detail=f"重置监测 {minutes}min · 槽 {existing.slot_index + 1}",
|
||||||
ip_address=ip_address,
|
ip_address=ip_address,
|
||||||
)
|
)
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
@@ -181,13 +183,13 @@ class WatchService:
|
|||||||
admin_id=admin.id,
|
admin_id=admin.id,
|
||||||
slot_index=slot_index,
|
slot_index=slot_index,
|
||||||
server_id=server_id,
|
server_id=server_id,
|
||||||
ttl_hours=hours,
|
ttl_minutes=minutes,
|
||||||
)
|
)
|
||||||
await self._audit(
|
await self._audit(
|
||||||
admin=admin,
|
admin=admin,
|
||||||
action="watch_pin_replace" if replace_slot is not None else "watch_pin_add",
|
action="watch_pin_replace" if replace_slot is not None else "watch_pin_add",
|
||||||
server=server,
|
server=server,
|
||||||
detail=f"加入监测 · 槽 {slot_index + 1} · {hours}h",
|
detail=f"加入监测 · 槽 {slot_index + 1} · {minutes}min",
|
||||||
ip_address=ip_address,
|
ip_address=ip_address,
|
||||||
)
|
)
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
@@ -199,13 +201,13 @@ class WatchService:
|
|||||||
admin_id=admin.id,
|
admin_id=admin.id,
|
||||||
server_id=server_id,
|
server_id=server_id,
|
||||||
slot_index=empty,
|
slot_index=empty,
|
||||||
ttl_hours=hours,
|
ttl_minutes=minutes,
|
||||||
)
|
)
|
||||||
await self._audit(
|
await self._audit(
|
||||||
admin=admin,
|
admin=admin,
|
||||||
action="watch_pin_add",
|
action="watch_pin_add",
|
||||||
server=server,
|
server=server,
|
||||||
detail=f"加入监测 · 槽 {empty + 1} · {hours}h",
|
detail=f"加入监测 · 槽 {empty + 1} · {minutes}min",
|
||||||
ip_address=ip_address,
|
ip_address=ip_address,
|
||||||
)
|
)
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
@@ -216,13 +218,13 @@ class WatchService:
|
|||||||
admin_id=admin.id,
|
admin_id=admin.id,
|
||||||
slot_index=replace_slot,
|
slot_index=replace_slot,
|
||||||
server_id=server_id,
|
server_id=server_id,
|
||||||
ttl_hours=hours,
|
ttl_minutes=minutes,
|
||||||
)
|
)
|
||||||
await self._audit(
|
await self._audit(
|
||||||
admin=admin,
|
admin=admin,
|
||||||
action="watch_pin_replace",
|
action="watch_pin_replace",
|
||||||
server=server,
|
server=server,
|
||||||
detail=f"替换监测 · 槽 {replace_slot + 1} · {hours}h",
|
detail=f"替换监测 · 槽 {replace_slot + 1} · {minutes}min",
|
||||||
ip_address=ip_address,
|
ip_address=ip_address,
|
||||||
)
|
)
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
@@ -252,18 +254,25 @@ class WatchService:
|
|||||||
await self.watch_repo.end_session(other.session_id, "replaced")
|
await self.watch_repo.end_session(other.session_id, "replaced")
|
||||||
await self.watch_repo.delete_pin(other.id)
|
await self.watch_repo.delete_pin(other.id)
|
||||||
existing = await self.watch_repo.get_pin_by_slot(admin.id, slot_index)
|
existing = await self.watch_repo.get_pin_by_slot(admin.id, slot_index)
|
||||||
slot_ttl = normalize_watch_ttl_hours(int(existing.ttl_hours or 8)) if existing else WATCH_PIN_TTL_HOURS
|
slot_ttl = (
|
||||||
|
resolve_pin_ttl_minutes(
|
||||||
|
ttl_minutes=getattr(existing, "ttl_minutes", None),
|
||||||
|
ttl_hours=existing.ttl_hours,
|
||||||
|
)
|
||||||
|
if existing
|
||||||
|
else WATCH_PIN_TTL_DEFAULT_MINUTES
|
||||||
|
)
|
||||||
await self.watch_repo.replace_pin_server(
|
await self.watch_repo.replace_pin_server(
|
||||||
admin_id=admin.id,
|
admin_id=admin.id,
|
||||||
slot_index=slot_index,
|
slot_index=slot_index,
|
||||||
server_id=server_id,
|
server_id=server_id,
|
||||||
ttl_hours=slot_ttl,
|
ttl_minutes=slot_ttl,
|
||||||
)
|
)
|
||||||
await self._audit(
|
await self._audit(
|
||||||
admin=admin,
|
admin=admin,
|
||||||
action="watch_pin_replace",
|
action="watch_pin_replace",
|
||||||
server=server,
|
server=server,
|
||||||
detail=f"替换监测 · 槽 {slot_index + 1} · {slot_ttl}h",
|
detail=f"替换监测 · 槽 {slot_index + 1} · {slot_ttl}min",
|
||||||
ip_address=ip_address,
|
ip_address=ip_address,
|
||||||
)
|
)
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
@@ -314,10 +323,10 @@ class WatchService:
|
|||||||
if not pin:
|
if not pin:
|
||||||
raise ValueError("槽位为空")
|
raise ValueError("槽位为空")
|
||||||
server = await self.server_repo.get_by_id(pin.server_id)
|
server = await self.server_repo.get_by_id(pin.server_id)
|
||||||
hours = int(pin.ttl_hours or WATCH_PIN_TTL_HOURS)
|
minutes = int(pin.ttl_minutes or WATCH_PIN_TTL_DEFAULT_MINUTES)
|
||||||
action = "watch_pin_resume" if monitoring_enabled else "watch_pin_pause"
|
action = "watch_pin_resume" if monitoring_enabled else "watch_pin_pause"
|
||||||
detail = (
|
detail = (
|
||||||
f"开启实时监测 · 槽 {slot_index + 1} · 重置 {hours}h"
|
f"开启实时监测 · 槽 {slot_index + 1} · 重置 {minutes}min"
|
||||||
if monitoring_enabled
|
if monitoring_enabled
|
||||||
else f"暂停实时监测 · 槽 {slot_index + 1} · 恢复 Agent 60s"
|
else f"暂停实时监测 · 槽 {slot_index + 1} · 恢复 Agent 60s"
|
||||||
)
|
)
|
||||||
@@ -338,14 +347,14 @@ class WatchService:
|
|||||||
*,
|
*,
|
||||||
admin: Admin,
|
admin: Admin,
|
||||||
slot_index: int,
|
slot_index: int,
|
||||||
ttl_hours: int,
|
ttl_minutes: int,
|
||||||
ip_address: str = "",
|
ip_address: str = "",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
await _finalize_due_pins(self.watch_repo, self.db)
|
await _finalize_due_pins(self.watch_repo, self.db)
|
||||||
if slot_index < 0 or slot_index >= WATCH_MAX_SLOTS:
|
if slot_index < 0 or slot_index >= WATCH_MAX_SLOTS:
|
||||||
raise ValueError("槽位无效")
|
raise ValueError("槽位无效")
|
||||||
hours = normalize_watch_ttl_hours(ttl_hours)
|
minutes = normalize_watch_ttl_minutes(ttl_minutes)
|
||||||
pin = await self.watch_repo.set_pin_ttl_hours(admin.id, slot_index, hours)
|
pin = await self.watch_repo.set_pin_ttl_minutes(admin.id, slot_index, minutes)
|
||||||
if not pin:
|
if not pin:
|
||||||
raise ValueError("槽位为空")
|
raise ValueError("槽位为空")
|
||||||
server = await self.server_repo.get_by_id(pin.server_id)
|
server = await self.server_repo.get_by_id(pin.server_id)
|
||||||
@@ -353,7 +362,7 @@ class WatchService:
|
|||||||
admin=admin,
|
admin=admin,
|
||||||
action="watch_pin_ttl",
|
action="watch_pin_ttl",
|
||||||
server=server,
|
server=server,
|
||||||
detail=f"监测时长 · 槽 {slot_index + 1} · {hours}h",
|
detail=f"监测时长 · 槽 {slot_index + 1} · {minutes}min",
|
||||||
ip_address=ip_address,
|
ip_address=ip_address,
|
||||||
)
|
)
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ class Settings(BaseSettings):
|
|||||||
# Telegram (mutable — configurable from settings UI)
|
# Telegram (mutable — configurable from settings UI)
|
||||||
TELEGRAM_BOT_TOKEN: str = ""
|
TELEGRAM_BOT_TOKEN: str = ""
|
||||||
TELEGRAM_CHAT_ID: str = ""
|
TELEGRAM_CHAT_ID: str = ""
|
||||||
|
# 子机离线告警专用(二者均配置时优先于默认 Bot;否则回退 TELEGRAM_*)
|
||||||
|
TELEGRAM_OFFLINE_BOT_TOKEN: str = ""
|
||||||
|
TELEGRAM_OFFLINE_CHAT_ID: str = ""
|
||||||
|
|
||||||
# API docs — "true" exposes /docs /redoc /openapi.json (default off for production)
|
# API docs — "true" exposes /docs /redoc /openapi.json (default off for production)
|
||||||
EXPOSE_API_DOCS: str = "false"
|
EXPOSE_API_DOCS: str = "false"
|
||||||
@@ -131,6 +134,8 @@ class Settings(BaseSettings):
|
|||||||
"disk_alert_threshold": "DISK_ALERT_THRESHOLD",
|
"disk_alert_threshold": "DISK_ALERT_THRESHOLD",
|
||||||
"telegram_bot_token": "TELEGRAM_BOT_TOKEN",
|
"telegram_bot_token": "TELEGRAM_BOT_TOKEN",
|
||||||
"telegram_chat_id": "TELEGRAM_CHAT_ID",
|
"telegram_chat_id": "TELEGRAM_CHAT_ID",
|
||||||
|
"telegram_offline_bot_token": "TELEGRAM_OFFLINE_BOT_TOKEN",
|
||||||
|
"telegram_offline_chat_id": "TELEGRAM_OFFLINE_CHAT_ID",
|
||||||
"login_allowlist_enabled": "LOGIN_ALLOWLIST_ENABLED",
|
"login_allowlist_enabled": "LOGIN_ALLOWLIST_ENABLED",
|
||||||
"login_subscription_url": "LOGIN_SUBSCRIPTION_URL",
|
"login_subscription_url": "LOGIN_SUBSCRIPTION_URL",
|
||||||
"login_subscription_ips": "LOGIN_SUBSCRIPTION_IPS",
|
"login_subscription_ips": "LOGIN_SUBSCRIPTION_IPS",
|
||||||
|
|||||||
@@ -193,7 +193,13 @@ class WatchPin(Base):
|
|||||||
Integer,
|
Integer,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default=8,
|
default=8,
|
||||||
comment="监测窗口时长(小时),仅允许 8 或 24",
|
comment="遗留字段(小时),新逻辑用 ttl_minutes",
|
||||||
|
)
|
||||||
|
ttl_minutes = Column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
default=30,
|
||||||
|
comment="监测窗口时长(分钟) 30|60|120|480|1440",
|
||||||
)
|
)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Browser Worker bridge infrastructure."""
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""In-memory browser session ownership (single-operator; primary worker only)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BridgeSession:
|
||||||
|
session_id: str
|
||||||
|
admin_id: int
|
||||||
|
viewport_w: int
|
||||||
|
viewport_h: int
|
||||||
|
created_at: float = field(default_factory=time.time)
|
||||||
|
last_activity: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
|
||||||
|
_sessions: dict[str, BridgeSession] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register(session_id: str, admin_id: int, viewport_w: int, viewport_h: int) -> BridgeSession:
|
||||||
|
sess = BridgeSession(session_id, admin_id, viewport_w, viewport_h)
|
||||||
|
_sessions[session_id] = sess
|
||||||
|
return sess
|
||||||
|
|
||||||
|
|
||||||
|
def get(session_id: str) -> BridgeSession | None:
|
||||||
|
return _sessions.get(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def touch(session_id: str) -> None:
|
||||||
|
sess = _sessions.get(session_id)
|
||||||
|
if sess:
|
||||||
|
sess.last_activity = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def remove(session_id: str) -> BridgeSession | None:
|
||||||
|
return _sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def count_for_admin(admin_id: int) -> int:
|
||||||
|
return sum(1 for s in _sessions.values() if s.admin_id == admin_id)
|
||||||
|
|
||||||
|
|
||||||
|
def session_ids_for_admin(admin_id: int) -> list[str]:
|
||||||
|
return [sid for sid, s in _sessions.items() if s.admin_id == admin_id]
|
||||||
|
|
||||||
|
|
||||||
|
def total_count() -> int:
|
||||||
|
return len(_sessions)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""HTTP client for Browser Worker VPS."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from server.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger("nexus.browser.worker")
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserWorkerError(Exception):
|
||||||
|
def __init__(self, message: str, status_code: int = 503) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
def browser_enabled() -> bool:
|
||||||
|
return (
|
||||||
|
settings.BROWSER_ENABLED.lower() in ("1", "true", "yes")
|
||||||
|
and bool(settings.BROWSER_WORKER_URL)
|
||||||
|
and bool(settings.BROWSER_WORKER_SECRET)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _headers() -> dict[str, str]:
|
||||||
|
return {"X-Nexus-Worker-Key": settings.BROWSER_WORKER_SECRET}
|
||||||
|
|
||||||
|
|
||||||
|
def _tls_verify() -> bool:
|
||||||
|
return settings.BROWSER_WORKER_TLS_VERIFY.lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def worker_stream_url(session_id: str) -> str:
|
||||||
|
base = settings.BROWSER_WORKER_URL.rstrip("/")
|
||||||
|
parsed = urlparse(base)
|
||||||
|
scheme = "wss" if parsed.scheme == "https" else "ws"
|
||||||
|
netloc = parsed.netloc or parsed.path
|
||||||
|
path = f"/v1/sessions/{session_id}/stream"
|
||||||
|
return urlunparse((scheme, netloc, path, "", "", ""))
|
||||||
|
|
||||||
|
|
||||||
|
async def create_worker_session(session_id: str, viewport_w: int, viewport_h: int) -> dict[str, Any]:
|
||||||
|
if not browser_enabled():
|
||||||
|
raise BrowserWorkerError("Browser worker not enabled", 503)
|
||||||
|
url = f"{settings.BROWSER_WORKER_URL.rstrip('/')}/v1/sessions"
|
||||||
|
payload = {"session_id": session_id, "viewport_w": viewport_w, "viewport_h": viewport_h}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0, verify=_tls_verify()) as client:
|
||||||
|
resp = await client.post(url, json=payload, headers=_headers())
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
logger.warning("worker create failed: %s", exc)
|
||||||
|
raise BrowserWorkerError("Browser worker unreachable", 503) from exc
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
detail = resp.text[:200]
|
||||||
|
raise BrowserWorkerError(detail or "Worker rejected session", resp.status_code)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_worker_session(session_id: str) -> None:
|
||||||
|
if not browser_enabled():
|
||||||
|
return
|
||||||
|
url = f"{settings.BROWSER_WORKER_URL.rstrip('/')}/v1/sessions/{session_id}"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15.0, verify=_tls_verify()) as client:
|
||||||
|
await client.delete(url, headers=_headers())
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
logger.warning("worker delete failed session=%s: %s", session_id, exc)
|
||||||
|
|
||||||
|
|
||||||
|
async def worker_health() -> dict[str, Any] | None:
|
||||||
|
if not browser_enabled():
|
||||||
|
return None
|
||||||
|
url = f"{settings.BROWSER_WORKER_URL.rstrip('/')}/health"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0, verify=_tls_verify()) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json()
|
||||||
|
except httpx.RequestError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
@@ -240,7 +240,8 @@ async def run_schema_migrations():
|
|||||||
`pinned_at` DATETIME NOT NULL,
|
`pinned_at` DATETIME NOT NULL,
|
||||||
`expires_at` DATETIME NOT NULL,
|
`expires_at` DATETIME NOT NULL,
|
||||||
`monitoring_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否开启实时探针',
|
`monitoring_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否开启实时探针',
|
||||||
`ttl_hours` INT NOT NULL DEFAULT 8 COMMENT '监测窗口时长(小时) 8|24',
|
`ttl_hours` INT NOT NULL DEFAULT 1 COMMENT '遗留字段(小时)',
|
||||||
|
`ttl_minutes` INT NOT NULL DEFAULT 30 COMMENT '监测窗口时长(分钟)',
|
||||||
UNIQUE KEY `uq_watch_pins_admin_slot` (`admin_id`, `slot_index`),
|
UNIQUE KEY `uq_watch_pins_admin_slot` (`admin_id`, `slot_index`),
|
||||||
UNIQUE KEY `uq_watch_pins_admin_server` (`admin_id`, `server_id`),
|
UNIQUE KEY `uq_watch_pins_admin_server` (`admin_id`, `server_id`),
|
||||||
INDEX `idx_watch_pins_expires` (`expires_at`),
|
INDEX `idx_watch_pins_expires` (`expires_at`),
|
||||||
@@ -285,6 +286,8 @@ async def run_schema_migrations():
|
|||||||
"ALTER TABLE `watch_probe_records` ADD COLUMN `processes_json` JSON NULL COMMENT 'Top5 processes snapshot'",
|
"ALTER TABLE `watch_probe_records` ADD COLUMN `processes_json` JSON NULL COMMENT 'Top5 processes snapshot'",
|
||||||
"ALTER TABLE `watch_pins` ADD COLUMN `monitoring_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否开启实时探针'",
|
"ALTER TABLE `watch_pins` ADD COLUMN `monitoring_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否开启实时探针'",
|
||||||
"ALTER TABLE `watch_pins` ADD COLUMN `ttl_hours` INT NOT NULL DEFAULT 8 COMMENT '监测窗口时长(小时) 8|24'",
|
"ALTER TABLE `watch_pins` ADD COLUMN `ttl_hours` INT NOT NULL DEFAULT 8 COMMENT '监测窗口时长(小时) 8|24'",
|
||||||
|
"ALTER TABLE `watch_pins` ADD COLUMN `ttl_minutes` INT NOT NULL DEFAULT 30 COMMENT '监测窗口时长(分钟)'",
|
||||||
|
"UPDATE `watch_pins` SET `ttl_minutes` = `ttl_hours` * 60 WHERE `ttl_hours` IN (8, 24)",
|
||||||
]
|
]
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
for sql in migrations:
|
for sql in migrations:
|
||||||
|
|||||||
@@ -9,7 +9,26 @@ from sqlalchemy import delete, func, or_, select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from server.domain.models import Server, WatchPin, WatchPinSession, WatchProbeRecord
|
from server.domain.models import Server, WatchPin, WatchPinSession, WatchProbeRecord
|
||||||
from server.utils.watch_metrics import normalize_watch_ttl_hours
|
from server.utils.watch_metrics import (
|
||||||
|
WATCH_PIN_TTL_DEFAULT_MINUTES,
|
||||||
|
normalize_watch_ttl_minutes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pin_ttl_minutes(pin: WatchPin) -> int:
|
||||||
|
raw = getattr(pin, "ttl_minutes", None)
|
||||||
|
if raw and int(raw) in {30, 60, 120, 480, 1440}:
|
||||||
|
return int(raw)
|
||||||
|
if pin.ttl_hours in (8, 24):
|
||||||
|
return int(pin.ttl_hours) * 60
|
||||||
|
return WATCH_PIN_TTL_DEFAULT_MINUTES
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_pin_ttl(pin: WatchPin, minutes: int) -> int:
|
||||||
|
m = normalize_watch_ttl_minutes(minutes)
|
||||||
|
pin.ttl_minutes = m
|
||||||
|
pin.ttl_hours = max(1, (m + 59) // 60)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
def _utc_naive_now() -> datetime:
|
def _utc_naive_now() -> datetime:
|
||||||
@@ -54,6 +73,7 @@ class WatchRepositoryImpl:
|
|||||||
"pinned_at": pin.pinned_at,
|
"pinned_at": pin.pinned_at,
|
||||||
"expires_at": pin.expires_at,
|
"expires_at": pin.expires_at,
|
||||||
"monitoring_enabled": bool(pin.monitoring_enabled),
|
"monitoring_enabled": bool(pin.monitoring_enabled),
|
||||||
|
"ttl_minutes": _pin_ttl_minutes(pin),
|
||||||
"ttl_hours": int(pin.ttl_hours or 8),
|
"ttl_hours": int(pin.ttl_hours or 8),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -109,11 +129,11 @@ class WatchRepositoryImpl:
|
|||||||
admin_id: int,
|
admin_id: int,
|
||||||
server_id: int,
|
server_id: int,
|
||||||
slot_index: int,
|
slot_index: int,
|
||||||
ttl_hours: int = 8,
|
ttl_minutes: int = WATCH_PIN_TTL_DEFAULT_MINUTES,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
now = _utc_naive_now()
|
now = _utc_naive_now()
|
||||||
hours = normalize_watch_ttl_hours(ttl_hours)
|
minutes = normalize_watch_ttl_minutes(ttl_minutes)
|
||||||
expires = now + timedelta(hours=hours)
|
expires = now + timedelta(minutes=minutes)
|
||||||
sess = WatchPinSession(
|
sess = WatchPinSession(
|
||||||
admin_id=admin_id,
|
admin_id=admin_id,
|
||||||
server_id=server_id,
|
server_id=server_id,
|
||||||
@@ -130,7 +150,8 @@ class WatchRepositoryImpl:
|
|||||||
pinned_at=now,
|
pinned_at=now,
|
||||||
expires_at=expires,
|
expires_at=expires,
|
||||||
monitoring_enabled=True,
|
monitoring_enabled=True,
|
||||||
ttl_hours=hours,
|
ttl_hours=max(1, (minutes + 59) // 60),
|
||||||
|
ttl_minutes=minutes,
|
||||||
)
|
)
|
||||||
self.session.add(pin)
|
self.session.add(pin)
|
||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
@@ -141,7 +162,8 @@ class WatchRepositoryImpl:
|
|||||||
"server_id": server_id,
|
"server_id": server_id,
|
||||||
"pinned_at": now,
|
"pinned_at": now,
|
||||||
"expires_at": expires,
|
"expires_at": expires,
|
||||||
"ttl_hours": hours,
|
"ttl_minutes": minutes,
|
||||||
|
"ttl_hours": pin.ttl_hours,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def end_session(self, session_id: int, reason: str) -> None:
|
async def end_session(self, session_id: int, reason: str) -> None:
|
||||||
@@ -168,7 +190,7 @@ class WatchRepositoryImpl:
|
|||||||
admin_id: int,
|
admin_id: int,
|
||||||
slot_index: int,
|
slot_index: int,
|
||||||
server_id: int,
|
server_id: int,
|
||||||
ttl_hours: int = 8,
|
ttl_minutes: int = WATCH_PIN_TTL_DEFAULT_MINUTES,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
existing = await self.get_pin_by_slot(admin_id, slot_index)
|
existing = await self.get_pin_by_slot(admin_id, slot_index)
|
||||||
if existing:
|
if existing:
|
||||||
@@ -182,23 +204,25 @@ class WatchRepositoryImpl:
|
|||||||
admin_id=admin_id,
|
admin_id=admin_id,
|
||||||
server_id=server_id,
|
server_id=server_id,
|
||||||
slot_index=slot_index,
|
slot_index=slot_index,
|
||||||
ttl_hours=ttl_hours,
|
ttl_minutes=ttl_minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def reset_pin_ttl(self, admin_id: int, server_id: int, ttl_hours: int | None = None) -> dict[str, Any]:
|
async def reset_pin_ttl(self, admin_id: int, server_id: int, ttl_minutes: int | None = None) -> dict[str, Any]:
|
||||||
"""Re-pin same server: end old session and start fresh monitoring window."""
|
"""Re-pin same server: end old session and start fresh monitoring window."""
|
||||||
pin = await self.get_pin_by_server(admin_id, server_id)
|
pin = await self.get_pin_by_server(admin_id, server_id)
|
||||||
if not pin:
|
if not pin:
|
||||||
raise ValueError("pin not found")
|
raise ValueError("pin not found")
|
||||||
slot_index = pin.slot_index
|
slot_index = pin.slot_index
|
||||||
hours = normalize_watch_ttl_hours(ttl_hours if ttl_hours is not None else int(pin.ttl_hours or 8))
|
minutes = normalize_watch_ttl_minutes(
|
||||||
|
ttl_minutes if ttl_minutes is not None else _pin_ttl_minutes(pin)
|
||||||
|
)
|
||||||
await self.end_session(pin.session_id, "replaced")
|
await self.end_session(pin.session_id, "replaced")
|
||||||
await self.delete_pin(pin.id)
|
await self.delete_pin(pin.id)
|
||||||
return await self.create_pin(
|
return await self.create_pin(
|
||||||
admin_id=admin_id,
|
admin_id=admin_id,
|
||||||
server_id=server_id,
|
server_id=server_id,
|
||||||
slot_index=slot_index,
|
slot_index=slot_index,
|
||||||
ttl_hours=hours,
|
ttl_minutes=minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def remove_pin_by_slot(self, admin_id: int, slot_index: int) -> WatchPin | None:
|
async def remove_pin_by_slot(self, admin_id: int, slot_index: int) -> WatchPin | None:
|
||||||
@@ -233,37 +257,35 @@ class WatchRepositoryImpl:
|
|||||||
slot_index: int,
|
slot_index: int,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
*,
|
*,
|
||||||
ttl_hours: int | None = None,
|
ttl_minutes: int | None = None,
|
||||||
) -> WatchPin | None:
|
) -> WatchPin | None:
|
||||||
pin = await self.get_pin_by_slot(admin_id, slot_index)
|
pin = await self.get_pin_by_slot(admin_id, slot_index)
|
||||||
if not pin:
|
if not pin:
|
||||||
return None
|
return None
|
||||||
if ttl_hours is not None:
|
if ttl_minutes is not None:
|
||||||
pin.ttl_hours = normalize_watch_ttl_hours(ttl_hours)
|
_apply_pin_ttl(pin, ttl_minutes)
|
||||||
if enabled:
|
if enabled:
|
||||||
await self._ensure_open_session(pin)
|
await self._ensure_open_session(pin)
|
||||||
hours = normalize_watch_ttl_hours(int(pin.ttl_hours or 8))
|
minutes = _apply_pin_ttl(pin, _pin_ttl_minutes(pin))
|
||||||
pin.ttl_hours = hours
|
|
||||||
pin.monitoring_enabled = True
|
pin.monitoring_enabled = True
|
||||||
pin.expires_at = _utc_naive_now() + timedelta(hours=hours)
|
pin.expires_at = _utc_naive_now() + timedelta(minutes=minutes)
|
||||||
else:
|
else:
|
||||||
pin.monitoring_enabled = False
|
pin.monitoring_enabled = False
|
||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
return pin
|
return pin
|
||||||
|
|
||||||
async def set_pin_ttl_hours(
|
async def set_pin_ttl_minutes(
|
||||||
self,
|
self,
|
||||||
admin_id: int,
|
admin_id: int,
|
||||||
slot_index: int,
|
slot_index: int,
|
||||||
ttl_hours: int,
|
ttl_minutes: int,
|
||||||
) -> WatchPin | None:
|
) -> WatchPin | None:
|
||||||
pin = await self.get_pin_by_slot(admin_id, slot_index)
|
pin = await self.get_pin_by_slot(admin_id, slot_index)
|
||||||
if not pin:
|
if not pin:
|
||||||
return None
|
return None
|
||||||
hours = normalize_watch_ttl_hours(ttl_hours)
|
minutes = _apply_pin_ttl(pin, ttl_minutes)
|
||||||
pin.ttl_hours = hours
|
|
||||||
if pin.monitoring_enabled:
|
if pin.monitoring_enabled:
|
||||||
pin.expires_at = _utc_naive_now() + timedelta(hours=hours)
|
pin.expires_at = _utc_naive_now() + timedelta(minutes=minutes)
|
||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
return pin
|
return pin
|
||||||
|
|
||||||
|
|||||||
@@ -419,6 +419,9 @@ async def remote_write_bytes(
|
|||||||
|
|
||||||
if not skip_sftp:
|
if not skip_sftp:
|
||||||
try:
|
try:
|
||||||
|
parent = posix_dirname(remote_path)
|
||||||
|
if parent and parent != "/":
|
||||||
|
await _ensure_remote_parent_dir(conn, remote_path, use_sudo=False)
|
||||||
async with await conn.start_sftp_client() as sftp:
|
async with await conn.start_sftp_client() as sftp:
|
||||||
await sftp_write_bytes(sftp, remote_path, data)
|
await sftp_write_bytes(sftp, remote_path, data)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -93,17 +93,38 @@ async def close_client():
|
|||||||
_http_client = None
|
_http_client = None
|
||||||
|
|
||||||
|
|
||||||
async def send_telegram(message: str) -> bool:
|
def resolve_offline_telegram_credentials() -> tuple[str, str] | None:
|
||||||
|
"""Bot + chat for offline alerts: dedicated pair if both set, else default."""
|
||||||
|
off_token = (settings.TELEGRAM_OFFLINE_BOT_TOKEN or "").strip()
|
||||||
|
off_chat = (settings.TELEGRAM_OFFLINE_CHAT_ID or "").strip()
|
||||||
|
if off_token and off_chat:
|
||||||
|
return off_token, off_chat
|
||||||
|
|
||||||
|
main_token = (settings.TELEGRAM_BOT_TOKEN or "").strip()
|
||||||
|
main_chat = (settings.TELEGRAM_CHAT_ID or "").strip()
|
||||||
|
if main_token and main_chat:
|
||||||
|
return main_token, main_chat
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def send_telegram(
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
bot_token: str | None = None,
|
||||||
|
chat_id: str | None = None,
|
||||||
|
) -> bool:
|
||||||
"""Send a Telegram message via Bot API."""
|
"""Send a Telegram message via Bot API."""
|
||||||
if not settings.TELEGRAM_BOT_TOKEN or not settings.TELEGRAM_CHAT_ID:
|
token = (bot_token or settings.TELEGRAM_BOT_TOKEN or "").strip()
|
||||||
|
chat = (chat_id or settings.TELEGRAM_CHAT_ID or "").strip()
|
||||||
|
if not token or not chat:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = await _get_client()
|
client = await _get_client()
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"{TELEGRAM_API_BASE}/bot{settings.TELEGRAM_BOT_TOKEN}/sendMessage",
|
f"{TELEGRAM_API_BASE}/bot{token}/sendMessage",
|
||||||
json={
|
json={
|
||||||
"chat_id": settings.TELEGRAM_CHAT_ID,
|
"chat_id": chat,
|
||||||
"text": message,
|
"text": message,
|
||||||
"parse_mode": "HTML",
|
"parse_mode": "HTML",
|
||||||
},
|
},
|
||||||
@@ -175,6 +196,11 @@ async def send_telegram_offline_alert(
|
|||||||
|
|
||||||
ip_line = _telegram_ip_line(public_ip, private_ip)
|
ip_line = _telegram_ip_line(public_ip, private_ip)
|
||||||
|
|
||||||
|
creds = resolve_offline_telegram_credentials()
|
||||||
|
if not creds:
|
||||||
|
return False
|
||||||
|
|
||||||
|
bot_token, tg_chat_id = creds
|
||||||
return await send_telegram(
|
return await send_telegram(
|
||||||
f"🔴 <b>【子机离线】Agent 心跳丢失</b>\n"
|
f"🔴 <b>【子机离线】Agent 心跳丢失</b>\n"
|
||||||
f"平台:{_system_name()}\n"
|
f"平台:{_system_name()}\n"
|
||||||
@@ -182,7 +208,9 @@ async def send_telegram_offline_alert(
|
|||||||
f"检测来源:Layer 2 server_offline_monitor(30 秒周期)\n"
|
f"检测来源:Layer 2 server_offline_monitor(30 秒周期)\n"
|
||||||
f"具体情况:子机未在预期时间内上报心跳,平台已将其标记为离线\n"
|
f"具体情况:子机未在预期时间内上报心跳,平台已将其标记为离线\n"
|
||||||
f"建议操作:检查子机 Agent 进程、网络与防火墙;或在 Nexus 服务器页执行诊断\n"
|
f"建议操作:检查子机 Agent 进程、网络与防火墙;或在 Nexus 服务器页执行诊断\n"
|
||||||
f"时间:{_now_cn()}"
|
f"时间:{_now_cn()}",
|
||||||
|
bot_token=bot_token,
|
||||||
|
chat_id=tg_chat_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ from server.api.files import router as files_router
|
|||||||
from server.api.search import router as search_router
|
from server.api.search import router as search_router
|
||||||
from server.api.terminal import router as terminal_router
|
from server.api.terminal import router as terminal_router
|
||||||
from server.api.watch import router as watch_router
|
from server.api.watch import router as watch_router
|
||||||
|
from server.api.browser import router as browser_router
|
||||||
|
from server.api.browser_session import router as browser_session_router, ws_router as browser_ws_router
|
||||||
|
|
||||||
# Background tasks
|
# Background tasks
|
||||||
from server.background.heartbeat_flush import heartbeat_flush_loop
|
from server.background.heartbeat_flush import heartbeat_flush_loop
|
||||||
@@ -696,6 +698,11 @@ app.include_router(search_router)
|
|||||||
app.include_router(terminal_router)
|
app.include_router(terminal_router)
|
||||||
app.include_router(watch_router)
|
app.include_router(watch_router)
|
||||||
|
|
||||||
|
# Remote browser (UI state + Worker bridge)
|
||||||
|
app.include_router(browser_router)
|
||||||
|
app.include_router(browser_session_router)
|
||||||
|
app.include_router(browser_ws_router)
|
||||||
|
|
||||||
|
|
||||||
# ── Custom 404: return blank HTML to hide backend identity ──
|
# ── Custom 404: return blank HTML to hide backend identity ──
|
||||||
@app.exception_handler(StarletteHTTPException)
|
@app.exception_handler(StarletteHTTPException)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user