feat: remote browser login persistence + balance display + UI consistency

- Retain login state in remote browser profiles (don't delete on disconnect)
- Add GET /api/browser-sessions/{id}/clipboard for clipboard sync
- Add POST /api/browser-sessions/{id}/autofill-login for manual credential fill
- Add DELETE /api/browser-sessions/profiles/{custom_page_id} for login clear
- Add balance tracking with configurable divisor (balance_divisor)
- Health check on session reuse, idle TTL eviction, background cleanup
- Add first-frame watchdog (10s timeout) to prevent infinite loading
- Reconnect browser on active=true when session was closed
- UI: uniform text-only inline buttons (websites + upstreams pages)
- Fix page switch race with closingRemoteSessionPromise
This commit is contained in:
liumangmang
2026-05-20 09:44:20 +08:00
parent 4c71148ff9
commit 6cc797f915
16 changed files with 773 additions and 52 deletions
+102
View File
@@ -131,11 +131,113 @@ async def get_selection(session_id: str, _=Depends(get_current_user)):
raise _error_from_browser(exc)
class BrowserClipboardResponse(BaseModel):
text: Optional[str] = None
error: Optional[str] = None
@router.get("/{session_id}/clipboard", response_model=BrowserClipboardResponse)
async def session_clipboard(session_id: str, _=Depends(get_current_user)):
"""Read text from the remote browser's clipboard."""
from fastapi.responses import JSONResponse
try:
text, error = await browser_sessions.read_clipboard(session_id)
body: dict[str, Any] = {}
if text:
body["text"] = text
elif error == "denied":
body["error"] = "远程浏览器未授予剪贴板读取权限"
elif error == "read_failed":
body["error"] = "读取远程剪贴板时发生内部错误"
else:
if error:
logger.warning("clipboard read error for %s: %s", session_id[:12], error)
body["error"] = "远程剪贴板为空"
return JSONResponse(content=body, headers={"Cache-Control": "no-store"})
except Exception as exc:
raise _error_from_browser(exc)
class AutofillLoginResponse(BaseModel):
success: bool
message: str
@router.post("/{session_id}/autofill-login", response_model=AutofillLoginResponse)
async def autofill_login(session_id: str, _=Depends(get_current_user)):
"""Manually trigger login autofill for the remote browser session.
Uses the linked custom page's saved credentials. Never returns passwords.
"""
try:
session_state = await browser_sessions.state(session_id)
except Exception as exc:
raise _error_from_browser(exc)
from app.database import SessionLocal as _Db
from app.models.custom_page import CustomPage
db = _Db()
try:
page = db.query(CustomPage).filter(
CustomPage.id == session_state["custom_page_id"]
).first()
if not page or not page.enabled:
raise HTTPException(400, "linked custom page is not available")
if page.access_mode != "remote_browser":
raise HTTPException(400, "linked custom page is not in remote browser mode")
if not page.login_autofill_enabled:
return AutofillLoginResponse(success=False, message="该页面未启用自动填充登录")
if not page.login_username or not page.login_password:
return AutofillLoginResponse(success=False, message="该页面未保存账号密码")
login_config = {
"enabled": True,
"username": page.login_username,
"password": page.login_password,
"username_selector": page.login_username_selector,
"password_selector": page.login_password_selector,
"submit_selector": page.login_submit_selector,
}
filled = await browser_sessions.autofill_login(session_id, login_config)
if filled:
return AutofillLoginResponse(success=True, message="已填入账号密码")
return AutofillLoginResponse(
success=False,
message="未找到登录输入框,请先关闭弹窗或进入登录页后重试",
)
finally:
db.close()
@router.delete("/{session_id}", status_code=204)
async def close_session(session_id: str, _=Depends(get_current_user)):
await browser_sessions.close(session_id)
@router.delete("/profiles/{custom_page_id}", status_code=204)
async def clear_profile(custom_page_id: int, _=Depends(get_current_user)):
"""Close active session for the page and delete its profile directory.
On next open the browser starts fresh, losing login state.
"""
from app.models.custom_page import CustomPage
from app.database import SessionLocal as _Db
db = _Db()
try:
page = db.query(CustomPage).filter(CustomPage.id == custom_page_id).first()
if not page or not page.enabled:
raise HTTPException(404, "custom page not found")
if page.access_mode != "remote_browser":
raise HTTPException(400, "custom page is not in remote browser mode")
try:
await browser_sessions.clear_profile(custom_page_id, page.url)
except RuntimeError as exc:
raise HTTPException(500, str(exc))
finally:
db.close()
# ——— WebSocket stream ———
# Frame interval & diff detection
_WS_MIN_INTERVAL = 0.10
+14 -1
View File
@@ -270,7 +270,20 @@ async def refresh_auth(pid: int, db: Session = Depends(get_db), _=Depends(get_cu
existing_config["new_api_user"] = candidate["new_api_user"]
elif ctype == "bearer_token":
upstream.auth_type = "bearer"
existing_config["token"] = candidate.get("value", "")
raw = candidate.get("value", "")
# Clean up: strip whitespace, remove "Bearer " prefix if present
token = raw.strip()
if token.startswith("Bearer "):
token = token[7:].strip()
# Validate token can be used as HTTP header value
try:
token.encode("latin-1")
except UnicodeEncodeError:
return RefreshAuthResponse(
success=False,
message=f"提取到的 Token 含有非 HTTP 标头字符,请确认已在远程浏览器中正确登录并重试",
)
existing_config["token"] = token
elif ctype == "api_key":
upstream.auth_type = "api_key"
existing_config["key"] = candidate.get("value", "")
+31
View File
@@ -2,9 +2,12 @@
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from typing import List
logger = logging.getLogger(__name__)
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
@@ -55,6 +58,11 @@ def _to_response(u: Upstream) -> UpstreamResponse:
last_status=u.last_status,
last_checked_at=u.last_checked_at,
last_error=u.last_error,
balance=u.balance,
balance_updated_at=u.balance_updated_at,
balance_endpoint=u.balance_endpoint or "",
balance_response_path=u.balance_response_path or "",
balance_divisor=u.balance_divisor or 1.0,
created_at=u.created_at,
updated_at=u.updated_at,
)
@@ -82,6 +90,9 @@ def create_upstream(
enabled=body.enabled,
check_interval_seconds=body.check_interval_seconds,
timeout_seconds=body.timeout_seconds,
balance_endpoint=body.balance_endpoint,
balance_response_path=body.balance_response_path,
balance_divisor=body.balance_divisor,
)
db.add(u)
db.commit()
@@ -156,6 +167,16 @@ def test_upstream(uid: int, db: Session = Depends(get_db), _=Depends(get_current
try:
client.login()
groups = client.get_available_groups(u.groups_endpoint)
# Also try balance if configured
if u.balance_endpoint and u.balance_response_path:
try:
raw_balance = client.get_balance(u.balance_endpoint, u.balance_response_path)
if raw_balance is not None:
divisor = u.balance_divisor or 1.0
u.balance = raw_balance / divisor
u.balance_updated_at = datetime.now(timezone.utc) if raw_balance is not None else None
except Exception as exc:
logger.warning("upstream %s balance fetch failed during test: %s", u.name, exc)
u.last_status = "healthy"
u.last_error = None
u.last_checked_at = datetime.now(timezone.utc)
@@ -189,6 +210,16 @@ def check_now(uid: int, db: Session = Depends(get_db), _=Depends(get_current_use
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)
# Also try balance if configured
if u.balance_endpoint and u.balance_response_path:
try:
raw_balance = client.get_balance(u.balance_endpoint, u.balance_response_path)
if raw_balance is not None:
divisor = u.balance_divisor or 1.0
u.balance = raw_balance / divisor
u.balance_updated_at = datetime.now(timezone.utc) if raw_balance is not None else None
except Exception as exc:
logger.warning("upstream %s balance fetch failed during check-now: %s", u.name, exc)
except Exception as exc:
u.consecutive_failures = (u.consecutive_failures or 0) + 1
u.last_error = str(exc)