fix: address multiple code audit findings

- CORS: replace wildcard with explicit origin list from CORS_ORIGINS env
- Auth: enforce strong defaults, JWT blacklist (RevokedToken model), login rate limiting
- Auth: validate password length before bcrypt (72-byte limit)
- Scheduler: single-threaded worker to mitigate SQLite write contention
- Scheduler: graceful shutdown (wait=True)
- Snapshots: add prune_snapshots() with configurable retention count
- Storage: isolate localStorage keys via VITE_APP_KEY prefix
- Config: add cors_origins, login_rate_limit, snapshot_retention_count settings
This commit is contained in:
SmartUp Developer
2026-05-17 10:52:18 +08:00
parent a42ecf7bcc
commit ad16618406
25 changed files with 792 additions and 165 deletions
+5 -3
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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"}
+1 -1
View File
@@ -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()
+17 -5
View File
@@ -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, 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,11 +21,21 @@ 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()
@@ -45,6 +55,7 @@ def _init_admin() -> None:
@asynccontextmanager
async def lifespan(app: FastAPI):
_validate_runtime_settings()
init_db()
_init_admin()
start_scheduler()
@@ -63,9 +74,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=["*"],
+14
View File
@@ -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))
+62 -6
View File
@@ -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"}
+34 -10
View File
@@ -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):
+10
View File
@@ -154,8 +154,18 @@ def test_upstream(uid: int, db: Session = Depends(get_db), _=Depends(get_current
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))
+2
View File
@@ -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()
@@ -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 -3
View File
@@ -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:
@@ -95,6 +96,7 @@ def _check_upstream(upstream_id: int) -> None:
upstream.last_checked_at = datetime.now(timezone.utc)
upstream.last_error = None
upstream.consecutive_failures = 0
prune_snapshots(db, upstream_id, settings.snapshot_retention_count)
db.commit()
if was_unhealthy:
@@ -155,4 +157,4 @@ def start_scheduler() -> None:
def stop_scheduler() -> None:
if _scheduler.running:
_scheduler.shutdown(wait=False)
_scheduler.shutdown(wait=True)
+21
View File
@@ -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)
+34 -12
View File
@@ -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
+4 -2
View File
@@ -1,5 +1,6 @@
import axios from 'axios'
import router from '@/router'
import { authStorageKeys } from '@/authStorage'
export const api = axios.create({
baseURL: '/',
@@ -10,8 +11,8 @@ 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 +294,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()) })
+5
View File
@@ -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;
+7
View File
@@ -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`,
}
+7 -12
View File
@@ -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;
+7 -6
View File
@@ -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']
}
+1 -1
View File
@@ -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' }],
+38 -12
View File
@@ -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>
+103 -10
View File
@@ -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 = ''
+376 -72
View File
@@ -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 -1
View File
@@ -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>
+8 -5
View File
@@ -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;