Compare commits
7 Commits
1026134977
...
84b714ced3
| Author | SHA1 | Date | |
|---|---|---|---|
| 84b714ced3 | |||
| 0ad8796285 | |||
| 5c60627fb6 | |||
| 2934473770 | |||
| 8a6ed249be | |||
| ad16618406 | |||
| a42ecf7bcc |
@@ -0,0 +1,30 @@
|
||||
# dependencies
|
||||
node_modules/
|
||||
backend/__pycache__/
|
||||
backend/**/__pycache__/
|
||||
backend/*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# data & runtime
|
||||
data/
|
||||
*.log
|
||||
|
||||
# git
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# env
|
||||
.env
|
||||
.env.*
|
||||
+5
-3
@@ -1,11 +1,13 @@
|
||||
# ===== 必填 =====
|
||||
# 管理员账号(首次启动自动创建)
|
||||
ADMIN_EMAIL=admin@smartup.local
|
||||
ADMIN_PASSWORD=changeme123
|
||||
ADMIN_PASSWORD=replace-with-a-strong-password
|
||||
|
||||
# ===== 推荐配置 =====
|
||||
# JWT 签名密钥(生产环境请替换): openssl rand -hex 32
|
||||
JWT_SECRET=change-me-in-production
|
||||
# JWT 签名密钥: openssl rand -hex 32
|
||||
JWT_SECRET=replace-with-openssl-rand-hex-32
|
||||
# 允许访问 API 的前端源,多个用逗号分隔
|
||||
CORS_ORIGINS=http://localhost:8899,http://127.0.0.1:8899
|
||||
|
||||
# ===== 可选 =====
|
||||
# 监听端口(默认 8899)
|
||||
|
||||
@@ -25,6 +25,7 @@ RUN apt-get update \
|
||||
libfreetype6 libgbm1 libglib2.0-0t64 libgtk-3-0t64 libnspr4 libnss3 \
|
||||
libpango-1.0-0 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 \
|
||||
libxdamage1 libxext6 libxfixes3 libxrandr2 libxshmfence1 xvfb \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN playwright install chromium
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ SERVICE ?= smartup
|
||||
|
||||
up:
|
||||
$(COMPOSE) up -d --build
|
||||
@port=$$(grep -E '^SERVER_PORT=' .env 2>/dev/null | tail -n 1 | cut -d= -f2-); \
|
||||
printf '访问地址:http://localhost:%s\n' "$${port:-8899}"
|
||||
|
||||
down:
|
||||
$(COMPOSE) down
|
||||
|
||||
+10
-2
@@ -4,9 +4,13 @@ from functools import lru_cache
|
||||
|
||||
class Settings(BaseSettings):
|
||||
admin_email: str = "admin@smartup.local"
|
||||
admin_password: str = "changeme"
|
||||
jwt_secret: str = "change-me-in-production"
|
||||
admin_password: str = ""
|
||||
jwt_secret: str = ""
|
||||
jwt_expire_hours: int = 24
|
||||
cors_origins: str = "http://localhost:8899,http://127.0.0.1:8899"
|
||||
login_rate_limit_attempts: int = 5
|
||||
login_rate_limit_window_seconds: int = 300
|
||||
snapshot_retention_count: int = 500
|
||||
database_url: str = "sqlite:////app/data/app.db"
|
||||
tz: str = "Asia/Shanghai"
|
||||
# consecutive failures before upstream goes unhealthy
|
||||
@@ -14,6 +18,10 @@ class Settings(BaseSettings):
|
||||
browser_profiles_dir: str = "/app/data/browser-profiles"
|
||||
browser_headless: bool = True
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
return [item.strip() for item in self.cors_origins.split(",") if item.strip()]
|
||||
|
||||
model_config = {"env_file": ".env", "case_sensitive": False, "extra": "ignore"}
|
||||
|
||||
|
||||
|
||||
+2
-13
@@ -26,7 +26,7 @@ def get_db():
|
||||
def init_db():
|
||||
"""Create all tables."""
|
||||
# import models so SQLAlchemy registers them
|
||||
from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page, website # noqa: F401
|
||||
from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page, website, revoked_token # noqa: F401
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_migrate_custom_pages()
|
||||
|
||||
@@ -64,15 +64,4 @@ def _migrate_custom_pages():
|
||||
"AND NULLIF(TRIM(login_password), '') IS NOT NULL"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE custom_pages "
|
||||
"SET access_mode = 'remote_browser', use_proxy = 0 "
|
||||
"WHERE url LIKE :host OR url LIKE :host_slash OR url LIKE :host_port"
|
||||
),
|
||||
{
|
||||
"host": "%://170.106.100.210",
|
||||
"host_slash": "%://170.106.100.210/%",
|
||||
"host_port": "%://170.106.100.210:%",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+27
-9
@@ -12,7 +12,7 @@ from app.config import get_settings
|
||||
from app.database import init_db
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.database import SessionLocal
|
||||
from app.utils.auth import hash_password
|
||||
from app.utils.auth import hash_password, verify_password, validate_password_supported
|
||||
from app.services.scheduler import start_scheduler, stop_scheduler
|
||||
from app.routers import auth, upstreams, webhooks, logs, custom_pages, browser_sessions, websites
|
||||
from app.services.browser_session_service import browser_sessions as browser_session_service
|
||||
@@ -21,15 +21,33 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _init_admin() -> None:
|
||||
def _validate_runtime_settings() -> None:
|
||||
settings = get_settings()
|
||||
if not settings.admin_password:
|
||||
logger.warning("ADMIN_PASSWORD not set, skip admin init")
|
||||
return
|
||||
raise RuntimeError("ADMIN_PASSWORD must be set")
|
||||
if settings.admin_password in {"changeme", "changeme123"}:
|
||||
raise RuntimeError("ADMIN_PASSWORD must not use the default placeholder")
|
||||
if not settings.jwt_secret or settings.jwt_secret == "change-me-in-production":
|
||||
raise RuntimeError("JWT_SECRET must be set to a non-default value")
|
||||
if not settings.cors_origin_list:
|
||||
raise RuntimeError("CORS_ORIGINS must include at least one explicit origin")
|
||||
validate_password_supported(settings.admin_password)
|
||||
|
||||
|
||||
def _init_admin() -> None:
|
||||
settings = get_settings()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
exists = db.query(AdminUser).filter(AdminUser.email == settings.admin_email).first()
|
||||
if not exists:
|
||||
existing = db.query(AdminUser).filter(AdminUser.email == settings.admin_email).first()
|
||||
if existing:
|
||||
# Sync password hash if .env has changed since first creation
|
||||
if not verify_password(settings.admin_password, existing.password_hash):
|
||||
existing.password_hash = hash_password(settings.admin_password)
|
||||
db.commit()
|
||||
logger.info("admin password updated: %s", settings.admin_email)
|
||||
else:
|
||||
logger.info("admin user already exists: %s", settings.admin_email)
|
||||
else:
|
||||
user = AdminUser(
|
||||
email=settings.admin_email,
|
||||
password_hash=hash_password(settings.admin_password),
|
||||
@@ -37,14 +55,13 @@ def _init_admin() -> None:
|
||||
db.add(user)
|
||||
db.commit()
|
||||
logger.info("admin user created: %s", settings.admin_email)
|
||||
else:
|
||||
logger.info("admin user already exists: %s", settings.admin_email)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
_validate_runtime_settings()
|
||||
init_db()
|
||||
_init_admin()
|
||||
start_scheduler()
|
||||
@@ -63,9 +80,10 @@ app = FastAPI(
|
||||
openapi_url="/api/openapi.json",
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=settings.cors_origin_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class RevokedToken(Base):
|
||||
__tablename__ = "revoked_tokens"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
jti: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime, index=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
@@ -1,18 +1,63 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from datetime import datetime, timezone
|
||||
from threading import Lock
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.models.revoked_token import RevokedToken
|
||||
from app.schemas.auth import LoginRequest, TokenResponse, UserInfo
|
||||
from app.utils.auth import verify_password, create_access_token, get_current_user
|
||||
from app.utils.auth import bearer_scheme, create_access_token, decode_token_payload, get_current_user, verify_password
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
_login_attempts: dict[tuple[str, str], list[float]] = {}
|
||||
_login_attempts_lock = Lock()
|
||||
|
||||
|
||||
def _login_key(request: Request, email: str) -> tuple[str, str]:
|
||||
forwarded = request.headers.get("x-forwarded-for", "").split(",", 1)[0].strip()
|
||||
ip = forwarded or (request.client.host if request.client else "unknown")
|
||||
return ip, email.lower()
|
||||
|
||||
|
||||
def _check_login_limit(key: tuple[str, str]) -> None:
|
||||
settings = get_settings()
|
||||
now = datetime.now(timezone.utc).timestamp()
|
||||
cutoff = now - settings.login_rate_limit_window_seconds
|
||||
with _login_attempts_lock:
|
||||
attempts = [item for item in _login_attempts.get(key, []) if item >= cutoff]
|
||||
if len(attempts) >= settings.login_rate_limit_attempts:
|
||||
_login_attempts[key] = attempts
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="登录尝试过多,请稍后再试")
|
||||
_login_attempts[key] = attempts
|
||||
|
||||
|
||||
def _record_login_failure(key: tuple[str, str]) -> None:
|
||||
with _login_attempts_lock:
|
||||
_login_attempts.setdefault(key, []).append(datetime.now(timezone.utc).timestamp())
|
||||
|
||||
|
||||
def _clear_login_failures(key: tuple[str, str]) -> None:
|
||||
with _login_attempts_lock:
|
||||
_login_attempts.pop(key, None)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(req: LoginRequest, db: Session = Depends(get_db)):
|
||||
def login(req: LoginRequest, request: Request, db: Session = Depends(get_db)):
|
||||
key = _login_key(request, req.email)
|
||||
_check_login_limit(key)
|
||||
user = db.query(AdminUser).filter(AdminUser.email == req.email).first()
|
||||
if not user or not verify_password(req.password, user.password_hash):
|
||||
try:
|
||||
password_ok = bool(user and verify_password(req.password, user.password_hash))
|
||||
except ValueError:
|
||||
password_ok = False
|
||||
if not password_ok:
|
||||
_record_login_failure(key)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="邮箱或密码错误")
|
||||
_clear_login_failures(key)
|
||||
token = create_access_token(user.email)
|
||||
return TokenResponse(access_token=token)
|
||||
|
||||
@@ -23,6 +68,17 @@ def me(current_user: AdminUser = Depends(get_current_user)):
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout():
|
||||
# JWT is stateless — client discards token
|
||||
def logout(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
_current_user: AdminUser = Depends(get_current_user),
|
||||
):
|
||||
payload = decode_token_payload(credentials.credentials)
|
||||
jti = payload.get("jti") if payload else None
|
||||
exp = payload.get("exp") if payload else None
|
||||
if jti and exp and not db.query(RevokedToken).filter(RevokedToken.jti == jti).first():
|
||||
expires_at = datetime.fromtimestamp(exp, tz=timezone.utc)
|
||||
db.add(RevokedToken(jti=jti, expires_at=expires_at))
|
||||
db.query(RevokedToken).filter(RevokedToken.expires_at < datetime.now(timezone.utc)).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
return {"message": "logged out"}
|
||||
|
||||
@@ -41,6 +41,10 @@ class BrowserSessionResponse(BaseModel):
|
||||
title: str
|
||||
|
||||
|
||||
class BrowserSelectionResponse(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
class BrowserEvent(BaseModel):
|
||||
type: Literal["click", "dblclick", "mousemove", "mousedown", "mouseup", "type", "key", "scroll", "reload", "back", "forward", "resize"]
|
||||
x: Optional[float] = None
|
||||
@@ -119,6 +123,14 @@ async def send_event(session_id: str, body: BrowserEvent, _=Depends(get_current_
|
||||
raise _error_from_browser(exc)
|
||||
|
||||
|
||||
@router.get("/{session_id}/selection", response_model=BrowserSelectionResponse)
|
||||
async def get_selection(session_id: str, _=Depends(get_current_user)):
|
||||
try:
|
||||
return BrowserSelectionResponse(text=await browser_sessions.selected_text(session_id))
|
||||
except Exception as exc:
|
||||
raise _error_from_browser(exc)
|
||||
|
||||
|
||||
@router.delete("/{session_id}", status_code=204)
|
||||
async def close_session(session_id: str, _=Depends(get_current_user)):
|
||||
await browser_sessions.close(session_id)
|
||||
@@ -126,9 +138,12 @@ async def close_session(session_id: str, _=Depends(get_current_user)):
|
||||
|
||||
# ——— WebSocket stream ———
|
||||
# Frame interval & diff detection
|
||||
_WS_MIN_INTERVAL = 0.05 # 50 ms floor (≈20 fps max)
|
||||
_WS_IDLE_INTERVAL = 0.15 # 150 ms when nothing changed recently
|
||||
_WS_ACTIVE_INTERVAL = 0.08 # 80 ms right after a user event
|
||||
_WS_MIN_INTERVAL = 0.10
|
||||
_WS_IDLE_INTERVAL = 0.35
|
||||
_WS_ACTIVE_INTERVAL = 0.12
|
||||
_WS_BACKOFF_INTERVAL = 0.60
|
||||
_WS_DEEP_IDLE_INTERVAL = 1.00
|
||||
_WS_ACTIVE_WINDOW = 1.25
|
||||
|
||||
|
||||
async def _ws_authenticate(token: Optional[str]) -> bool:
|
||||
@@ -163,10 +178,11 @@ async def session_ws(
|
||||
# Track when a user event arrived so we can temporarily speed up
|
||||
last_event_at: float = 0.0
|
||||
last_frame_hash: str = ""
|
||||
unchanged_count = 0
|
||||
|
||||
# Task: receive events from client
|
||||
async def receive_loop():
|
||||
nonlocal last_event_at
|
||||
nonlocal last_event_at, unchanged_count
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
@@ -179,8 +195,9 @@ async def session_ws(
|
||||
continue
|
||||
payload: dict[str, Any] = {k: v for k, v in msg.items() if k != "type"}
|
||||
try:
|
||||
await browser_sessions.event(session_id, msg_type, payload)
|
||||
await browser_sessions.event(session_id, msg_type, payload, include_state=False)
|
||||
last_event_at = asyncio.get_event_loop().time()
|
||||
unchanged_count = 0
|
||||
except Exception as exc:
|
||||
logger.warning("ws event error: %s", exc)
|
||||
try:
|
||||
@@ -194,17 +211,22 @@ async def session_ws(
|
||||
|
||||
# Task: push screenshots
|
||||
async def push_loop():
|
||||
nonlocal last_frame_hash
|
||||
nonlocal last_frame_hash, unchanged_count
|
||||
try:
|
||||
while True:
|
||||
now = asyncio.get_event_loop().time()
|
||||
# Faster cadence right after a user interaction
|
||||
interval = _WS_ACTIVE_INTERVAL if (now - last_event_at) < 1.0 else _WS_IDLE_INTERVAL
|
||||
if (now - last_event_at) < _WS_ACTIVE_WINDOW:
|
||||
interval = _WS_ACTIVE_INTERVAL
|
||||
elif unchanged_count >= 9:
|
||||
interval = _WS_DEEP_IDLE_INTERVAL
|
||||
elif unchanged_count >= 3:
|
||||
interval = _WS_BACKOFF_INTERVAL
|
||||
else:
|
||||
interval = _WS_IDLE_INTERVAL
|
||||
|
||||
try:
|
||||
frame = await browser_sessions.screenshot(session_id)
|
||||
except KeyError:
|
||||
# Session gone
|
||||
await websocket.send_json({"error": "session_not_found"})
|
||||
break
|
||||
except Exception as exc:
|
||||
@@ -212,14 +234,16 @@ async def session_ws(
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
|
||||
# Only push if content changed
|
||||
frame_hash = hashlib.md5(frame).hexdigest()
|
||||
if frame_hash != last_frame_hash:
|
||||
last_frame_hash = frame_hash
|
||||
unchanged_count = 0
|
||||
try:
|
||||
await websocket.send_bytes(frame)
|
||||
except Exception:
|
||||
break
|
||||
else:
|
||||
unchanged_count += 1
|
||||
|
||||
await asyncio.sleep(max(_WS_MIN_INTERVAL, interval))
|
||||
except (WebSocketDisconnect, asyncio.CancelledError):
|
||||
|
||||
@@ -121,6 +121,8 @@ def update_upstream(
|
||||
data["base_url"] = data["base_url"].rstrip("/")
|
||||
for k, v in data.items():
|
||||
setattr(u, k, v)
|
||||
# Reset failure counter on any update — the user may have fixed the issue
|
||||
u.consecutive_failures = 0
|
||||
u.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(u)
|
||||
@@ -144,19 +146,29 @@ def test_upstream(uid: int, db: Session = Depends(get_db), _=Depends(get_current
|
||||
if not u:
|
||||
raise HTTPException(404, "upstream not found")
|
||||
auth_config = json.loads(u.auth_config_json or "{}")
|
||||
client = UpstreamClient(
|
||||
with UpstreamClient(
|
||||
base_url=u.base_url,
|
||||
api_prefix=u.api_prefix,
|
||||
auth_type=u.auth_type,
|
||||
auth_config=auth_config,
|
||||
timeout=float(u.timeout_seconds),
|
||||
)
|
||||
try:
|
||||
client.login()
|
||||
groups = client.get_available_groups(u.groups_endpoint)
|
||||
return TestResult(success=True, message=f"连接成功,获取到 {len(groups)} 个分组")
|
||||
except Exception as exc:
|
||||
return TestResult(success=False, message="连接失败", detail=str(exc))
|
||||
) as client:
|
||||
try:
|
||||
client.login()
|
||||
groups = client.get_available_groups(u.groups_endpoint)
|
||||
u.last_status = "healthy"
|
||||
u.last_error = None
|
||||
u.last_checked_at = datetime.now(timezone.utc)
|
||||
u.consecutive_failures = 0
|
||||
db.commit()
|
||||
return TestResult(success=True, message=f"连接成功,获取到 {len(groups)} 个分组")
|
||||
except Exception as exc:
|
||||
u.last_status = "unhealthy"
|
||||
u.last_error = str(exc)
|
||||
u.last_checked_at = datetime.now(timezone.utc)
|
||||
u.consecutive_failures = (u.consecutive_failures or 0) + 1
|
||||
db.commit()
|
||||
return TestResult(success=False, message="连接失败", detail=str(exc))
|
||||
|
||||
|
||||
@router.post("/{uid}/check-now", response_model=TestResult)
|
||||
@@ -165,24 +177,24 @@ def check_now(uid: int, db: Session = Depends(get_db), _=Depends(get_current_use
|
||||
if not u:
|
||||
raise HTTPException(404, "upstream not found")
|
||||
auth_config = json.loads(u.auth_config_json or "{}")
|
||||
client = UpstreamClient(
|
||||
with UpstreamClient(
|
||||
base_url=u.base_url,
|
||||
api_prefix=u.api_prefix,
|
||||
auth_type=u.auth_type,
|
||||
auth_config=auth_config,
|
||||
timeout=float(u.timeout_seconds),
|
||||
)
|
||||
try:
|
||||
client.login()
|
||||
groups = client.get_available_groups(u.groups_endpoint)
|
||||
raw_rates = client.get_group_rates(u.rate_endpoint)
|
||||
snapshot = build_snapshot(u.id, u.base_url, u.api_prefix, groups, raw_rates)
|
||||
except Exception as exc:
|
||||
u.consecutive_failures = (u.consecutive_failures or 0) + 1
|
||||
u.last_error = str(exc)
|
||||
u.last_checked_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return TestResult(success=False, message="检测失败", detail=str(exc))
|
||||
) as client:
|
||||
try:
|
||||
client.login()
|
||||
groups = client.get_available_groups(u.groups_endpoint)
|
||||
raw_rates = client.get_group_rates(u.rate_endpoint)
|
||||
snapshot = build_snapshot(u.id, u.base_url, u.api_prefix, groups, raw_rates)
|
||||
except Exception as exc:
|
||||
u.consecutive_failures = (u.consecutive_failures or 0) + 1
|
||||
u.last_error = str(exc)
|
||||
u.last_checked_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return TestResult(success=False, message="检测失败", detail=str(exc))
|
||||
|
||||
prev_row = (
|
||||
db.query(UpstreamRateSnapshot)
|
||||
|
||||
@@ -176,6 +176,8 @@ def delete_website(wid: int, db: Session = Depends(get_db), _=Depends(get_curren
|
||||
row = db.query(Website).filter(Website.id == wid).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "website not found")
|
||||
db.query(WebsiteSyncLog).filter(WebsiteSyncLog.website_id == wid).delete(synchronize_session=False)
|
||||
db.query(WebsiteGroupBinding).filter(WebsiteGroupBinding.website_id == wid).delete(synchronize_session=False)
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
|
||||
@@ -186,7 +188,8 @@ def test_website(wid: int, db: Session = Depends(get_db), _=Depends(get_current_
|
||||
if not row:
|
||||
raise HTTPException(404, "website not found")
|
||||
try:
|
||||
groups = _client(row).get_groups(row.groups_endpoint)
|
||||
with _client(row) as c:
|
||||
groups = c.get_groups(row.groups_endpoint)
|
||||
row.last_status = "healthy"
|
||||
row.last_error = None
|
||||
row.last_checked_at = datetime.now(timezone.utc)
|
||||
@@ -206,7 +209,8 @@ def list_website_groups(wid: int, db: Session = Depends(get_db), _=Depends(get_c
|
||||
if not row:
|
||||
raise HTTPException(404, "website not found")
|
||||
try:
|
||||
return _client(row).get_groups(row.groups_endpoint)
|
||||
with _client(row) as c:
|
||||
return c.get_groups(row.groups_endpoint)
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, str(exc))
|
||||
|
||||
|
||||
@@ -98,9 +98,16 @@ class BrowserSessionService:
|
||||
session = self._get(session_id)
|
||||
async with session.lock:
|
||||
self._ensure_open(session)
|
||||
return await session.page.screenshot(type="jpeg", quality=78, full_page=False)
|
||||
return await session.page.screenshot(type="jpeg", quality=65, full_page=False)
|
||||
|
||||
async def event(self, session_id: str, event_type: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
async def event(
|
||||
self,
|
||||
session_id: str,
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
include_state: bool = True,
|
||||
) -> dict[str, Any] | None:
|
||||
session = self._get(session_id)
|
||||
async with session.lock:
|
||||
self._ensure_open(session)
|
||||
@@ -141,8 +148,17 @@ class BrowserSessionService:
|
||||
await page.set_viewport_size({"width": width, "height": height})
|
||||
else:
|
||||
raise ValueError("Unsupported browser event")
|
||||
if not include_state:
|
||||
return None
|
||||
return await self._session_state(session)
|
||||
|
||||
async def selected_text(self, session_id: str) -> str:
|
||||
session = self._get(session_id)
|
||||
async with session.lock:
|
||||
self._ensure_open(session)
|
||||
value = await session.page.evaluate("() => window.getSelection()?.toString() || ''")
|
||||
return str(value or "")
|
||||
|
||||
async def close(self, session_id: str) -> None:
|
||||
session = self._discard_session(session_id)
|
||||
if not session:
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from apscheduler.executors.pool import ThreadPoolExecutor
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -12,14 +13,14 @@ from app.database import SessionLocal
|
||||
from app.models.upstream import Upstream
|
||||
from app.models.snapshot import UpstreamRateSnapshot
|
||||
from app.services.upstream_client import UpstreamClient, UpstreamError, build_snapshot
|
||||
from app.services.snapshot_service import diff_snapshots
|
||||
from app.services.snapshot_service import diff_snapshots, prune_snapshots
|
||||
from app.services import webhook_service
|
||||
from app.services import website_sync
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_scheduler = BackgroundScheduler(timezone="UTC")
|
||||
_scheduler = BackgroundScheduler(timezone="UTC", executors={"default": ThreadPoolExecutor(max_workers=1)})
|
||||
|
||||
|
||||
def get_scheduler() -> BackgroundScheduler:
|
||||
@@ -27,8 +28,14 @@ def get_scheduler() -> BackgroundScheduler:
|
||||
|
||||
|
||||
def _check_upstream(upstream_id: int) -> None:
|
||||
"""Full upstream check executed by scheduler (runs in thread)."""
|
||||
"""Full upstream check executed by scheduler (runs in thread).
|
||||
|
||||
Phase 1 — upstream API call + snapshot write (single transaction).
|
||||
Phase 2 — webhook/website sync (separate sessions, so a notification
|
||||
failure never rolls back the snapshot).
|
||||
"""
|
||||
settings = get_settings()
|
||||
# ── Phase 1: upstream check + DB write ──────────────────────────
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
upstream = db.query(Upstream).filter(Upstream.id == upstream_id).first()
|
||||
@@ -37,42 +44,45 @@ def _check_upstream(upstream_id: int) -> None:
|
||||
return
|
||||
|
||||
auth_config = json.loads(upstream.auth_config_json or "{}")
|
||||
client = UpstreamClient(
|
||||
was_unhealthy = upstream.last_status == "unhealthy"
|
||||
snapshot = None
|
||||
changes = None
|
||||
|
||||
with UpstreamClient(
|
||||
base_url=upstream.base_url,
|
||||
api_prefix=upstream.api_prefix,
|
||||
auth_type=upstream.auth_type,
|
||||
auth_config=auth_config,
|
||||
timeout=float(upstream.timeout_seconds),
|
||||
)
|
||||
|
||||
was_unhealthy = upstream.last_status == "unhealthy"
|
||||
|
||||
try:
|
||||
client.login()
|
||||
groups = client.get_available_groups(upstream.groups_endpoint)
|
||||
raw_rates = client.get_group_rates(upstream.rate_endpoint)
|
||||
snapshot = build_snapshot(
|
||||
upstream.id, upstream.base_url, upstream.api_prefix, groups, raw_rates
|
||||
)
|
||||
except Exception as exc:
|
||||
# failure path
|
||||
upstream.consecutive_failures = (upstream.consecutive_failures or 0) + 1
|
||||
upstream.last_error = str(exc)
|
||||
upstream.last_checked_at = datetime.now(timezone.utc)
|
||||
threshold = settings.unhealthy_threshold
|
||||
if upstream.consecutive_failures >= threshold and upstream.last_status != "unhealthy":
|
||||
upstream.last_status = "unhealthy"
|
||||
db.commit()
|
||||
webhook_service.send_status_event(
|
||||
db, upstream.id, upstream.name, upstream.base_url,
|
||||
"upstream_unhealthy", str(exc)
|
||||
) as client:
|
||||
try:
|
||||
client.login()
|
||||
groups = client.get_available_groups(upstream.groups_endpoint)
|
||||
raw_rates = client.get_group_rates(upstream.rate_endpoint)
|
||||
snapshot = build_snapshot(
|
||||
upstream.id, upstream.base_url, upstream.api_prefix, groups, raw_rates
|
||||
)
|
||||
else:
|
||||
except Exception as exc:
|
||||
# failure path
|
||||
upstream.consecutive_failures = (upstream.consecutive_failures or 0) + 1
|
||||
upstream.last_error = str(exc)
|
||||
upstream.last_checked_at = datetime.now(timezone.utc)
|
||||
threshold = settings.unhealthy_threshold
|
||||
became_unhealthy = (
|
||||
upstream.consecutive_failures >= threshold
|
||||
and upstream.last_status != "unhealthy"
|
||||
)
|
||||
if became_unhealthy:
|
||||
upstream.last_status = "unhealthy"
|
||||
db.commit()
|
||||
logger.warning("upstream %s check failed: %s", upstream.name, exc)
|
||||
return
|
||||
logger.warning("upstream %s check failed: %s", upstream.name, exc)
|
||||
# Phase 2: notify unhealthy in a fresh session
|
||||
if became_unhealthy:
|
||||
_notify_status(upstream.id, upstream.name, upstream.base_url,
|
||||
"upstream_unhealthy", str(exc))
|
||||
return
|
||||
|
||||
# success path
|
||||
# success path (client auto-closed by `with`)
|
||||
prev_snapshot_row = (
|
||||
db.query(UpstreamRateSnapshot)
|
||||
.filter(UpstreamRateSnapshot.upstream_id == upstream_id)
|
||||
@@ -89,6 +99,7 @@ def _check_upstream(upstream_id: int) -> None:
|
||||
captured_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(new_row)
|
||||
prune_snapshots(db, upstream_id, settings.snapshot_retention_count)
|
||||
|
||||
# update upstream status
|
||||
upstream.last_status = "healthy"
|
||||
@@ -97,20 +108,60 @@ def _check_upstream(upstream_id: int) -> None:
|
||||
upstream.consecutive_failures = 0
|
||||
db.commit()
|
||||
|
||||
if was_unhealthy:
|
||||
webhook_service.send_status_event(
|
||||
db, upstream.id, upstream.name, upstream.base_url, "upstream_recovered"
|
||||
)
|
||||
logger.info(
|
||||
"upstream %s: %d rate change(s)" if changes else "upstream %s: no changes",
|
||||
upstream.name, len(changes) if changes else 0,
|
||||
)
|
||||
|
||||
if changes:
|
||||
webhook_service.send_rate_changed(
|
||||
db, upstream.id, upstream.name, upstream.base_url, changes
|
||||
)
|
||||
website_sync.sync_affected_bindings(db, upstream.id, changes)
|
||||
logger.info("upstream %s: %d rate change(s)", upstream.name, len(changes))
|
||||
else:
|
||||
logger.debug("upstream %s: no changes", upstream.name)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# ── Phase 2: notifications (independent sessions) ──────────────
|
||||
if was_unhealthy:
|
||||
_notify_status(upstream_id, upstream.name, upstream.base_url, "upstream_recovered")
|
||||
|
||||
if changes:
|
||||
_notify_rate_changed(upstream_id, upstream.name, upstream.base_url, changes)
|
||||
_sync_website_bindings(upstream_id, changes)
|
||||
|
||||
|
||||
def _notify_status(
|
||||
upstream_id: int,
|
||||
upstream_name: str,
|
||||
base_url: str,
|
||||
event: str,
|
||||
error: str = "",
|
||||
) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
webhook_service.send_status_event(db, upstream_id, upstream_name, base_url, event, error)
|
||||
except Exception:
|
||||
logger.exception("status webhook failed for upstream %s", upstream_name)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _notify_rate_changed(
|
||||
upstream_id: int,
|
||||
upstream_name: str,
|
||||
base_url: str,
|
||||
changes: list[dict[str, Any]],
|
||||
) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
webhook_service.send_rate_changed(db, upstream_id, upstream_name, base_url, changes)
|
||||
except Exception:
|
||||
logger.exception("rate webhook failed for upstream %s", upstream_name)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _sync_website_bindings(upstream_id: int, changes: list[dict[str, Any]]) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
website_sync.sync_affected_bindings(db, upstream_id, changes)
|
||||
except Exception:
|
||||
logger.exception("website sync failed for upstream %s", upstream_id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -155,4 +206,4 @@ def start_scheduler() -> None:
|
||||
|
||||
def stop_scheduler() -> None:
|
||||
if _scheduler.running:
|
||||
_scheduler.shutdown(wait=False)
|
||||
_scheduler.shutdown(wait=True)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"""Snapshot diff logic."""
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.snapshot import UpstreamRateSnapshot
|
||||
|
||||
|
||||
def diff_snapshots(
|
||||
previous: Optional[dict[str, Any]],
|
||||
@@ -37,3 +41,20 @@ def diff_snapshots(
|
||||
"new_rate": None,
|
||||
})
|
||||
return changes
|
||||
|
||||
|
||||
def prune_snapshots(db: Session, upstream_id: int, keep: int) -> None:
|
||||
if keep <= 0:
|
||||
return
|
||||
stale_ids = [
|
||||
row_id
|
||||
for (row_id,) in (
|
||||
db.query(UpstreamRateSnapshot.id)
|
||||
.filter(UpstreamRateSnapshot.upstream_id == upstream_id)
|
||||
.order_by(UpstreamRateSnapshot.captured_at.desc(), UpstreamRateSnapshot.id.desc())
|
||||
.offset(keep)
|
||||
.all()
|
||||
)
|
||||
]
|
||||
if stale_ids:
|
||||
db.query(UpstreamRateSnapshot).filter(UpstreamRateSnapshot.id.in_(stale_ids)).delete(synchronize_session=False)
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urljoin
|
||||
import httpx
|
||||
|
||||
from app.utils.number import decimal_string
|
||||
|
||||
|
||||
class UpstreamError(RuntimeError):
|
||||
pass
|
||||
@@ -66,19 +67,6 @@ def _unwrap_list(value: Any) -> Optional[list[dict[str, Any]]]:
|
||||
return None
|
||||
|
||||
|
||||
def _decimal_str(value: Any) -> str:
|
||||
if value is None or value == "":
|
||||
return ""
|
||||
try:
|
||||
d = Decimal(str(value))
|
||||
except (InvalidOperation, ValueError):
|
||||
return str(value)
|
||||
n = d.normalize()
|
||||
if n == n.to_integral():
|
||||
return str(n.quantize(Decimal("1")))
|
||||
return format(n, "f")
|
||||
|
||||
|
||||
def _group_id(group: dict[str, Any]) -> str:
|
||||
for key in ("id", "group_id", "groupId"):
|
||||
v = group.get(key)
|
||||
@@ -95,7 +83,7 @@ def _rate_from_group(group: dict[str, Any]) -> str:
|
||||
"effective_rate_multiplier", "effectiveRateMultiplier",
|
||||
"rate_multiplier", "rateMultiplier",
|
||||
):
|
||||
r = _decimal_str(group.get(key))
|
||||
r = decimal_string(group.get(key))
|
||||
if r:
|
||||
return r
|
||||
return ""
|
||||
@@ -117,7 +105,7 @@ def _extract_rates_map(raw: Any) -> dict[str, str]:
|
||||
if isinstance(parsed, dict):
|
||||
result: dict[str, str] = {}
|
||||
for k, v in parsed.items():
|
||||
r = _decimal_str(v)
|
||||
r = decimal_string(v)
|
||||
if r:
|
||||
result[str(k)] = r
|
||||
return result
|
||||
@@ -127,7 +115,7 @@ def _extract_rates_map(raw: Any) -> dict[str, str]:
|
||||
# In case it's returned as dict directly
|
||||
result = {}
|
||||
for k, v in val.items():
|
||||
r = _decimal_str(v)
|
||||
r = decimal_string(v)
|
||||
if r:
|
||||
result[str(k)] = r
|
||||
return result
|
||||
@@ -153,13 +141,13 @@ def _extract_rates_map(raw: Any) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for k, v in candidates.items():
|
||||
if isinstance(v, dict):
|
||||
r = _decimal_str(
|
||||
r = decimal_string(
|
||||
v.get("rate_multiplier") or v.get("rateMultiplier")
|
||||
or v.get("user_rate_multiplier") or v.get("userRateMultiplier")
|
||||
or v.get("ratio")
|
||||
)
|
||||
else:
|
||||
r = _decimal_str(v)
|
||||
r = decimal_string(v)
|
||||
if r:
|
||||
result[str(k)] = r
|
||||
return result
|
||||
@@ -221,6 +209,16 @@ class UpstreamClient:
|
||||
self._token: str = ""
|
||||
self._cookies: dict[str, str] = {}
|
||||
self._new_api_user: str = ""
|
||||
self._client = httpx.Client(timeout=timeout)
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def __enter__(self) -> UpstreamClient:
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: Any) -> None:
|
||||
self.close()
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
prefix = f"/{self.api_prefix}" if self.api_prefix else ""
|
||||
@@ -250,22 +248,21 @@ class UpstreamClient:
|
||||
|
||||
def _request(self, method: str, path: str, body: Any = None, auth: bool = True) -> Any:
|
||||
url = self._url(path)
|
||||
with httpx.Client(timeout=self.timeout) as client:
|
||||
if body is not None:
|
||||
resp = client.request(
|
||||
method,
|
||||
url,
|
||||
json=body,
|
||||
headers=self._headers(auth),
|
||||
cookies=self._cookies,
|
||||
)
|
||||
else:
|
||||
resp = client.request(
|
||||
method,
|
||||
url,
|
||||
headers=self._headers(auth),
|
||||
cookies=self._cookies,
|
||||
)
|
||||
if body is not None:
|
||||
resp = self._client.request(
|
||||
method,
|
||||
url,
|
||||
json=body,
|
||||
headers=self._headers(auth),
|
||||
cookies=self._cookies,
|
||||
)
|
||||
else:
|
||||
resp = self._client.request(
|
||||
method,
|
||||
url,
|
||||
headers=self._headers(auth),
|
||||
cookies=self._cookies,
|
||||
)
|
||||
self._cookies.update(dict(resp.cookies))
|
||||
resp.raise_for_status()
|
||||
ct = resp.headers.get("content-type", "")
|
||||
|
||||
@@ -6,24 +6,13 @@ from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from app.utils.number import decimal_string
|
||||
|
||||
|
||||
class WebsiteError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def decimal_string(value: Any) -> str:
|
||||
if value is None or value == "":
|
||||
return ""
|
||||
try:
|
||||
d = Decimal(str(value))
|
||||
except (InvalidOperation, ValueError):
|
||||
return str(value)
|
||||
n = d.normalize()
|
||||
if n == n.to_integral():
|
||||
return str(n.quantize(Decimal("1")))
|
||||
return format(n, "f")
|
||||
|
||||
|
||||
def parse_positive_decimal(value: Any) -> Decimal | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
@@ -111,6 +100,16 @@ class Sub2ApiWebsiteClient:
|
||||
self.auth_type = auth_type
|
||||
self.auth_config = auth_config
|
||||
self.timeout = timeout
|
||||
self._client = httpx.Client(timeout=timeout)
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def __enter__(self) -> Sub2ApiWebsiteClient:
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: Any) -> None:
|
||||
self.close()
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
prefix = f"/{self.api_prefix}" if self.api_prefix else ""
|
||||
@@ -130,8 +129,7 @@ class Sub2ApiWebsiteClient:
|
||||
return headers
|
||||
|
||||
def _request(self, method: str, path: str, body: Any = None) -> Any:
|
||||
with httpx.Client(timeout=self.timeout) as client:
|
||||
resp = client.request(method, self._url(path), json=body, headers=self._headers())
|
||||
resp = self._client.request(method, self._url(path), json=body, headers=self._headers())
|
||||
resp.raise_for_status()
|
||||
if not resp.content:
|
||||
return None
|
||||
|
||||
@@ -118,11 +118,11 @@ def sync_binding(db: Session, binding: WebsiteGroupBinding, write: bool = True)
|
||||
old_rate = None
|
||||
if write and website.enabled and website.auto_sync_enabled and binding.enabled:
|
||||
try:
|
||||
client = _client_for(website)
|
||||
groups = client.get_groups(website.groups_endpoint)
|
||||
target = next((item for item in groups if item.get("id") == binding.target_group_id), None)
|
||||
old_rate = target.get("rate_multiplier") if target else None
|
||||
client.update_group_rate(website.group_update_endpoint, binding.target_group_id, target_rate)
|
||||
with _client_for(website) as client:
|
||||
groups = client.get_groups(website.groups_endpoint)
|
||||
target = next((item for item in groups if item.get("id") == binding.target_group_id), None)
|
||||
old_rate = target.get("rate_multiplier") if target else None
|
||||
client.update_group_rate(website.group_update_endpoint, binding.target_group_id, target_rate)
|
||||
website.last_status = "healthy"
|
||||
website.last_error = None
|
||||
except Exception as exc:
|
||||
|
||||
+34
-12
@@ -1,25 +1,37 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from jose import JWTError, jwt
|
||||
import bcrypt
|
||||
from fastapi import Depends, HTTPException, Query, Request, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.models.revoked_token import RevokedToken
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
BCRYPT_MAX_PASSWORD_BYTES = 72
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def validate_password_supported(password: str) -> None:
|
||||
if len(password.encode("utf-8")) > BCRYPT_MAX_PASSWORD_BYTES:
|
||||
raise ValueError("password must be at most 72 bytes when UTF-8 encoded")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
pw = password.encode("utf-8")[:72]
|
||||
validate_password_supported(password)
|
||||
pw = password.encode("utf-8")
|
||||
return bcrypt.hashpw(pw, bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
pw = plain.encode("utf-8")[:72]
|
||||
validate_password_supported(plain)
|
||||
pw = plain.encode("utf-8")
|
||||
return bcrypt.checkpw(pw, hashed.encode("utf-8"))
|
||||
|
||||
|
||||
@@ -27,19 +39,29 @@ def create_access_token(email: str, expires_hours: Optional[int] = None) -> str:
|
||||
settings = get_settings()
|
||||
hours = expires_hours or settings.jwt_expire_hours
|
||||
expire = datetime.now(timezone.utc) + timedelta(hours=hours)
|
||||
data = {"sub": email, "exp": expire}
|
||||
data = {"sub": email, "exp": expire, "jti": uuid4().hex}
|
||||
return jwt.encode(data, settings.jwt_secret, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[str]:
|
||||
def decode_token_payload(token: str) -> Optional[dict]:
|
||||
settings = get_settings()
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
|
||||
return payload.get("sub")
|
||||
return jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[str]:
|
||||
payload = decode_token_payload(token)
|
||||
return payload.get("sub") if payload else None
|
||||
|
||||
|
||||
def _is_revoked(db: Session, jti: str | None) -> bool:
|
||||
if not jti:
|
||||
return True
|
||||
return db.query(RevokedToken).filter(RevokedToken.jti == jti).first() is not None
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -47,10 +69,10 @@ def get_current_user(
|
||||
token = credentials.credentials if credentials else None
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
email = decode_token(token)
|
||||
if not email:
|
||||
payload = decode_token_payload(token)
|
||||
if not payload or _is_revoked(db, payload.get("jti")):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
user = db.query(AdminUser).filter(AdminUser.email == email).first()
|
||||
user = db.query(AdminUser).filter(AdminUser.email == payload.get("sub")).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
return user
|
||||
@@ -65,10 +87,10 @@ def get_user_from_token_param(
|
||||
raw = token or (credentials.credentials if credentials else None)
|
||||
if not raw:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
email = decode_token(raw)
|
||||
if not email:
|
||||
payload = decode_token_payload(raw)
|
||||
if not payload or _is_revoked(db, payload.get("jti")):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
user = db.query(AdminUser).filter(AdminUser.email == email).first()
|
||||
user = db.query(AdminUser).filter(AdminUser.email == payload.get("sub")).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
return user
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Shared numeric formatting utilities."""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
|
||||
def decimal_string(value: Any) -> str:
|
||||
"""Format a numeric value as a clean decimal string.
|
||||
|
||||
- None / empty → ""
|
||||
- Whole numbers → no decimal point (e.g. "5")
|
||||
- Decimals → trailing zeros stripped (e.g. "3.14")
|
||||
- Unparseable → raw str()
|
||||
"""
|
||||
if value is None or value == "":
|
||||
return ""
|
||||
try:
|
||||
d = Decimal(str(value))
|
||||
except (InvalidOperation, ValueError):
|
||||
return str(value)
|
||||
n = d.normalize()
|
||||
if n == n.to_integral():
|
||||
return str(n.quantize(Decimal("1")))
|
||||
return format(n, "f")
|
||||
+29
-22
@@ -6,31 +6,38 @@ from app.services.upstream_client import UpstreamClient
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
def main():
|
||||
client = UpstreamClient(
|
||||
base_url="http://170.106.100.210:55555",
|
||||
api_prefix="",
|
||||
auth_type="bearer",
|
||||
auth_config={"token": ""}, # We don't have token, but /api/group/ in some new-api may be open, or fail with 401
|
||||
# Usage: python test_upstream.py <base_url> [api_prefix] [auth_type] [token]
|
||||
# Example: python test_upstream.py http://localhost:8000 "" bearer "sk-xxx"
|
||||
import sys
|
||||
base_url = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8000"
|
||||
api_prefix = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||
auth_type = sys.argv[3] if len(sys.argv) > 3 else "bearer"
|
||||
token = sys.argv[4] if len(sys.argv) > 4 else ""
|
||||
with UpstreamClient(
|
||||
base_url=base_url,
|
||||
api_prefix=api_prefix,
|
||||
auth_type=auth_type,
|
||||
auth_config={"token": token},
|
||||
timeout=10.0,
|
||||
)
|
||||
try:
|
||||
groups = client.get_available_groups("/api/group/")
|
||||
print("Groups:", groups)
|
||||
except Exception as e:
|
||||
print("Groups Error:", e)
|
||||
) as client:
|
||||
try:
|
||||
groups = client.get_available_groups("/api/group/")
|
||||
print("Groups:", groups)
|
||||
except Exception as e:
|
||||
print("Groups Error:", e)
|
||||
|
||||
try:
|
||||
rates = client.get_group_rates("/api/option/?key=GroupRatio")
|
||||
print("Rates:", rates)
|
||||
except Exception as e:
|
||||
print("Rates Error:", e)
|
||||
try:
|
||||
rates = client.get_group_rates("/api/option/?key=GroupRatio")
|
||||
print("Rates:", rates)
|
||||
except Exception as e:
|
||||
print("Rates Error:", e)
|
||||
|
||||
try:
|
||||
from app.services.upstream_client import _extract_rates_map, _unwrap_list
|
||||
print("Unwrapped Groups:", _unwrap_list(groups))
|
||||
print("Extracted Rates:", _extract_rates_map(rates))
|
||||
except Exception as e:
|
||||
pass
|
||||
try:
|
||||
from app.services.upstream_client import _extract_rates_map, _unwrap_list
|
||||
print("Unwrapped Groups:", _unwrap_list(groups))
|
||||
print("Extracted Rates:", _extract_rates_map(rates))
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
+6
-3
@@ -7,17 +7,20 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${BIND_HOST:-0.0.0.0}:${SERVER_PORT:-8899}:8000"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@smartup.local}
|
||||
- ADMIN_EMAIL=admin@smartup.local
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:?ADMIN_PASSWORD is required}
|
||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:8899,http://127.0.0.1:8899}
|
||||
- DATABASE_URL=sqlite:////app/data/app.db
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- UNHEALTHY_THRESHOLD=${UNHEALTHY_THRESHOLD:-3}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz', timeout=5).read()"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/healthz"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
Generated
+25
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.7.9",
|
||||
"axios-retry": "^4.5.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"element-plus": "^2.8.8",
|
||||
"pinia": "^2.2.6",
|
||||
@@ -1202,6 +1203,18 @@
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios-retry": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/axios-retry/-/axios-retry-4.5.0.tgz",
|
||||
"integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"is-retry-allowed": "^2.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"axios": "0.x || 1.x"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -1593,6 +1606,18 @@
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/is-retry-allowed": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz",
|
||||
"integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
|
||||
|
||||
@@ -8,13 +8,14 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.4.5",
|
||||
"pinia": "^2.2.6",
|
||||
"element-plus": "^2.8.8",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.7.9",
|
||||
"dayjs": "^1.11.13"
|
||||
"axios-retry": "^4.5.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"element-plus": "^2.8.8",
|
||||
"pinia": "^2.2.6",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
|
||||
@@ -1,17 +1,35 @@
|
||||
import axios from 'axios'
|
||||
import axiosRetry from 'axios-retry'
|
||||
import router from '@/router'
|
||||
import { authStorageKeys } from '@/authStorage'
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: '/',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
axiosRetry(api, {
|
||||
retries: 3,
|
||||
retryDelay: axiosRetry.exponentialDelay,
|
||||
onRetry: (_retryCount, _err, _requestConfig) => {
|
||||
// no-op — could log in dev
|
||||
},
|
||||
// Only retry idempotent methods — never retry POST/PUT/PATCH/DELETE
|
||||
retryCondition: (err) => {
|
||||
const method = (err.config?.method ?? '').toUpperCase()
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) return false
|
||||
// Retry on network errors or 5xx, but never on 401/403/404/4xx
|
||||
if (!err.response) return true
|
||||
return err.response.status >= 500 && err.response.status < 600
|
||||
},
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(r) => r,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('smartup_token')
|
||||
localStorage.removeItem('smartup_email')
|
||||
localStorage.removeItem(authStorageKeys.token)
|
||||
localStorage.removeItem(authStorageKeys.email)
|
||||
router.push('/login')
|
||||
}
|
||||
return Promise.reject(err)
|
||||
@@ -293,6 +311,7 @@ export const browserSessionsApi = {
|
||||
get: (id: string) => api.get<BrowserSessionData>(`/api/browser-sessions/${id}`),
|
||||
event: (id: string, data: BrowserEventPayload) =>
|
||||
api.post<BrowserSessionData>(`/api/browser-sessions/${id}/events`, data),
|
||||
selection: (id: string) => api.get<{ text: string }>(`/api/browser-sessions/${id}/selection`),
|
||||
close: (id: string) => api.delete(`/api/browser-sessions/${id}`),
|
||||
screenshotUrl: (id: string, token?: string) => {
|
||||
const params = new URLSearchParams({ t: String(Date.now()) })
|
||||
|
||||
@@ -91,6 +91,11 @@ img {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.shell-page.shell-page-fluid {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.page-section {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
const appKey = import.meta.env.VITE_APP_KEY || location.pathname.replace(/\W+/g, '_') || 'smartup'
|
||||
const prefix = `smartup_${appKey}`
|
||||
|
||||
export const authStorageKeys = {
|
||||
token: `${prefix}_token`,
|
||||
email: `${prefix}_email`,
|
||||
}
|
||||
@@ -19,13 +19,6 @@
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-title">监控中枢</div>
|
||||
<nav class="sidebar-nav">
|
||||
<router-link to="/upstreams" class="nav-item" active-class="active" @click="closeMobileNav">
|
||||
<span class="nav-icon"><el-icon><Connection /></el-icon></span>
|
||||
<span class="nav-copy">
|
||||
<strong>上游管理</strong>
|
||||
<small>轮询、健康度、倍率快照</small>
|
||||
</span>
|
||||
</router-link>
|
||||
<router-link to="/websites" class="nav-item" active-class="active" @click="closeMobileNav">
|
||||
<span class="nav-icon"><el-icon><OfficeBuilding /></el-icon></span>
|
||||
<span class="nav-copy">
|
||||
@@ -33,6 +26,13 @@
|
||||
<small>目标站点、分组映射、自动同步</small>
|
||||
</span>
|
||||
</router-link>
|
||||
<router-link to="/upstreams" class="nav-item" active-class="active" @click="closeMobileNav">
|
||||
<span class="nav-icon"><el-icon><Connection /></el-icon></span>
|
||||
<span class="nav-copy">
|
||||
<strong>上游管理</strong>
|
||||
<small>轮询、健康度、倍率快照</small>
|
||||
</span>
|
||||
</router-link>
|
||||
<router-link to="/webhooks" class="nav-item" active-class="active" @click="closeMobileNav">
|
||||
<span class="nav-icon"><el-icon><Bell /></el-icon></span>
|
||||
<span class="nav-copy">
|
||||
@@ -531,11 +531,6 @@ watch([() => route.path, customPages], () => {
|
||||
background: rgba(25, 19, 16, 0.7);
|
||||
}
|
||||
|
||||
.topbar:not(.compact) {
|
||||
width: min(100%, var(--content-max));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.topbar.compact {
|
||||
min-height: 2.8rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/api'
|
||||
import { authStorageKeys } from '@/authStorage'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string>(localStorage.getItem('smartup_token') || '')
|
||||
const email = ref<string>(localStorage.getItem('smartup_email') || '')
|
||||
const token = ref<string>(localStorage.getItem(authStorageKeys.token) || '')
|
||||
const email = ref<string>(localStorage.getItem(authStorageKeys.email) || '')
|
||||
|
||||
function setToken(t: string, e: string) {
|
||||
token.value = t
|
||||
email.value = e
|
||||
localStorage.setItem('smartup_token', t)
|
||||
localStorage.setItem('smartup_email', e)
|
||||
localStorage.setItem(authStorageKeys.token, t)
|
||||
localStorage.setItem(authStorageKeys.email, e)
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${t}`
|
||||
}
|
||||
|
||||
function clear() {
|
||||
token.value = ''
|
||||
email.value = ''
|
||||
localStorage.removeItem('smartup_token')
|
||||
localStorage.removeItem('smartup_email')
|
||||
localStorage.removeItem(authStorageKeys.token)
|
||||
localStorage.removeItem(authStorageKeys.email)
|
||||
delete api.defaults.headers.common['Authorization']
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
const form = ref({ email: 'admin@smartup.local', password: 'changeme123' })
|
||||
const form = ref({ email: '', password: '' })
|
||||
const rules = {
|
||||
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="shell-page page-section">
|
||||
<div class="shell-page shell-page-fluid page-section">
|
||||
<div class="page-header surface-card page-block">
|
||||
<div class="page-heading">
|
||||
<p class="page-kicker">Delivery Trace</p>
|
||||
@@ -7,11 +7,11 @@
|
||||
<p class="page-desc">查看所有 Webhook 通知的发送记录</p>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<el-select v-model="filterStatus" placeholder="状态" clearable style="width:110px" @change="loadList">
|
||||
<el-select v-model="filterStatus" placeholder="状态" clearable style="width:110px" @change="handleFilterChange">
|
||||
<el-option label="成功" value="success" />
|
||||
<el-option label="失败" value="failed" />
|
||||
</el-select>
|
||||
<el-select v-model="filterEvent" placeholder="事件类型" clearable style="width:150px" @change="loadList">
|
||||
<el-select v-model="filterEvent" placeholder="事件类型" clearable style="width:150px" @change="handleFilterChange">
|
||||
<el-option label="上游倍率变更" value="upstream_rate_changed" />
|
||||
<el-option label="网站倍率变更" value="website_rate_changed" />
|
||||
<el-option label="服务异常" value="upstream_unhealthy" />
|
||||
@@ -58,9 +58,13 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination">
|
||||
<el-button :disabled="offset === 0" @click="prevPage" size="small">上一页</el-button>
|
||||
<span class="page-info">第 {{ offset / limit + 1 }} 页</span>
|
||||
<el-button :disabled="list.length < limit" @click="nextPage" size="small">下一页</el-button>
|
||||
<div class="page-info">
|
||||
第 {{ currentPage }} 页 · 每页 {{ pageSize }} 条
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<el-button :disabled="offset === 0 || tableLoading" @click="prevPage" size="small">上一页</el-button>
|
||||
<el-button :disabled="!hasNextPage || tableLoading" @click="nextPage" size="small">下一页</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -91,7 +95,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { logsApi, type LogData } from '@/api'
|
||||
|
||||
@@ -102,7 +106,9 @@ const detailRow = ref<LogData | null>(null)
|
||||
const filterStatus = ref('')
|
||||
const filterEvent = ref('')
|
||||
const offset = ref(0)
|
||||
const limit = 50
|
||||
const pageSize = 50
|
||||
const hasNextPage = ref(false)
|
||||
const currentPage = computed(() => Math.floor(offset.value / pageSize) + 1)
|
||||
|
||||
const EVENT_LABELS: Record<string, string> = {
|
||||
upstream_rate_changed: '上游倍率变更',
|
||||
@@ -124,10 +130,11 @@ async function loadList() {
|
||||
const res = await logsApi.list({
|
||||
status: filterStatus.value || undefined,
|
||||
event_type: filterEvent.value || undefined,
|
||||
limit,
|
||||
limit: pageSize + 1,
|
||||
offset: offset.value,
|
||||
})
|
||||
list.value = res.data
|
||||
hasNextPage.value = res.data.length > pageSize
|
||||
list.value = res.data.slice(0, pageSize)
|
||||
} finally {
|
||||
tableLoading.value = false
|
||||
}
|
||||
@@ -138,12 +145,17 @@ function viewDetail(row: LogData) {
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
function handleFilterChange() {
|
||||
offset.value = 0
|
||||
loadList()
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
offset.value = Math.max(0, offset.value - limit)
|
||||
offset.value = Math.max(0, offset.value - pageSize)
|
||||
loadList()
|
||||
}
|
||||
function nextPage() {
|
||||
offset.value += limit
|
||||
offset.value += pageSize
|
||||
loadList()
|
||||
}
|
||||
|
||||
@@ -158,4 +170,18 @@ onMounted(loadList)
|
||||
.page-header {
|
||||
border-radius: var(--radius-shell);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
<el-icon><Back /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip v-if="isRemoteBrowser" content="复制远程选中文本">
|
||||
<el-button size="small" text @click="copyRemoteSelection">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip v-if="isRemoteBrowser" content="前进">
|
||||
<el-button size="small" text @click="sendRemoteCommand('forward')">
|
||||
<el-icon><Right /></el-icon>
|
||||
@@ -67,7 +72,7 @@
|
||||
class="remote-screen"
|
||||
alt=""
|
||||
draggable="false"
|
||||
@load="() => { iframeLoading = false }"
|
||||
@load="onRemoteImageLoad"
|
||||
@error="() => handleRemoteSessionFailure(undefined, '远程浏览器截图加载失败')"
|
||||
@pointerdown.stop.prevent="onRemotePointerDown"
|
||||
@pointermove.stop.prevent="onRemotePointerMove"
|
||||
@@ -126,7 +131,7 @@ import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, markRaw } f
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
ArrowLeft, TopRight, Refresh, Loading, Back, Right, Warning,
|
||||
ArrowLeft, TopRight, Refresh, Loading, Back, Right, Warning, DocumentCopy,
|
||||
Link as LinkIcon, Monitor, SetUp, Reading, Cpu, DataLine,
|
||||
Grid, Connection, Ticket, Wallet, Key, Tools, Star, House,
|
||||
} from '@element-plus/icons-vue'
|
||||
@@ -164,7 +169,12 @@ const isReconnectingRemoteBrowser = ref(false)
|
||||
const remoteErrorState = ref<RemoteBrowserErrorState | null>(null)
|
||||
let startRemoteBrowserPromise: Promise<void> | null = null
|
||||
let screenshotObjectUrl = ''
|
||||
let previousScreenshotObjectUrl = ''
|
||||
let pendingScreenshotBlob: Blob | null = null
|
||||
let screenshotFrameRequest: number | undefined
|
||||
let mouseMoveTimer: number | undefined
|
||||
let wheelTimer: number | undefined
|
||||
let pendingWheel: { deltaX: number; deltaY: number; x: number; y: number } | null = null
|
||||
let caretHideTimer: number | undefined
|
||||
|
||||
// Caret / cursor overlay
|
||||
@@ -183,8 +193,11 @@ let wsReconnectTimer: number | undefined
|
||||
let wsReconnectAttempts = 0
|
||||
const WS_MAX_RECONNECT = 5
|
||||
const WS_RECONNECT_BASE_MS = 800
|
||||
const REMOTE_DRAG_MOVE_INTERVAL_MS = 16
|
||||
const REMOTE_HOVER_MOVE_INTERVAL_MS = 80
|
||||
const WS_BACKPRESSURE_BYTES = 256 * 1024
|
||||
const REMOTE_DRAG_MOVE_INTERVAL_MS = 32
|
||||
const REMOTE_HOVER_MOVE_INTERVAL_MS = 120
|
||||
const REMOTE_WHEEL_INTERVAL_MS = 45
|
||||
const HIGH_FREQUENCY_EVENTS = new Set(['mousemove', 'scroll'])
|
||||
|
||||
type RemoteBrowserErrorState = {
|
||||
title: string
|
||||
@@ -318,10 +331,7 @@ function connectRemoteWs() {
|
||||
|
||||
socket.onmessage = (evt) => {
|
||||
if (evt.data instanceof Blob) {
|
||||
// Binary frame = JPEG screenshot
|
||||
const newUrl = URL.createObjectURL(evt.data)
|
||||
setRemoteScreenshotUrl(newUrl)
|
||||
iframeLoading.value = false
|
||||
queueRemoteScreenshot(evt.data)
|
||||
return
|
||||
}
|
||||
// Text frame = JSON control message
|
||||
@@ -368,6 +378,11 @@ function stopRemoteWs() {
|
||||
window.clearTimeout(wsReconnectTimer)
|
||||
wsReconnectTimer = undefined
|
||||
}
|
||||
if (wheelTimer !== undefined) {
|
||||
window.clearTimeout(wheelTimer)
|
||||
wheelTimer = undefined
|
||||
}
|
||||
pendingWheel = null
|
||||
if (ws) {
|
||||
// Prevent onclose from triggering reconnect
|
||||
const old = ws
|
||||
@@ -380,11 +395,13 @@ function stopRemoteWs() {
|
||||
async function sendRemoteEvent(payload: BrowserEventPayload) {
|
||||
if (!props.active || isStartingRemoteBrowser.value) return
|
||||
if (!remoteSession.value) return
|
||||
const highFrequency = HIGH_FREQUENCY_EVENTS.has(payload.type)
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
if (highFrequency && ws.bufferedAmount > WS_BACKPRESSURE_BYTES) return
|
||||
ws.send(JSON.stringify(payload))
|
||||
return
|
||||
}
|
||||
// Fallback: HTTP POST (e.g. during reconnect window)
|
||||
if (highFrequency) return
|
||||
try {
|
||||
const res = await browserSessionsApi.event(remoteSession.value.id, payload)
|
||||
remoteSession.value = res.data
|
||||
@@ -397,6 +414,22 @@ function sendRemoteCommand(type: 'reload' | 'back' | 'forward') {
|
||||
sendRemoteEvent({ type })
|
||||
}
|
||||
|
||||
async function copyRemoteSelection() {
|
||||
if (!remoteSession.value) return
|
||||
try {
|
||||
const res = await browserSessionsApi.selection(remoteSession.value.id)
|
||||
const text = res.data.text.trim()
|
||||
if (!text) {
|
||||
ElMessage.warning('远程页面没有选中文本')
|
||||
return
|
||||
}
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success('已复制远程选中文本')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.detail || '复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
function remoteViewport() {
|
||||
const rect = remoteFrameRef.value?.getBoundingClientRect()
|
||||
return {
|
||||
@@ -513,7 +546,23 @@ function onRemotePointerCancel(event: PointerEvent) {
|
||||
function onRemoteWheel(event: WheelEvent) {
|
||||
const point = eventPoint(event)
|
||||
if (!point) return
|
||||
sendRemoteEvent({ type: 'scroll', delta_x: event.deltaX, delta_y: event.deltaY, ...point })
|
||||
if (!pendingWheel) {
|
||||
pendingWheel = { deltaX: 0, deltaY: 0, ...point }
|
||||
}
|
||||
pendingWheel.deltaX += event.deltaX
|
||||
pendingWheel.deltaY += event.deltaY
|
||||
pendingWheel.x = point.x
|
||||
pendingWheel.y = point.y
|
||||
if (wheelTimer !== undefined) return
|
||||
wheelTimer = window.setTimeout(flushRemoteWheel, REMOTE_WHEEL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function flushRemoteWheel() {
|
||||
wheelTimer = undefined
|
||||
const wheel = pendingWheel
|
||||
pendingWheel = null
|
||||
if (!wheel) return
|
||||
sendRemoteEvent({ type: 'scroll', delta_x: wheel.deltaX, delta_y: wheel.deltaY, x: wheel.x, y: wheel.y })
|
||||
}
|
||||
|
||||
function onRemoteKeydown(event: KeyboardEvent) {
|
||||
@@ -521,6 +570,12 @@ function onRemoteKeydown(event: KeyboardEvent) {
|
||||
// which we handle in onRemotePaste with the actual clipboard text.
|
||||
const isPaste = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'v'
|
||||
if (isPaste) return
|
||||
const isCopy = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c'
|
||||
if (isCopy) {
|
||||
event.preventDefault()
|
||||
copyRemoteSelection()
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent browser from handling other keys (scrolling, shortcuts, etc.)
|
||||
event.preventDefault()
|
||||
@@ -568,7 +623,45 @@ async function closeRemoteSession() {
|
||||
await browserSessionsApi.close(id).catch(() => undefined)
|
||||
}
|
||||
|
||||
function queueRemoteScreenshot(blob: Blob) {
|
||||
pendingScreenshotBlob = blob
|
||||
if (screenshotFrameRequest !== undefined) return
|
||||
screenshotFrameRequest = window.requestAnimationFrame(renderPendingScreenshot)
|
||||
}
|
||||
|
||||
function renderPendingScreenshot() {
|
||||
screenshotFrameRequest = undefined
|
||||
const blob = pendingScreenshotBlob
|
||||
pendingScreenshotBlob = null
|
||||
if (!blob) return
|
||||
const nextUrl = URL.createObjectURL(blob)
|
||||
if (previousScreenshotObjectUrl) URL.revokeObjectURL(previousScreenshotObjectUrl)
|
||||
previousScreenshotObjectUrl = screenshotObjectUrl
|
||||
screenshotObjectUrl = nextUrl
|
||||
remoteScreenshotUrl.value = nextUrl
|
||||
}
|
||||
|
||||
function onRemoteImageLoad() {
|
||||
iframeLoading.value = false
|
||||
if (previousScreenshotObjectUrl) {
|
||||
URL.revokeObjectURL(previousScreenshotObjectUrl)
|
||||
previousScreenshotObjectUrl = ''
|
||||
}
|
||||
if (pendingScreenshotBlob && screenshotFrameRequest === undefined) {
|
||||
screenshotFrameRequest = window.requestAnimationFrame(renderPendingScreenshot)
|
||||
}
|
||||
}
|
||||
|
||||
function setRemoteScreenshotUrl(url: string) {
|
||||
if (screenshotFrameRequest !== undefined) {
|
||||
window.cancelAnimationFrame(screenshotFrameRequest)
|
||||
screenshotFrameRequest = undefined
|
||||
}
|
||||
pendingScreenshotBlob = null
|
||||
if (previousScreenshotObjectUrl) {
|
||||
URL.revokeObjectURL(previousScreenshotObjectUrl)
|
||||
previousScreenshotObjectUrl = ''
|
||||
}
|
||||
if (screenshotObjectUrl) {
|
||||
URL.revokeObjectURL(screenshotObjectUrl)
|
||||
screenshotObjectUrl = ''
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="shell-page page-section upstreams-page">
|
||||
<div class="shell-page shell-page-fluid page-section upstreams-page">
|
||||
<section class="page-header upstreams-hero surface-card">
|
||||
<div class="page-heading">
|
||||
<p class="page-kicker">Monitoring Matrix</p>
|
||||
@@ -44,85 +44,166 @@
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="surface-card data-stage">
|
||||
<div class="section-header data-stage-head">
|
||||
<div>
|
||||
<div class="section-caption">Upstream Registry</div>
|
||||
<h3 class="data-stage-title brand-type">检测与变更控制台</h3>
|
||||
<section class="upstreams-content">
|
||||
<section class="surface-card data-stage">
|
||||
<div class="section-header data-stage-head">
|
||||
<div>
|
||||
<div class="section-caption">Upstream Registry</div>
|
||||
<h3 class="data-stage-title brand-type">检测与变更控制台</h3>
|
||||
</div>
|
||||
<p class="data-stage-note">点击详情可查看快照历史、分组倍率与最近错误。</p>
|
||||
</div>
|
||||
<p class="data-stage-note">点击详情可查看快照历史、分组倍率与最近错误。</p>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="tableLoading" row-key="id" style="width: 100%">
|
||||
<el-table-column label="来源" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-name">{{ row.name }}</div>
|
||||
<div class="cell-url mono">{{ row.base_url }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table :data="list" v-loading="tableLoading" row-key="id" style="width: 100%">
|
||||
<el-table-column label="来源" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-name">{{ row.name }}</div>
|
||||
<div class="cell-url mono">{{ row.base_url }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="状态" width="118">
|
||||
<template #default="{ row }">
|
||||
<span :class="['status-badge', row.last_status]">
|
||||
<span class="dot" />
|
||||
{{ statusLabel(row.last_status) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="118">
|
||||
<template #default="{ row }">
|
||||
<span :class="['status-badge', row.last_status]">
|
||||
<span class="dot" />
|
||||
{{ statusLabel(row.last_status) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="启用" width="88" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.enabled" @change="toggleEnabled(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用" width="88" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.enabled" @change="toggleEnabled(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="认证" width="132">
|
||||
<template #default="{ row }">
|
||||
<span class="status-badge auth-badge">{{ authLabel(row.auth_type) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="认证" width="132">
|
||||
<template #default="{ row }">
|
||||
<span class="status-badge auth-badge">{{ authLabel(row.auth_type) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="检测间隔" width="112">
|
||||
<template #default="{ row }">
|
||||
<span class="mono time-inline">{{ row.check_interval_seconds }}s</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="检测间隔" width="112">
|
||||
<template #default="{ row }">
|
||||
<span class="mono time-inline">{{ row.check_interval_seconds }}s</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="最近检测" min-width="168">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.last_checked_at" class="time-text mono">{{ fmtTime(row.last_checked_at) }}</span>
|
||||
<span v-else class="muted">未检测</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近检测" min-width="168">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.last_checked_at" class="time-text mono">{{ fmtTime(row.last_checked_at) }}</span>
|
||||
<span v-else class="muted">未检测</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="最近错误" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip v-if="row.last_error" :content="row.last_error" placement="top" :show-after="300">
|
||||
<span class="error-text">{{ shrinkError(row.last_error) }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="muted">无异常</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近错误" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip v-if="row.last_error" :content="row.last_error" placement="top" :show-after="300">
|
||||
<span class="error-text">{{ shrinkError(row.last_error) }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="muted">无异常</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="258" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-row">
|
||||
<el-button size="small" text @click="openEdit(row)" title="编辑">
|
||||
<el-icon><Edit /></el-icon>
|
||||
</el-button>
|
||||
<el-button size="small" text type="success" @click="testUpstream(row)" :loading="row._testing">测试</el-button>
|
||||
<el-button size="small" text type="primary" @click="checkNow(row)" :loading="row._checking">检测</el-button>
|
||||
<el-button size="small" text type="info" @click="openDetail(row)">
|
||||
<el-icon><List /></el-icon>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button size="small" text type="danger" @click="confirmDelete(row)" title="删除">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
<el-table-column label="操作" width="258" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-row">
|
||||
<el-button size="small" text @click="openEdit(row)" title="编辑">
|
||||
<el-icon><Edit /></el-icon>
|
||||
</el-button>
|
||||
<el-button size="small" text type="success" @click="testUpstream(row)" :loading="row._testing">测试</el-button>
|
||||
<el-button size="small" text type="primary" @click="checkNow(row)" :loading="row._checking">检测</el-button>
|
||||
<el-button size="small" text type="info" @click="openDetail(row)">
|
||||
<el-icon><List /></el-icon>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button size="small" text type="danger" @click="confirmDelete(row)" title="删除">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<aside class="upstreams-side surface-card">
|
||||
<section class="side-section overview-section">
|
||||
<div class="section-header insight-head">
|
||||
<div>
|
||||
<div class="section-caption">Runtime Snapshot</div>
|
||||
<h3 class="insight-title">运行概览</h3>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<span class="insight-pill">健康率 {{ healthyRate }}%</span>
|
||||
</div>
|
||||
<div class="insight-grid">
|
||||
<div class="insight-metric">
|
||||
<span>异常节点</span>
|
||||
<strong>{{ metrics.unhealthy }}</strong>
|
||||
</div>
|
||||
<div class="insight-metric">
|
||||
<span>已启用</span>
|
||||
<strong>{{ metrics.enabled }}</strong>
|
||||
</div>
|
||||
<div class="insight-metric">
|
||||
<span>待检测</span>
|
||||
<strong>{{ pendingChecks }}</strong>
|
||||
</div>
|
||||
<div class="insight-metric">
|
||||
<span>最近检测</span>
|
||||
<strong>{{ latestCheckedAt ? fmtTime(latestCheckedAt) : '—' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="side-section">
|
||||
<div class="section-header insight-head compact">
|
||||
<div>
|
||||
<div class="section-caption">Need Attention</div>
|
||||
<h3 class="insight-title">待关注节点</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="attentionList.length > 0" class="feed-list">
|
||||
<button
|
||||
v-for="row in attentionList"
|
||||
:key="row.id"
|
||||
type="button"
|
||||
class="feed-item"
|
||||
@click="openDetail(row)"
|
||||
>
|
||||
<div class="feed-main">
|
||||
<div class="feed-top">
|
||||
<span class="feed-name">{{ row.name }}</span>
|
||||
<span :class="['status-badge', row.last_status]">
|
||||
<span class="dot" />{{ statusLabel(row.last_status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="feed-meta">{{ row.last_error ? shrinkError(row.last_error) : '最近状态异常,建议查看快照详情' }}</div>
|
||||
</div>
|
||||
<span class="feed-link">详情</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="empty-hint side-empty">当前没有需要关注的异常节点</div>
|
||||
</section>
|
||||
|
||||
<section class="side-section">
|
||||
<div class="section-header insight-head compact">
|
||||
<div>
|
||||
<div class="section-caption">Recent Activity</div>
|
||||
<h3 class="insight-title">最近检测</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="recentChecks.length > 0" class="timeline-list">
|
||||
<div v-for="row in recentChecks" :key="row.id" class="timeline-item">
|
||||
<div class="timeline-main">
|
||||
<div class="timeline-name">{{ row.name }}</div>
|
||||
<div class="timeline-meta mono">{{ fmtTime(row.last_checked_at!) }}</div>
|
||||
</div>
|
||||
<el-button size="small" text type="primary" @click="openDetail(row)">查看</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-hint side-empty">还没有检测记录</div>
|
||||
</section>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<el-drawer
|
||||
@@ -382,6 +463,34 @@ const metrics = computed(() => ({
|
||||
unhealthy: list.value.filter((item) => item.last_status === 'unhealthy').length,
|
||||
}))
|
||||
|
||||
const healthyRate = computed(() => {
|
||||
if (metrics.value.total === 0) return 0
|
||||
return Math.round((metrics.value.healthy / metrics.value.total) * 100)
|
||||
})
|
||||
|
||||
const pendingChecks = computed(() => list.value.filter((item) => !item.last_checked_at).length)
|
||||
|
||||
const latestCheckedAt = computed(() => {
|
||||
const times = list.value
|
||||
.map((item) => item.last_checked_at)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.sort((a, b) => new Date(toUTC(b)).getTime() - new Date(toUTC(a)).getTime())
|
||||
return times[0] || ''
|
||||
})
|
||||
|
||||
const attentionList = computed(() =>
|
||||
list.value
|
||||
.filter((item) => item.last_status === 'unhealthy' || item.last_error)
|
||||
.slice(0, 4),
|
||||
)
|
||||
|
||||
const recentChecks = computed(() =>
|
||||
[...list.value]
|
||||
.filter((item) => Boolean(item.last_checked_at))
|
||||
.sort((a, b) => new Date(toUTC(b.last_checked_at as string)).getTime() - new Date(toUTC(a.last_checked_at as string)).getTime())
|
||||
.slice(0, 5),
|
||||
)
|
||||
|
||||
const statusLabel = (s: string) => ({ healthy: '健康', unhealthy: '异常', unknown: '未知' }[s] || s)
|
||||
const authLabel = (s: string) => ({ none: '无认证', bearer: 'Bearer', api_key: 'API Key', login_password: '邮箱密码' }[s] || s)
|
||||
const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : `${t}Z`
|
||||
@@ -468,6 +577,7 @@ async function testUpstream(row: any) {
|
||||
const res = await upstreamsApi.test(row.id)
|
||||
if (res.data.success) ElMessage.success(res.data.message)
|
||||
else ElMessage.error(res.data.detail || res.data.message)
|
||||
await loadList()
|
||||
} finally {
|
||||
row._testing = false
|
||||
}
|
||||
@@ -544,7 +654,9 @@ onMounted(loadList)
|
||||
}
|
||||
|
||||
.upstreams-hero {
|
||||
padding: 1.35rem;
|
||||
align-items: center;
|
||||
padding: 1.2rem 1.25rem;
|
||||
min-height: 8.7rem;
|
||||
border-radius: var(--radius-shell);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(217, 139, 66, 0.14), transparent 24%),
|
||||
@@ -552,15 +664,43 @@ onMounted(loadList)
|
||||
var(--bg-panel);
|
||||
}
|
||||
|
||||
.upstreams-hero .page-heading {
|
||||
gap: 0.32rem;
|
||||
}
|
||||
|
||||
.upstreams-hero .page-title {
|
||||
font-size: clamp(1.95rem, 1.45rem + 1.2vw, 2.65rem);
|
||||
}
|
||||
|
||||
.upstreams-hero .page-desc {
|
||||
max-width: 50rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.upstreams-content {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.9fr) minmax(360px, 0.82fr);
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.data-stage {
|
||||
padding: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.data-stage,
|
||||
.upstreams-side {
|
||||
min-height: 33rem;
|
||||
}
|
||||
|
||||
.data-stage-head {
|
||||
min-height: 4.65rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -576,6 +716,149 @@ onMounted(loadList)
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.upstreams-side {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
padding: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.side-section {
|
||||
padding-block: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.side-section:first-child {
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.side-section:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.overview-section {
|
||||
min-height: 12.4rem;
|
||||
}
|
||||
|
||||
.insight-head {
|
||||
min-height: 3.75rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.insight-head.compact {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.insight-title {
|
||||
margin: 0.24rem 0 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.insight-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.38rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(239, 175, 99, 0.12);
|
||||
border: 1px solid rgba(239, 175, 99, 0.18);
|
||||
color: var(--color-primary-strong);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.insight-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.insight-metric {
|
||||
display: grid;
|
||||
gap: 0.28rem;
|
||||
padding: 0.85rem 0.9rem;
|
||||
border-radius: var(--radius-control);
|
||||
background: rgba(255, 244, 232, 0.03);
|
||||
border: 1px solid rgba(255, 244, 232, 0.06);
|
||||
}
|
||||
|
||||
.insight-metric span {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.insight-metric strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.feed-list,
|
||||
.timeline-list {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.feed-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
width: 100%;
|
||||
padding: 0.85rem 0.9rem;
|
||||
border: 1px solid rgba(255, 244, 232, 0.06);
|
||||
border-radius: var(--radius-control);
|
||||
background: rgba(255, 244, 232, 0.02);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.feed-item:hover {
|
||||
border-color: rgba(239, 175, 99, 0.2);
|
||||
background: rgba(255, 244, 232, 0.04);
|
||||
}
|
||||
|
||||
.feed-main,
|
||||
.timeline-main {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.32rem;
|
||||
}
|
||||
|
||||
.feed-top,
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.feed-name,
|
||||
.timeline-name {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.feed-meta,
|
||||
.timeline-meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.feed-link {
|
||||
color: var(--color-primary-strong);
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.side-empty {
|
||||
min-height: auto;
|
||||
padding: 0.6rem 0;
|
||||
}
|
||||
|
||||
.auth-badge {
|
||||
background: rgba(134, 183, 199, 0.12);
|
||||
color: var(--color-info);
|
||||
@@ -714,6 +997,12 @@ onMounted(loadList)
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
.upstreams-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.info-cards {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
@@ -722,10 +1011,16 @@ onMounted(loadList)
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.upstreams-hero,
|
||||
.data-stage {
|
||||
.data-stage,
|
||||
.upstreams-side {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.data-stage,
|
||||
.upstreams-side {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -733,5 +1028,14 @@ onMounted(loadList)
|
||||
.hero-actions :deep(.el-button) {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.insight-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.feed-item,
|
||||
.timeline-item {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="shell-page page-section">
|
||||
<div class="shell-page shell-page-fluid page-section">
|
||||
<div class="page-header surface-card page-block">
|
||||
<div class="page-heading">
|
||||
<p class="page-kicker">Delivery Mesh</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="shell-page page-section websites-page">
|
||||
<div class="shell-page shell-page-fluid page-section websites-page">
|
||||
<div class="page-header surface-card page-block">
|
||||
<div class="page-heading">
|
||||
<p class="page-kicker">Sync Orchestration</p>
|
||||
@@ -44,7 +44,7 @@
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<el-table-column label="操作" width="174" align="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-row">
|
||||
<el-tooltip content="查看分组" placement="top" :show-after="300">
|
||||
@@ -637,12 +637,15 @@ onMounted(loadAll)
|
||||
.action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.action-row .el-button.is-circle {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
margin-left: 0;
|
||||
}
|
||||
.binding-actions {
|
||||
display: flex;
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,233 @@
|
||||
# SmartUp 项目优化审查报告
|
||||
|
||||
> 基于代码审计整理的优化点,按严重程度分级。
|
||||
> 审查范围:backend/ 全部 Python 代码、frontend/src/ 全部 TS/Vue 代码、Dockerfile、docker-compose.yml。
|
||||
|
||||
---
|
||||
|
||||
## 🔴 严重 —— 生产稳定性 & 安全风险
|
||||
|
||||
### 1. SQLite 并发写导致锁冲突
|
||||
|
||||
**位置**:`backend/app/services/scheduler.py:28-91`(`_check_upstream`)
|
||||
**问题**:APScheduler `BackgroundScheduler` 使用线程池执行检测任务,每个线程独立调用 `SessionLocal()` 创建 SQLAlchemy session。SQLite 的锁粒度是整个文件,多线程并发 `commit()`(尤其是 `website_sync.sync_binding()` 内多次提交)必然出现 `database is locked` 错误。
|
||||
|
||||
```python
|
||||
# scheduler.py:28 — 每个 job 开独立 session
|
||||
db: Session = SessionLocal()
|
||||
...
|
||||
|
||||
# website_sync.py:178-180 — 内部多次 commit
|
||||
db.commit()
|
||||
...
|
||||
db.commit()
|
||||
```
|
||||
|
||||
**建议**:上游检测改为单线程串行队列(`apscheduler` 设置 `max_instances=1` + 单 worker),或将 SQLite 替换为 PostgreSQL。
|
||||
|
||||
### 2. CORS 通配符 + credentials 违反规范
|
||||
|
||||
**位置**:`backend/app/main.py:48-53`
|
||||
**问题**:`allow_origins=["*"]` 与 `allow_credentials=True` 同时设置,浏览器端会忽略通配符(CORS 规范要求显式 origin)。当前实际行为取决于 FastAPI 中间件实现,不可靠。
|
||||
|
||||
```python
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # ← 通配符
|
||||
allow_credentials=True, # ← 不允许与通配符共存
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
**建议**:`allow_origins` 设为显式列表(从环境变量读取),或关闭 `allow_credentials`。
|
||||
|
||||
### 3. HTTP 客户端每次请求新建连接,无连接池
|
||||
|
||||
**位置**:
|
||||
- `backend/app/services/upstream_client.py`(`UpstreamClient._request`,约 L230)
|
||||
- `backend/app/services/website_client.py`(`Sub2ApiWebsiteClient._request`,约 L95)
|
||||
|
||||
**问题**:每次 API 调用都创建新的 `httpx.Client()`,用完即销毁。一个上游检测周期(login → groups → rates)建立 3 个独立 TCP 连接;N 个上游即 3N 个连接。无连接复用、无 DNS 缓存。
|
||||
|
||||
```python
|
||||
# upstream_client.py
|
||||
def _request(self, method, path, body=None, auth=True):
|
||||
with httpx.Client(timeout=self.timeout) as client: # ← 每个请求新建
|
||||
resp = client.request(...)
|
||||
```
|
||||
|
||||
**建议**:在 `__init__` 中创建 `httpx.AsyncClient` 或复用 `Client`,析构时关闭。
|
||||
|
||||
### 4. 快照表无限增长
|
||||
|
||||
**位置**:`backend/app/models/snapshot.py`(`UpstreamRateSnapshot` 模型)
|
||||
**问题**:每次检测成功都 INSERT 一行,无任何清理策略。按每 10 分钟一次、数据量约 2KB/行估算:
|
||||
|
||||
- 1 个上游 × 1 年 ≈ 52K 行 ≈ 100MB+
|
||||
- 10 个上游 × 1 年 ≈ 0.5M 行 ≈ 1GB+
|
||||
|
||||
`snapshot_json` 是 `Text` 字段存全量 JSON,查询 `ORDER BY captured_at DESC` 扫描行数随时间线性增长。
|
||||
|
||||
**建议**:定时清理(保留最近 N 条或最近 M 天),或只保存 diff 增量。
|
||||
|
||||
### 5. 弱默认凭据 + 无登录限流
|
||||
|
||||
**位置**:
|
||||
- `backend/app/config.py:7-8`
|
||||
- `backend/app/routers/auth.py:12-20`
|
||||
- `backend/app/utils/auth.py:41-57`
|
||||
|
||||
**问题**:
|
||||
- `admin_password` 默认值 `"changeme"`,`jwt_secret` 默认值 `"change-me-in-production"`
|
||||
- `/api/auth/login` 无任何速率限制,可暴力枚举
|
||||
- `/api/auth/logout` 是空操作(JWT 无黑名单),token 过期前一直有效
|
||||
- JWT 不包含 `jti` 等唯一标识,无法按需吊销
|
||||
|
||||
```python
|
||||
# config.py
|
||||
admin_password: str = "changeme"
|
||||
jwt_secret: str = "change-me-in-production"
|
||||
|
||||
# auth.py
|
||||
@router.post("/logout")
|
||||
def logout():
|
||||
return {"message": "logged out"} # ← 纯空操作
|
||||
```
|
||||
|
||||
**建议**:登录端点加限流(内存令牌桶或 `slowapi`);密码至少 8 位校验;JWT 加入 `jti` + 内存/DB 黑名单。
|
||||
|
||||
### 6. 密码被 bcrypt 静默截断到 72 字节
|
||||
|
||||
**位置**:`backend/app/utils/auth.py:12-18`
|
||||
**问题**:`hash_password` 和 `verify_password` 都做 `[:72]` 截断。用户设置超长密码时无提示,登录时输入完整密码也能匹配——用户永远不会发现密码被截断了。
|
||||
|
||||
```python
|
||||
pw = password.encode("utf-8")[:72]
|
||||
return bcrypt.hashpw(pw, bcrypt.gensalt()).decode("utf-8")
|
||||
```
|
||||
|
||||
**建议**:在设置/修改密码时检查长度并给出提示,或改用非截断的哈希方案。
|
||||
|
||||
---
|
||||
|
||||
## 🟡 中等 —— 代码质量 & 维护性
|
||||
|
||||
### 7. 重复工具函数
|
||||
|
||||
**位置**:
|
||||
- `backend/app/services/upstream_client.py:69-80`(`_decimal_str`)
|
||||
- `backend/app/services/website_client.py:17-28`(`decimal_string`)
|
||||
|
||||
**问题**:同一份 Decimal 格式化逻辑在两个文件中重复实现,行为略有差异(函数名不同但逻辑相同)。修改一个必然漏掉另一个。
|
||||
|
||||
**建议**:抽取到 `app/utils/number.py` 统一引用。
|
||||
|
||||
### 8. 事务边界模糊,部分失败导致状态不一致
|
||||
|
||||
**位置**:`backend/app/services/scheduler.py:55-88`
|
||||
**问题**:`_check_upstream` 在同一个 DB session 内顺序执行:
|
||||
|
||||
1. 写 snapshot → `db.commit()`
|
||||
2. 调用 `webhook_service.send_rate_changed()` → 内部 `httpx.post` + `db.commit()`
|
||||
3. 调用 `website_sync.sync_affected_bindings()` → 内部多次 `httpx.put` + 多次 `db.commit()`
|
||||
|
||||
如果步骤 2 或 3 失败,步骤 1 的 snapshot 已经提交,但 webhook 和网站同步丢失——数据处于"检测到了变化但没通知"的不一致状态。
|
||||
|
||||
**建议**:检测(只读 + snapshot 写入)与通知/同步(webhook + 网站写回)分离为两个独立事务。
|
||||
|
||||
### 9. 硬编码上游 IP 迁移逻辑
|
||||
|
||||
**位置**:`backend/app/database.py:58-65`
|
||||
**问题**:`_migrate_custom_pages()` 包含针对特定 IP 的数据迁移:
|
||||
|
||||
```python
|
||||
"host": "%://170.106.100.210",
|
||||
```
|
||||
|
||||
这是特定部署的遗留数据修正,放在通用数据库初始化代码中。其他部署者每次启动都会跑这段无用代码。
|
||||
|
||||
**建议**:移出到独立 migration 脚本,或在检测到实际数据时按需执行。
|
||||
|
||||
### 10. Scheduler 关闭不等待进行中的任务
|
||||
|
||||
**位置**:`backend/app/services/scheduler.py:120`
|
||||
**问题**:`_scheduler.shutdown(wait=False)` 立即返回,正在执行的 job 可能被中断(正在写 DB 或发 HTTP 请求)。
|
||||
|
||||
```python
|
||||
def stop_scheduler() -> None:
|
||||
if _scheduler.running:
|
||||
_scheduler.shutdown(wait=False) # ← 不等待
|
||||
```
|
||||
|
||||
**建议**:设 `wait=True` 或给一个合理的 grace period(如 30s)。
|
||||
|
||||
### 11. `consecutive_failures` 未在启用/编辑时重置
|
||||
|
||||
**位置**:`backend/app/services/scheduler.py:60-75`
|
||||
**问题**:失败计数只在检测成功时归零。如果一个上游被禁用(`enabled=False`)、修改配置、再启用,旧的失败计数会延续——可能立即标记为 unhealthy。同样,手动 `check-now` 成功也没有重置计数器。
|
||||
|
||||
**建议**:编辑上游或重新启用时重置计数;`check-now` 成功路径也一并重置。
|
||||
|
||||
---
|
||||
|
||||
## 🟢 轻量 —— 体验 & 运维改进
|
||||
|
||||
### 12. 前端 localStorage key 易冲突
|
||||
|
||||
**位置**:`frontend/src/api/index.ts:14-15`
|
||||
**问题**:硬编码 `'smartup_token'` / `'smartup_email'`,同域名下多个实例会互相覆盖。
|
||||
|
||||
**建议**:用环境变量或 `import.meta.env.VITE_APP_KEY` 做 key 前缀。
|
||||
|
||||
### 13. 健康检查开销大
|
||||
|
||||
**位置**:`docker-compose.yml:23-28`
|
||||
**问题**:`CMD ["python", "-c", "import urllib.request; ..."]` 每 30 秒启动一次完整 Python 解释器,每次约 50-100ms 启动耗时。
|
||||
|
||||
**建议**:Dockerfile 安装 `curl`,改为 `curl -f http://localhost:8000/healthz`。
|
||||
|
||||
### 14. 缺少 `.dockerignore`
|
||||
|
||||
**位置**:项目根目录
|
||||
**问题**:无 `.dockerignore`,`node_modules/`、`__pycache__`、`data/`、`.venv/` 等大规模目录都会被打包进入 Docker build context,拖慢构建。
|
||||
|
||||
**建议**:添加 `.dockerignore`,排除 `node_modules/`、`data/`、`*.pyc`、`.git/`、`.venv/` 等。
|
||||
|
||||
### 15. 前端请求无网络重试
|
||||
|
||||
**位置**:`frontend/src/api/index.ts:6-20`
|
||||
**问题**:axios 拦截器遇到 401 直接跳登录页。网络瞬断、502/503 等临时故障无重试。
|
||||
|
||||
**建议**:引入 `axios-retry` 实现指数退避重试(对非 401/4xx 可重试)。
|
||||
|
||||
### 16. 快照分页 API 实现微妙
|
||||
|
||||
**位置**:`backend/app/routers/upstreams.py:256-294`
|
||||
**问题**:`list_snapshots` 为了计算 diff 数量多取 1 行,与 `offset` + `limit` 配合时逻辑复杂:
|
||||
|
||||
```python
|
||||
rows = (
|
||||
db.query(...)
|
||||
.offset(offset)
|
||||
.limit(limit + 1) # 多取 1 行用于 diff
|
||||
.all()
|
||||
)
|
||||
```
|
||||
|
||||
当 `offset > 0` 时,多取的 1 行是相对于 offset 的额外行,并不一定是前一页的最后一行——diff 结果可能不准。
|
||||
|
||||
**建议**:放弃"附带 diff 计数"设计,改为前端单独调 diff 接口,或直接在前次快照 id 上做 diff。
|
||||
|
||||
---
|
||||
|
||||
## 📋 优先级实施建议
|
||||
|
||||
| 优先级 | 编号 | 估算工时 | 影响面 |
|
||||
|--------|------|---------|--------|
|
||||
| P0 | 1, 2, 3, 4, 5 | 1-2 天 | DB 锁死 / 安全漏洞 / 性能衰减 |
|
||||
| P1 | 6, 7, 8, 10, 11 | 0.5 天 | 数据不一致 / 维护负担 |
|
||||
| P2 | 9, 12, 13, 14, 15, 16 | 0.5 天 | 部署体验 / 代码整洁 |
|
||||
|
||||
---
|
||||
|
||||
*生成日期:2026-06-13 · 审查范围:commit HEAD*
|
||||
Reference in New Issue
Block a user