feat: persist browser sessions and update admin workflows
This commit is contained in:
@@ -126,6 +126,10 @@ def _migrate_upstreams():
|
|||||||
conn.execute(text("ALTER TABLE upstreams ADD COLUMN balance_response_path VARCHAR(256) NOT NULL DEFAULT ''"))
|
conn.execute(text("ALTER TABLE upstreams ADD COLUMN balance_response_path VARCHAR(256) NOT NULL DEFAULT ''"))
|
||||||
if "balance_divisor" not in columns:
|
if "balance_divisor" not in columns:
|
||||||
conn.execute(text("ALTER TABLE upstreams ADD COLUMN balance_divisor FLOAT NOT NULL DEFAULT 1.0"))
|
conn.execute(text("ALTER TABLE upstreams ADD COLUMN balance_divisor FLOAT NOT NULL DEFAULT 1.0"))
|
||||||
|
if "balance_alert_threshold" not in columns:
|
||||||
|
conn.execute(text("ALTER TABLE upstreams ADD COLUMN balance_alert_threshold FLOAT"))
|
||||||
|
if "balance_alert_notified" not in columns:
|
||||||
|
conn.execute(text("ALTER TABLE upstreams ADD COLUMN balance_alert_notified BOOLEAN NOT NULL DEFAULT 0"))
|
||||||
|
|
||||||
|
|
||||||
def _migrate_upstream_generated_keys():
|
def _migrate_upstream_generated_keys():
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ class Upstream(Base):
|
|||||||
balance_endpoint: Mapped[str] = mapped_column(String(256), default="")
|
balance_endpoint: Mapped[str] = mapped_column(String(256), default="")
|
||||||
balance_response_path: Mapped[str] = mapped_column(String(256), default="")
|
balance_response_path: Mapped[str] = mapped_column(String(256), default="")
|
||||||
balance_divisor: Mapped[float] = mapped_column(Float, default=1.0)
|
balance_divisor: Mapped[float] = mapped_column(Float, default=1.0)
|
||||||
|
# Balance alert
|
||||||
|
balance_alert_threshold: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||||
|
balance_alert_notified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)
|
DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ def _to_response(u: Upstream) -> UpstreamResponse:
|
|||||||
balance_endpoint=u.balance_endpoint or "",
|
balance_endpoint=u.balance_endpoint or "",
|
||||||
balance_response_path=u.balance_response_path or "",
|
balance_response_path=u.balance_response_path or "",
|
||||||
balance_divisor=u.balance_divisor or 1.0,
|
balance_divisor=u.balance_divisor or 1.0,
|
||||||
|
balance_alert_threshold=u.balance_alert_threshold,
|
||||||
created_at=u.created_at,
|
created_at=u.created_at,
|
||||||
updated_at=u.updated_at,
|
updated_at=u.updated_at,
|
||||||
)
|
)
|
||||||
@@ -352,6 +353,7 @@ def create_upstream(
|
|||||||
balance_endpoint=body.balance_endpoint,
|
balance_endpoint=body.balance_endpoint,
|
||||||
balance_response_path=body.balance_response_path,
|
balance_response_path=body.balance_response_path,
|
||||||
balance_divisor=body.balance_divisor,
|
balance_divisor=body.balance_divisor,
|
||||||
|
balance_alert_threshold=body.balance_alert_threshold,
|
||||||
)
|
)
|
||||||
db.add(u)
|
db.add(u)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -169,6 +169,28 @@ def _numeric_group_id(value: str | None) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_rate_priority_map(db: Session, upstream_ids: set[int]) -> dict[str, int]:
|
||||||
|
"""根据上游分组倍率构建 group_id → priority 映射。
|
||||||
|
|
||||||
|
遍历所有涉及的上游的最新快照,收集分组的倍率,按倍率升序排列后赋值 priority。
|
||||||
|
倍率最低的 priority=1,次低的 priority=2,以此类推。相同倍率的分组共享同一 priority。
|
||||||
|
"""
|
||||||
|
group_rates: dict[str, float] = {}
|
||||||
|
for uid in upstream_ids:
|
||||||
|
groups = _latest_upstream_groups(db, uid)
|
||||||
|
for g in groups:
|
||||||
|
gid = _source_group_id(g)
|
||||||
|
rate = _source_group_rate(g)
|
||||||
|
if gid:
|
||||||
|
# 同一 group_id 在同个 upstream 内是唯一的;跨 upstream 的相同 group_id
|
||||||
|
# 如果倍率不同则以最后遇到的为准(实际很少冲突)
|
||||||
|
group_rates[gid] = rate
|
||||||
|
# 按倍率排序分配 priority
|
||||||
|
unique_rates = sorted(set(group_rates.values()))
|
||||||
|
rate_to_priority = {rate: idx + 1 for idx, rate in enumerate(unique_rates)}
|
||||||
|
return {gid: rate_to_priority[rate] for gid, rate in group_rates.items()}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/websites", response_model=List[WebsiteResponse])
|
@router.get("/api/websites", response_model=List[WebsiteResponse])
|
||||||
def list_websites(db: Session = Depends(get_db), _=Depends(get_current_user)):
|
def list_websites(db: Session = Depends(get_db), _=Depends(get_current_user)):
|
||||||
return [_website_response(row) for row in db.query(Website).order_by(Website.id).all()]
|
return [_website_response(row) for row in db.query(Website).order_by(Website.id).all()]
|
||||||
@@ -496,6 +518,16 @@ def import_upstream_keys_as_accounts(
|
|||||||
if _u:
|
if _u:
|
||||||
upstream_base_url = _u.base_url
|
upstream_base_url = _u.base_url
|
||||||
|
|
||||||
|
# 按倍率自动分配优先级
|
||||||
|
rate_priority_map: dict[str, int] = {}
|
||||||
|
if body.auto_priority_by_rate:
|
||||||
|
upstream_ids = {row.upstream_id for row in rows}
|
||||||
|
try:
|
||||||
|
rate_priority_map = _build_rate_priority_map(db, upstream_ids)
|
||||||
|
except HTTPException:
|
||||||
|
# 没有快照时忽略,后续 fallback 到 body.priority
|
||||||
|
pass
|
||||||
|
|
||||||
with _client(website) as c:
|
with _client(website) as c:
|
||||||
for row in rows:
|
for row in rows:
|
||||||
# 先确定平台(失败项也需要记录)
|
# 先确定平台(失败项也需要记录)
|
||||||
@@ -512,6 +544,16 @@ def import_upstream_keys_as_accounts(
|
|||||||
old_account_id = row.imported_account_id
|
old_account_id = row.imported_account_id
|
||||||
exists = c.account_exists(row.imported_account_id)
|
exists = c.account_exists(row.imported_account_id)
|
||||||
if exists is True:
|
if exists is True:
|
||||||
|
# 自动更新已有账号的 priority(分步导入时全局倍率排序可能已变)
|
||||||
|
new_priority = rate_priority_map.get(row.group_id) if body.auto_priority_by_rate else None
|
||||||
|
priority_msg = "已导入过,已跳过"
|
||||||
|
if new_priority is not None:
|
||||||
|
try:
|
||||||
|
c.update_account(old_account_id, {"priority": new_priority})
|
||||||
|
priority_msg = f"已导入过,优先级已更新为 {new_priority}"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("update priority failed account=%s: %s", old_account_id, exc)
|
||||||
|
priority_msg = f"已导入过,优先级更新失败: {exc}"
|
||||||
items.append(ImportAccountItem(
|
items.append(ImportAccountItem(
|
||||||
upstream_key_id=row.id,
|
upstream_key_id=row.id,
|
||||||
source_group_id=row.group_id,
|
source_group_id=row.group_id,
|
||||||
@@ -522,7 +564,7 @@ def import_upstream_keys_as_accounts(
|
|||||||
platform=platform,
|
platform=platform,
|
||||||
upstream_base_url=upstream_base_url,
|
upstream_base_url=upstream_base_url,
|
||||||
status="exists",
|
status="exists",
|
||||||
message="已导入过,已跳过",
|
message=priority_msg,
|
||||||
))
|
))
|
||||||
continue
|
continue
|
||||||
elif exists is False:
|
elif exists is False:
|
||||||
@@ -574,7 +616,7 @@ def import_upstream_keys_as_accounts(
|
|||||||
"group_ids": group_ids,
|
"group_ids": group_ids,
|
||||||
"rate_multiplier": 1,
|
"rate_multiplier": 1,
|
||||||
"concurrency": body.concurrency,
|
"concurrency": body.concurrency,
|
||||||
"priority": body.priority,
|
"priority": rate_priority_map.get(row.group_id, body.priority) if body.auto_priority_by_rate else body.priority,
|
||||||
"notes": f"Imported by SmartUp from upstream key #{row.id}",
|
"notes": f"Imported by SmartUp from upstream key #{row.id}",
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class UpstreamCreate(BaseModel):
|
|||||||
balance_endpoint: str = ""
|
balance_endpoint: str = ""
|
||||||
balance_response_path: str = ""
|
balance_response_path: str = ""
|
||||||
balance_divisor: float = 1.0
|
balance_divisor: float = 1.0
|
||||||
|
balance_alert_threshold: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
class UpstreamUpdate(BaseModel):
|
class UpstreamUpdate(BaseModel):
|
||||||
@@ -48,6 +49,7 @@ class UpstreamUpdate(BaseModel):
|
|||||||
balance_endpoint: Optional[str] = None
|
balance_endpoint: Optional[str] = None
|
||||||
balance_response_path: Optional[str] = None
|
balance_response_path: Optional[str] = None
|
||||||
balance_divisor: Optional[float] = None
|
balance_divisor: Optional[float] = None
|
||||||
|
balance_alert_threshold: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
class UpstreamResponse(BaseModel):
|
class UpstreamResponse(BaseModel):
|
||||||
@@ -70,6 +72,7 @@ class UpstreamResponse(BaseModel):
|
|||||||
balance_endpoint: str = ""
|
balance_endpoint: str = ""
|
||||||
balance_response_path: str = ""
|
balance_response_path: str = ""
|
||||||
balance_divisor: float = 1.0
|
balance_divisor: float = 1.0
|
||||||
|
balance_alert_threshold: Optional[float] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ class ImportAccountsRequest(BaseModel):
|
|||||||
platform_mode: str = "auto" # "auto" | "manual"
|
platform_mode: str = "auto" # "auto" | "manual"
|
||||||
concurrency: int = Field(default=10, ge=1)
|
concurrency: int = Field(default=10, ge=1)
|
||||||
priority: int = Field(default=1, ge=0)
|
priority: int = Field(default=1, ge=0)
|
||||||
|
auto_priority_by_rate: bool = True
|
||||||
|
|
||||||
|
|
||||||
class ImportAccountItem(BaseModel):
|
class ImportAccountItem(BaseModel):
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class BrowserSession:
|
|||||||
lock: asyncio.Lock
|
lock: asyncio.Lock
|
||||||
cdp_session: Any = None
|
cdp_session: Any = None
|
||||||
captured_headers: list[dict] = None # auth headers from CDP
|
captured_headers: list[dict] = None # auth headers from CDP
|
||||||
|
last_saved_state_at: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
class BrowserSessionService:
|
class BrowserSessionService:
|
||||||
@@ -92,12 +93,15 @@ class BrowserSessionService:
|
|||||||
self._profiles.pop(profile_key, None)
|
self._profiles.pop(profile_key, None)
|
||||||
# Idle cleanup: close stale sessions before spawning new ones
|
# Idle cleanup: close stale sessions before spawning new ones
|
||||||
await self._evict_idle_sessions()
|
await self._evict_idle_sessions()
|
||||||
|
|
||||||
context = await self._playwright.chromium.launch_persistent_context(
|
context = await self._playwright.chromium.launch_persistent_context(
|
||||||
str(self._profile_dir(profile_key)),
|
str(self._profile_dir(profile_key)),
|
||||||
headless=get_settings().browser_headless,
|
headless=get_settings().browser_headless,
|
||||||
viewport={"width": width, "height": height},
|
viewport={"width": width, "height": height},
|
||||||
|
color_scheme="dark",
|
||||||
args=["--no-sandbox", "--disable-dev-shm-usage"],
|
args=["--no-sandbox", "--disable-dev-shm-usage"],
|
||||||
)
|
)
|
||||||
|
await self._restore_session_state(context, profile_key)
|
||||||
# Grant clipboard access for the page origin
|
# Grant clipboard access for the page origin
|
||||||
try:
|
try:
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
@@ -137,6 +141,11 @@ class BrowserSessionService:
|
|||||||
self._touch(session_id)
|
self._touch(session_id)
|
||||||
async with session.lock:
|
async with session.lock:
|
||||||
self._ensure_open(session)
|
self._ensure_open(session)
|
||||||
|
if session.profile_key and not session.profile_key.startswith("auth-capture-"):
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - session.last_saved_state_at > 10.0:
|
||||||
|
await self._save_session_state(session)
|
||||||
|
session.last_saved_state_at = now
|
||||||
return await session.page.screenshot(type="jpeg", quality=65, full_page=False)
|
return await session.page.screenshot(type="jpeg", quality=65, full_page=False)
|
||||||
|
|
||||||
async def event(
|
async def event(
|
||||||
@@ -188,6 +197,12 @@ class BrowserSessionService:
|
|||||||
await page.set_viewport_size({"width": width, "height": height})
|
await page.set_viewport_size({"width": width, "height": height})
|
||||||
else:
|
else:
|
||||||
raise ValueError("Unsupported browser event")
|
raise ValueError("Unsupported browser event")
|
||||||
|
if session.profile_key and not session.profile_key.startswith("auth-capture-"):
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - session.last_saved_state_at > 5.0:
|
||||||
|
await self._save_session_state(session)
|
||||||
|
session.last_saved_state_at = now
|
||||||
|
|
||||||
if not include_state:
|
if not include_state:
|
||||||
return None
|
return None
|
||||||
return await self._session_state(session)
|
return await self._session_state(session)
|
||||||
@@ -242,6 +257,15 @@ class BrowserSessionService:
|
|||||||
session = self._discard_session(session_id)
|
session = self._discard_session(session_id)
|
||||||
if not session:
|
if not session:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 在完全关闭 context 前,强制将最新的状态落盘保存
|
||||||
|
if session.profile_key and not session.profile_key.startswith("auth-capture-"):
|
||||||
|
try:
|
||||||
|
if not session.page.is_closed():
|
||||||
|
await self._save_session_state(session)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("failed to save state during close: %s", exc)
|
||||||
|
|
||||||
# Detach CDP session if active
|
# Detach CDP session if active
|
||||||
if session.cdp_session:
|
if session.cdp_session:
|
||||||
try:
|
try:
|
||||||
@@ -261,6 +285,7 @@ class BrowserSessionService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def shutdown(self) -> None:
|
async def shutdown(self) -> None:
|
||||||
# Cancel the background eviction loop
|
# Cancel the background eviction loop
|
||||||
if self._evict_task is not None and not self._evict_task.done():
|
if self._evict_task is not None and not self._evict_task.done():
|
||||||
@@ -524,6 +549,9 @@ class BrowserSessionService:
|
|||||||
profile.mkdir(parents=True, exist_ok=True)
|
profile.mkdir(parents=True, exist_ok=True)
|
||||||
return profile
|
return profile
|
||||||
|
|
||||||
|
def _cookies_path(self, profile_key: str) -> Path:
|
||||||
|
return self._profile_dir(profile_key) / "session-cookies.json"
|
||||||
|
|
||||||
def _profile_key(self, custom_page_id: int, url: str) -> str:
|
def _profile_key(self, custom_page_id: int, url: str) -> str:
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
origin = f"{parsed.scheme}-{parsed.netloc}".lower()
|
origin = f"{parsed.scheme}-{parsed.netloc}".lower()
|
||||||
@@ -553,6 +581,7 @@ class BrowserSessionService:
|
|||||||
str(self._profile_dir(profile_key)),
|
str(self._profile_dir(profile_key)),
|
||||||
headless=get_settings().browser_headless,
|
headless=get_settings().browser_headless,
|
||||||
viewport={"width": width, "height": height},
|
viewport={"width": width, "height": height},
|
||||||
|
color_scheme="dark",
|
||||||
args=["--no-sandbox", "--disable-dev-shm-usage"],
|
args=["--no-sandbox", "--disable-dev-shm-usage"],
|
||||||
)
|
)
|
||||||
# Grant clipboard access for the page origin
|
# Grant clipboard access for the page origin
|
||||||
@@ -613,5 +642,85 @@ class BrowserSessionService:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("CDP capture not available: %s", exc)
|
logger.debug("CDP capture not available: %s", exc)
|
||||||
|
|
||||||
|
async def _save_session_state(self, session: BrowserSession) -> None:
|
||||||
|
if not session.profile_key or session.profile_key.startswith("auth-capture-"):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
state = await session.context.storage_state()
|
||||||
|
cookies_path = self._cookies_path(session.profile_key)
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
# Ensure parent directories exist
|
||||||
|
cookies_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temp_fd, temp_path = tempfile.mkstemp(dir=str(cookies_path.parent))
|
||||||
|
try:
|
||||||
|
with os.fdopen(temp_fd, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(state, f, ensure_ascii=False, indent=2)
|
||||||
|
os.replace(temp_path, cookies_path)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.unlink(temp_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("failed to save session state for %s: %s", session.profile_key, exc)
|
||||||
|
|
||||||
|
async def _restore_session_state(self, context: Any, profile_key: str) -> None:
|
||||||
|
if profile_key.startswith("auth-capture-"):
|
||||||
|
return
|
||||||
|
cookies_path = self._cookies_path(profile_key)
|
||||||
|
if not cookies_path.exists() or cookies_path.stat().st_size == 0:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
with open(cookies_path, 'r', encoding='utf-8') as f:
|
||||||
|
state = json.load(f)
|
||||||
|
cookies = state.get("cookies", [])
|
||||||
|
if cookies:
|
||||||
|
now = time.time()
|
||||||
|
valid_cookies = []
|
||||||
|
for c in cookies:
|
||||||
|
expires = c.get("expires")
|
||||||
|
if expires is not None and expires > 0 and expires <= now:
|
||||||
|
continue
|
||||||
|
if expires is not None and expires <= 0:
|
||||||
|
c.pop("expires", None)
|
||||||
|
valid_cookies.append(c)
|
||||||
|
if valid_cookies:
|
||||||
|
await context.add_cookies(valid_cookies)
|
||||||
|
logger.info("restored %d cookies for profile %s", len(valid_cookies), profile_key)
|
||||||
|
|
||||||
|
# 还原 LocalStorage
|
||||||
|
origins = state.get("origins", [])
|
||||||
|
if origins:
|
||||||
|
origins_json = json.dumps(origins)
|
||||||
|
init_script = f"""
|
||||||
|
(() => {{
|
||||||
|
try {{
|
||||||
|
const origins = {origins_json};
|
||||||
|
const currentOrigin = window.location.origin;
|
||||||
|
const target = origins.find(o => o.origin === currentOrigin);
|
||||||
|
if (target && target.localStorage) {{
|
||||||
|
for (const item of target.localStorage) {{
|
||||||
|
try {{
|
||||||
|
window.localStorage.setItem(item.name, item.value);
|
||||||
|
}} catch (e) {{
|
||||||
|
console.error('Failed to restore localStorage key', item.name, e);
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}} catch (err) {{
|
||||||
|
console.error('LocalStorage restore initialization script failed', err);
|
||||||
|
}}
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
await context.add_init_script(init_script)
|
||||||
|
logger.info("registered LocalStorage init script for profile %s (origins: %d)", profile_key, len(origins))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("failed to restore cookies/state for profile %s: %s", profile_key, exc)
|
||||||
|
|
||||||
|
|
||||||
browser_sessions = BrowserSessionService()
|
browser_sessions = BrowserSessionService()
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ def _check_upstream(upstream_id: int) -> None:
|
|||||||
|
|
||||||
auth_config = json.loads(upstream.auth_config_json or "{}")
|
auth_config = json.loads(upstream.auth_config_json or "{}")
|
||||||
was_unhealthy = upstream.last_status == "unhealthy"
|
was_unhealthy = upstream.last_status == "unhealthy"
|
||||||
|
balance_alert_triggered = False
|
||||||
snapshot = None
|
snapshot = None
|
||||||
changes = None
|
changes = None
|
||||||
|
|
||||||
@@ -76,6 +77,14 @@ def _check_upstream(upstream_id: int) -> None:
|
|||||||
if balance is not None:
|
if balance is not None:
|
||||||
upstream.balance = balance
|
upstream.balance = balance
|
||||||
upstream.balance_updated_at = datetime.now(timezone.utc)
|
upstream.balance_updated_at = datetime.now(timezone.utc)
|
||||||
|
# ── 余额告警阈值检查 ──
|
||||||
|
threshold = upstream.balance_alert_threshold
|
||||||
|
if threshold is not None and threshold > 0:
|
||||||
|
if balance < threshold and not upstream.balance_alert_notified:
|
||||||
|
upstream.balance_alert_notified = True
|
||||||
|
balance_alert_triggered = True
|
||||||
|
elif balance >= threshold and upstream.balance_alert_notified:
|
||||||
|
upstream.balance_alert_notified = False
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# failure path
|
# failure path
|
||||||
upstream.consecutive_failures = (upstream.consecutive_failures or 0) + 1
|
upstream.consecutive_failures = (upstream.consecutive_failures or 0) + 1
|
||||||
@@ -152,6 +161,12 @@ def _check_upstream(upstream_id: int) -> None:
|
|||||||
_notify_rate_changed(upstream_id, upstream.name, upstream.base_url, changes)
|
_notify_rate_changed(upstream_id, upstream.name, upstream.base_url, changes)
|
||||||
_sync_website_bindings(upstream_id, changes)
|
_sync_website_bindings(upstream_id, changes)
|
||||||
|
|
||||||
|
if balance_alert_triggered:
|
||||||
|
_notify_balance_low(
|
||||||
|
upstream_id, upstream.name, upstream.base_url,
|
||||||
|
upstream.balance, upstream.balance_alert_threshold,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _notify_status(
|
def _notify_status(
|
||||||
upstream_id: int,
|
upstream_id: int,
|
||||||
@@ -184,6 +199,22 @@ def _notify_rate_changed(
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_balance_low(
|
||||||
|
upstream_id: int,
|
||||||
|
upstream_name: str,
|
||||||
|
base_url: str,
|
||||||
|
balance: float,
|
||||||
|
threshold: float,
|
||||||
|
) -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
webhook_service.send_balance_low(db, upstream_id, upstream_name, base_url, balance, threshold)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("balance low webhook failed for upstream %s", upstream_name)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def _sync_upstream_keys(upstream_id: int, snapshot: dict[str, Any], captured_at: datetime) -> None:
|
def _sync_upstream_keys(upstream_id: int, snapshot: dict[str, Any], captured_at: datetime) -> None:
|
||||||
"""上游检测成功后同步 SmartUp Key 状态(远端删除/分组删除)。"""
|
"""上游检测成功后同步 SmartUp Key 状态(远端删除/分组删除)。"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from app.utils.dingtalk import (
|
|||||||
format_dingtalk_rate_changed,
|
format_dingtalk_rate_changed,
|
||||||
format_dingtalk_website_rate_changed,
|
format_dingtalk_website_rate_changed,
|
||||||
format_dingtalk_status,
|
format_dingtalk_status,
|
||||||
|
format_dingtalk_balance_low,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -185,6 +186,43 @@ def send_status_event(
|
|||||||
_log(db, wh, event, generic_payload, "failed", str(exc))
|
_log(db, wh, event, generic_payload, "failed", str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
def send_balance_low(
|
||||||
|
db: Session,
|
||||||
|
upstream_id: int,
|
||||||
|
upstream_name: str,
|
||||||
|
base_url: str,
|
||||||
|
balance: float,
|
||||||
|
threshold: float,
|
||||||
|
) -> None:
|
||||||
|
webhooks = (
|
||||||
|
db.query(WebhookConfig)
|
||||||
|
.filter(WebhookConfig.enabled == True)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
event = "upstream_balance_low"
|
||||||
|
changed_at = _now_iso()
|
||||||
|
generic_payload = {
|
||||||
|
"event": event,
|
||||||
|
"upstream": {"id": upstream_id, "name": upstream_name, "base_url": base_url},
|
||||||
|
"balance": balance,
|
||||||
|
"threshold": threshold,
|
||||||
|
"changed_at": changed_at,
|
||||||
|
}
|
||||||
|
for wh in webhooks:
|
||||||
|
events = json.loads(wh.events_json or "[]")
|
||||||
|
if event not in events:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if wh.type == "dingtalk":
|
||||||
|
msg = format_dingtalk_balance_low(upstream_name, balance, threshold, changed_at)
|
||||||
|
resp_text = _send_dingtalk(wh.url, wh.secret, msg)
|
||||||
|
else:
|
||||||
|
resp_text = _send_generic(wh.url, generic_payload)
|
||||||
|
_log(db, wh, event, generic_payload, "success", resp_text)
|
||||||
|
except Exception as exc:
|
||||||
|
_log(db, wh, event, generic_payload, "failed", str(exc))
|
||||||
|
|
||||||
|
|
||||||
def send_test_notification(db: Session, webhook: WebhookConfig) -> tuple[bool, str]:
|
def send_test_notification(db: Session, webhook: WebhookConfig) -> tuple[bool, str]:
|
||||||
payload = {
|
payload = {
|
||||||
"event": "test",
|
"event": "test",
|
||||||
|
|||||||
@@ -223,6 +223,12 @@ class Sub2ApiWebsiteClient:
|
|||||||
data = _unwrap_data(resp)
|
data = _unwrap_data(resp)
|
||||||
return data if isinstance(data, dict) else {"value": data}
|
return data if isinstance(data, dict) else {"value": data}
|
||||||
|
|
||||||
|
def update_account(self, account_id: str, body: dict[str, Any], endpoint: str = "/accounts") -> dict[str, Any]:
|
||||||
|
"""更新远端账号(仅传入需要变更的字段)。"""
|
||||||
|
resp = self._request("PUT", f"{endpoint}/{account_id}", body)
|
||||||
|
data = _unwrap_data(resp)
|
||||||
|
return data if isinstance(data, dict) else {"value": data}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _unwrap_list(value: dict) -> list | None:
|
def _unwrap_list(value: dict) -> list | None:
|
||||||
"""递归展开嵌套的列表包装:data.items、data.data、items、accounts 等。"""
|
"""递归展开嵌套的列表包装:data.items、data.data、items、accounts 等。"""
|
||||||
|
|||||||
@@ -64,6 +64,25 @@ def format_dingtalk_website_rate_changed(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_dingtalk_balance_low(
|
||||||
|
upstream_name: str, balance: float, threshold: float, changed_at: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
lines = [
|
||||||
|
f"### ⚠️ {upstream_name} 余额不足",
|
||||||
|
"",
|
||||||
|
f"- **当前余额**:{balance:.2f}",
|
||||||
|
f"- **告警阈值**:{threshold:.2f}",
|
||||||
|
f"- **时间**:{changed_at}",
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"msgtype": "markdown",
|
||||||
|
"markdown": {
|
||||||
|
"title": f"{upstream_name} 余额不足",
|
||||||
|
"text": "\n".join(lines),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def format_dingtalk_status(upstream_name: str, event: str, changed_at: str, error: str = "") -> dict[str, Any]:
|
def format_dingtalk_status(upstream_name: str, event: str, changed_at: str, error: str = "") -> dict[str, Any]:
|
||||||
emoji = "🔴" if event == "upstream_unhealthy" else "🟢"
|
emoji = "🔴" if event == "upstream_unhealthy" else "🟢"
|
||||||
label = "服务异常" if event == "upstream_unhealthy" else "服务恢复"
|
label = "服务异常" if event == "upstream_unhealthy" else "服务恢复"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from app.config import get_settings
|
||||||
from app.routers.auth_capture import _sanitize_candidate
|
from app.routers.auth_capture import _sanitize_candidate
|
||||||
from app.services.browser_session_service import BrowserSessionService
|
from app.services.browser_session_service import BrowserSessionService
|
||||||
|
|
||||||
@@ -127,3 +128,293 @@ def test_sanitize_candidate_strips_secret_fields_but_keeps_metadata():
|
|||||||
"cookie_name": "session",
|
"cookie_name": "session",
|
||||||
"domain": "example.test",
|
"domain": "example.test",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_cookies_path_mapping():
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
service = BrowserSessionService()
|
||||||
|
temp_dir = tempfile.mkdtemp()
|
||||||
|
original_dir = get_settings().browser_profiles_dir
|
||||||
|
get_settings().browser_profiles_dir = temp_dir
|
||||||
|
try:
|
||||||
|
profile_key = "test-profile-123"
|
||||||
|
expected_path = Path(service._cookies_path(profile_key))
|
||||||
|
assert expected_path.name == "session-cookies.json"
|
||||||
|
assert expected_path.parent == Path(service._profile_dir(profile_key))
|
||||||
|
finally:
|
||||||
|
get_settings().browser_profiles_dir = original_dir
|
||||||
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_screenshot_throttled_save():
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
service = BrowserSessionService()
|
||||||
|
|
||||||
|
# 准备临时目录并 mock settings.browser_profiles_dir
|
||||||
|
temp_dir = tempfile.mkdtemp()
|
||||||
|
original_dir = get_settings().browser_profiles_dir
|
||||||
|
get_settings().browser_profiles_dir = temp_dir
|
||||||
|
|
||||||
|
try:
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from app.services.browser_session_service import BrowserSession
|
||||||
|
|
||||||
|
# Mock Context & Page
|
||||||
|
fake_context = AsyncMock()
|
||||||
|
fake_page = MagicMock()
|
||||||
|
fake_page.is_closed = MagicMock(return_value=False)
|
||||||
|
fake_page.screenshot = AsyncMock(return_value=b"screenshot-bytes")
|
||||||
|
|
||||||
|
session = BrowserSession(
|
||||||
|
id="session123",
|
||||||
|
custom_page_id=1,
|
||||||
|
profile_key="page-1-test",
|
||||||
|
context=fake_context,
|
||||||
|
page=fake_page,
|
||||||
|
lock=asyncio.Lock(),
|
||||||
|
last_saved_state_at=0.0
|
||||||
|
)
|
||||||
|
service._sessions[session.id] = session
|
||||||
|
|
||||||
|
# 第一次调用 screenshot: 触发存储
|
||||||
|
res1 = run(service.screenshot(session.id))
|
||||||
|
assert res1 == b"screenshot-bytes"
|
||||||
|
assert fake_context.storage_state.call_count == 1
|
||||||
|
|
||||||
|
# 记录第一次保存后的时间戳
|
||||||
|
first_save_time = session.last_saved_state_at
|
||||||
|
assert first_save_time > 0
|
||||||
|
|
||||||
|
# 第二次立即调用 screenshot: 应该因为限流 10s 被跳过,不增加 call_count
|
||||||
|
res2 = run(service.screenshot(session.id))
|
||||||
|
assert res2 == b"screenshot-bytes"
|
||||||
|
assert fake_context.storage_state.call_count == 1
|
||||||
|
|
||||||
|
# 模拟 11 秒后(防抖时间已过)再度截图
|
||||||
|
session.last_saved_state_at = first_save_time - 11.0
|
||||||
|
res3 = run(service.screenshot(session.id))
|
||||||
|
assert res3 == b"screenshot-bytes"
|
||||||
|
assert fake_context.storage_state.call_count == 2
|
||||||
|
|
||||||
|
# 测试临时 auth-capture 会话不触发任何 state 存储
|
||||||
|
ephemeral_session = BrowserSession(
|
||||||
|
id="session-eph",
|
||||||
|
custom_page_id=0,
|
||||||
|
profile_key="auth-capture-xyz",
|
||||||
|
context=fake_context,
|
||||||
|
page=fake_page,
|
||||||
|
lock=asyncio.Lock(),
|
||||||
|
last_saved_state_at=0.0
|
||||||
|
)
|
||||||
|
service._sessions[ephemeral_session.id] = ephemeral_session
|
||||||
|
run(service.screenshot(ephemeral_session.id))
|
||||||
|
# 它的 call_count 依然是 2,没有增加
|
||||||
|
assert fake_context.storage_state.call_count == 2
|
||||||
|
|
||||||
|
finally:
|
||||||
|
get_settings().browser_profiles_dir = original_dir
|
||||||
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_saves_state_and_cleans_up():
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
service = BrowserSessionService()
|
||||||
|
temp_dir = tempfile.mkdtemp()
|
||||||
|
original_dir = get_settings().browser_profiles_dir
|
||||||
|
get_settings().browser_profiles_dir = temp_dir
|
||||||
|
|
||||||
|
try:
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from app.services.browser_session_service import BrowserSession
|
||||||
|
|
||||||
|
fake_context = AsyncMock()
|
||||||
|
fake_page = MagicMock()
|
||||||
|
fake_page.is_closed = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
import time
|
||||||
|
session = BrowserSession(
|
||||||
|
id="session_close",
|
||||||
|
custom_page_id=2,
|
||||||
|
profile_key="page-2-test",
|
||||||
|
context=fake_context,
|
||||||
|
page=fake_page,
|
||||||
|
lock=asyncio.Lock(),
|
||||||
|
last_saved_state_at=time.monotonic() # 此时在限流内
|
||||||
|
)
|
||||||
|
service._sessions[session.id] = session
|
||||||
|
|
||||||
|
# 即使在限流时间内,close 也必须强制保存
|
||||||
|
run(service.close(session.id))
|
||||||
|
assert fake_context.storage_state.call_count == 1
|
||||||
|
assert fake_context.close.call_count == 1
|
||||||
|
|
||||||
|
# 测试 ephemeral 会话在 close 时不应该保存 state,并且其 profile_dir 应当被清理,导致 cookies json 不复存在
|
||||||
|
eph_context = AsyncMock()
|
||||||
|
eph_page = MagicMock()
|
||||||
|
eph_page.is_closed = MagicMock(return_value=False)
|
||||||
|
eph_session = BrowserSession(
|
||||||
|
id="session_eph_close",
|
||||||
|
custom_page_id=0,
|
||||||
|
profile_key="auth-capture-abc",
|
||||||
|
context=eph_context,
|
||||||
|
page=eph_page,
|
||||||
|
lock=asyncio.Lock(),
|
||||||
|
last_saved_state_at=0.0
|
||||||
|
)
|
||||||
|
service._sessions[eph_session.id] = eph_session
|
||||||
|
|
||||||
|
# 先手动创建一个假 session-cookies.json
|
||||||
|
eph_cookies_path = service._cookies_path(eph_session.profile_key)
|
||||||
|
eph_cookies_path.write_text("{}")
|
||||||
|
assert eph_cookies_path.exists()
|
||||||
|
|
||||||
|
run(service.close(eph_session.id))
|
||||||
|
# ephemeral close 时不保存,所以 call_count 依然是 0
|
||||||
|
assert eph_context.storage_state.call_count == 0
|
||||||
|
assert eph_context.close.call_count == 1
|
||||||
|
# 但对应的 profile 目录已被删除,文件自然不复存在
|
||||||
|
assert not eph_cookies_path.exists()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
get_settings().browser_profiles_dir = original_dir
|
||||||
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_session_state_decoding_and_inject():
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
service = BrowserSessionService()
|
||||||
|
temp_dir = tempfile.mkdtemp()
|
||||||
|
original_dir = get_settings().browser_profiles_dir
|
||||||
|
get_settings().browser_profiles_dir = temp_dir
|
||||||
|
|
||||||
|
try:
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
fake_context = AsyncMock()
|
||||||
|
profile_key = "test-restore-profile"
|
||||||
|
|
||||||
|
# 准备假 cookies.json,包含 cookies 和 origins/localStorage
|
||||||
|
cookies_path = service._cookies_path(profile_key)
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
fake_state = {
|
||||||
|
"cookies": [
|
||||||
|
{
|
||||||
|
"name": "valid_persistent",
|
||||||
|
"value": "123",
|
||||||
|
"domain": "example.test",
|
||||||
|
"path": "/",
|
||||||
|
"expires": now + 3600 # 未过期
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "expired_cookie",
|
||||||
|
"value": "456",
|
||||||
|
"domain": "example.test",
|
||||||
|
"path": "/",
|
||||||
|
"expires": now - 3600 # 已过期
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "session_cookie",
|
||||||
|
"value": "789",
|
||||||
|
"domain": "example.test",
|
||||||
|
"path": "/",
|
||||||
|
"expires": -1 # session cookie,应被保留并剔除 expires 字段
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"origins": [
|
||||||
|
{
|
||||||
|
"origin": "https://example.test",
|
||||||
|
"localStorage": [
|
||||||
|
{
|
||||||
|
"name": "theme",
|
||||||
|
"value": "dark"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
with open(cookies_path, "w", encoding='utf-8') as f:
|
||||||
|
json.dump(fake_state, f)
|
||||||
|
|
||||||
|
# 运行还原方法
|
||||||
|
run(service._restore_session_state(fake_context, profile_key))
|
||||||
|
|
||||||
|
# 检查是否成功调用 add_cookies
|
||||||
|
assert fake_context.add_cookies.call_count == 1
|
||||||
|
|
||||||
|
# 检查过滤后的 cookies 内容
|
||||||
|
injected_cookies = fake_context.add_cookies.call_args[0][0]
|
||||||
|
assert len(injected_cookies) == 2
|
||||||
|
|
||||||
|
names = [c["name"] for c in injected_cookies]
|
||||||
|
assert "valid_persistent" in names
|
||||||
|
assert "session_cookie" in names
|
||||||
|
assert "expired_cookie" not in names
|
||||||
|
|
||||||
|
# 校验 session_cookie 是否成功移除了 expires
|
||||||
|
session_c = next(c for c in injected_cookies if c["name"] == "session_cookie")
|
||||||
|
assert "expires" not in session_c
|
||||||
|
|
||||||
|
# 检查是否成功调用了 add_init_script (用于还原 LocalStorage)
|
||||||
|
assert fake_context.add_init_script.call_count == 1
|
||||||
|
init_script = fake_context.add_init_script.call_args[0][0]
|
||||||
|
assert "window.localStorage.setItem" in init_script
|
||||||
|
assert "theme" in init_script
|
||||||
|
assert "dark" in init_script
|
||||||
|
|
||||||
|
finally:
|
||||||
|
get_settings().browser_profiles_dir = original_dir
|
||||||
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_websocket_event_saves_state():
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
service = BrowserSessionService()
|
||||||
|
temp_dir = tempfile.mkdtemp()
|
||||||
|
original_dir = get_settings().browser_profiles_dir
|
||||||
|
get_settings().browser_profiles_dir = temp_dir
|
||||||
|
|
||||||
|
try:
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from app.services.browser_session_service import BrowserSession
|
||||||
|
|
||||||
|
fake_context = AsyncMock()
|
||||||
|
fake_page = MagicMock()
|
||||||
|
fake_page.is_closed = MagicMock(return_value=False)
|
||||||
|
fake_page.mouse = MagicMock()
|
||||||
|
fake_page.mouse.click = AsyncMock()
|
||||||
|
|
||||||
|
session = BrowserSession(
|
||||||
|
id="session_ws",
|
||||||
|
custom_page_id=3,
|
||||||
|
profile_key="page-3-ws-test",
|
||||||
|
context=fake_context,
|
||||||
|
page=fake_page,
|
||||||
|
lock=asyncio.Lock(),
|
||||||
|
last_saved_state_at=0.0
|
||||||
|
)
|
||||||
|
service._sessions[session.id] = session
|
||||||
|
|
||||||
|
# 即使 include_state=False,也应当在 5 秒节流到期后保存状态
|
||||||
|
run(service.event(
|
||||||
|
session_id=session.id,
|
||||||
|
event_type="click",
|
||||||
|
payload={"x": 10.0, "y": 20.0},
|
||||||
|
include_state=False
|
||||||
|
))
|
||||||
|
|
||||||
|
# storage_state 应该被调用,说明保存成功触发了
|
||||||
|
assert fake_context.storage_state.call_count == 1
|
||||||
|
assert session.last_saved_state_at > 0
|
||||||
|
|
||||||
|
finally:
|
||||||
|
get_settings().browser_profiles_dir = original_dir
|
||||||
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export interface UpstreamData {
|
|||||||
balance_endpoint: string
|
balance_endpoint: string
|
||||||
balance_response_path: string
|
balance_response_path: string
|
||||||
balance_divisor: number
|
balance_divisor: number
|
||||||
|
balance_alert_threshold: number | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@@ -106,6 +107,7 @@ export interface UpstreamForm {
|
|||||||
balance_endpoint: string
|
balance_endpoint: string
|
||||||
balance_response_path: string
|
balance_response_path: string
|
||||||
balance_divisor: number
|
balance_divisor: number
|
||||||
|
balance_alert_threshold: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GeneratedUpstreamKey {
|
export interface GeneratedUpstreamKey {
|
||||||
@@ -284,6 +286,7 @@ export const websitesApi = {
|
|||||||
platform_mode?: string
|
platform_mode?: string
|
||||||
concurrency?: number
|
concurrency?: number
|
||||||
priority?: number
|
priority?: number
|
||||||
|
auto_priority_by_rate?: boolean
|
||||||
}) => api.post<{ success: boolean; message: string; items: ImportAccountItem[] }>(`/api/websites/${id}/accounts/import-upstream-keys`, data),
|
}) => api.post<{ success: boolean; message: string; items: ImportAccountItem[] }>(`/api/websites/${id}/accounts/import-upstream-keys`, data),
|
||||||
listBindings: () => api.get<GroupBindingData[]>('/api/group-bindings'),
|
listBindings: () => api.get<GroupBindingData[]>('/api/group-bindings'),
|
||||||
createBinding: (data: GroupBindingForm) => api.post<GroupBindingData>('/api/group-bindings', data),
|
createBinding: (data: GroupBindingForm) => api.post<GroupBindingData>('/api/group-bindings', data),
|
||||||
|
|||||||
@@ -19,13 +19,6 @@
|
|||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<div class="sidebar-section-title">监控中枢</div>
|
<div class="sidebar-section-title">监控中枢</div>
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<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">
|
|
||||||
<strong>网站管理</strong>
|
|
||||||
<small>目标站点、分组映射、自动同步</small>
|
|
||||||
</span>
|
|
||||||
</router-link>
|
|
||||||
<router-link to="/upstreams" class="nav-item" active-class="active" @click="closeMobileNav">
|
<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-icon"><el-icon><Connection /></el-icon></span>
|
||||||
<span class="nav-copy">
|
<span class="nav-copy">
|
||||||
@@ -33,6 +26,13 @@
|
|||||||
<small>轮询、健康度、倍率快照</small>
|
<small>轮询、健康度、倍率快照</small>
|
||||||
</span>
|
</span>
|
||||||
</router-link>
|
</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">
|
||||||
|
<strong>网站管理</strong>
|
||||||
|
<small>目标站点、分组映射、自动同步</small>
|
||||||
|
</span>
|
||||||
|
</router-link>
|
||||||
<router-link to="/webhooks" class="nav-item" active-class="active" @click="closeMobileNav">
|
<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-icon"><el-icon><Bell /></el-icon></span>
|
||||||
<span class="nav-copy">
|
<span class="nav-copy">
|
||||||
@@ -317,6 +317,23 @@ watch([() => route.path, customPages], () => {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 优化侧边栏滚动条,使其不超出圆角范围 */
|
||||||
|
.sidebar::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
.sidebar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.sidebar::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 244, 232, 0.12);
|
||||||
|
border-radius: 99px;
|
||||||
|
}
|
||||||
|
.sidebar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 244, 232, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar.open {
|
.sidebar.open {
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const router = createRouter({
|
|||||||
path: '/',
|
path: '/',
|
||||||
component: () => import('@/components/AppLayout.vue'),
|
component: () => import('@/components/AppLayout.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
redirect: '/websites',
|
redirect: '/upstreams',
|
||||||
children: [
|
children: [
|
||||||
{ path: 'upstreams', component: () => import('@/views/Upstreams.vue') },
|
{ path: 'upstreams', component: () => import('@/views/Upstreams.vue') },
|
||||||
{ path: 'websites', component: () => import('@/views/Websites.vue') },
|
{ path: 'websites', component: () => import('@/views/Websites.vue') },
|
||||||
@@ -32,7 +32,7 @@ router.beforeEach((to, _from, next) => {
|
|||||||
if (to.meta.requiresAuth && !auth.token) {
|
if (to.meta.requiresAuth && !auth.token) {
|
||||||
next('/login')
|
next('/login')
|
||||||
} else if (to.path === '/login' && auth.token) {
|
} else if (to.path === '/login' && auth.token) {
|
||||||
next('/websites')
|
next('/upstreams')
|
||||||
} else {
|
} else {
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="shell-page shell-page-fluid page-section">
|
<div class="shell-page shell-page-fluid page-section">
|
||||||
<div class="page-header surface-card page-block">
|
<div class="panel">
|
||||||
<div class="page-heading">
|
<div class="panel-head">
|
||||||
<p class="page-kicker">Delivery Trace</p>
|
<div class="panel-title">通知日志</div>
|
||||||
<h2 class="page-title">通知日志</h2>
|
<div class="panel-actions filters">
|
||||||
<p class="page-desc">查看所有 Webhook 通知的发送记录</p>
|
|
||||||
</div>
|
|
||||||
<div class="filters">
|
|
||||||
<el-select v-model="filterStatus" placeholder="状态" clearable style="width:110px" @change="handleFilterChange">
|
<el-select v-model="filterStatus" placeholder="状态" clearable style="width:110px" @change="handleFilterChange">
|
||||||
<el-option label="成功" value="success" />
|
<el-option label="成功" value="success" />
|
||||||
<el-option label="失败" value="failed" />
|
<el-option label="失败" value="failed" />
|
||||||
@@ -16,15 +13,13 @@
|
|||||||
<el-option label="网站倍率变更" value="website_rate_changed" />
|
<el-option label="网站倍率变更" value="website_rate_changed" />
|
||||||
<el-option label="服务异常" value="upstream_unhealthy" />
|
<el-option label="服务异常" value="upstream_unhealthy" />
|
||||||
<el-option label="服务恢复" value="upstream_recovered" />
|
<el-option label="服务恢复" value="upstream_recovered" />
|
||||||
|
<el-option label="余额不足" value="upstream_balance_low" />
|
||||||
<el-option label="测试" value="test" />
|
<el-option label="测试" value="test" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button @click="loadList" :loading="tableLoading">
|
<el-button size="small" text @click="loadList" :loading="tableLoading">刷新</el-button>
|
||||||
<el-icon><Refresh /></el-icon>
|
|
||||||
</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card page-block">
|
|
||||||
<el-table :data="list" v-loading="tableLoading" style="width:100%">
|
<el-table :data="list" v-loading="tableLoading" style="width:100%">
|
||||||
<el-table-column label="时间" width="150">
|
<el-table-column label="时间" width="150">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -51,15 +46,23 @@
|
|||||||
<span class="muted small">{{ row.response_text?.substring(0, 80) || '—' }}</span>
|
<span class="muted small">{{ row.response_text?.substring(0, 80) || '—' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="详情" width="80" fixed="right">
|
<el-table-column label="操作" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button size="small" text @click="viewDetail(row)"><el-icon><View /></el-icon></el-button>
|
<el-button size="small" text @click="viewDetail(row)" title="查看通知发送的Payload以及接口返回的响应详情">详情</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
<div class="page-info">
|
<div class="page-info">
|
||||||
第 {{ currentPage }} 页 · 每页 {{ pageSize }} 条
|
第 {{ currentPage }} 页
|
||||||
|
<span style="margin: 0 8px;">·</span>
|
||||||
|
每页
|
||||||
|
<el-select v-model="pageSize" style="width: 76px; margin: 0 4px;" size="small" @change="handlePageSizeChange">
|
||||||
|
<el-option :value="20" label="20" />
|
||||||
|
<el-option :value="50" label="50" />
|
||||||
|
<el-option :value="100" label="100" />
|
||||||
|
</el-select>
|
||||||
|
条
|
||||||
</div>
|
</div>
|
||||||
<div class="page-actions">
|
<div class="page-actions">
|
||||||
<el-button :disabled="offset === 0 || tableLoading" @click="prevPage" size="small">上一页</el-button>
|
<el-button :disabled="offset === 0 || tableLoading" @click="prevPage" size="small">上一页</el-button>
|
||||||
@@ -106,20 +109,21 @@ const detailRow = ref<LogData | null>(null)
|
|||||||
const filterStatus = ref('')
|
const filterStatus = ref('')
|
||||||
const filterEvent = ref('')
|
const filterEvent = ref('')
|
||||||
const offset = ref(0)
|
const offset = ref(0)
|
||||||
const pageSize = 50
|
const pageSize = ref(20)
|
||||||
const hasNextPage = ref(false)
|
const hasNextPage = ref(false)
|
||||||
const currentPage = computed(() => Math.floor(offset.value / pageSize) + 1)
|
const currentPage = computed(() => Math.floor(offset.value / pageSize.value) + 1)
|
||||||
|
|
||||||
const EVENT_LABELS: Record<string, string> = {
|
const EVENT_LABELS: Record<string, string> = {
|
||||||
upstream_rate_changed: '上游倍率变更',
|
upstream_rate_changed: '上游倍率变更',
|
||||||
website_rate_changed: '网站倍率变更',
|
website_rate_changed: '网站倍率变更',
|
||||||
upstream_unhealthy: '服务异常',
|
upstream_unhealthy: '服务异常',
|
||||||
upstream_recovered: '服务恢复',
|
upstream_recovered: '服务恢复',
|
||||||
|
upstream_balance_low: '余额不足',
|
||||||
test: '测试通知',
|
test: '测试通知',
|
||||||
}
|
}
|
||||||
const eventLabel = (e: string) => EVENT_LABELS[e] || e
|
const eventLabel = (e: string) => EVENT_LABELS[e] || e
|
||||||
const eventTagType = (e: string) =>
|
const eventTagType = (e: string) =>
|
||||||
({ upstream_rate_changed: 'primary', website_rate_changed: 'warning', upstream_unhealthy: 'danger', upstream_recovered: 'success', test: 'info' }[e] || '')
|
({ upstream_rate_changed: 'primary', website_rate_changed: 'warning', upstream_unhealthy: 'danger', upstream_recovered: 'success', upstream_balance_low: 'warning', test: 'info' }[e] || '')
|
||||||
const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : t + 'Z'
|
const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : t + 'Z'
|
||||||
const fmtTime = (t: string) => dayjs(toUTC(t)).format('MM-DD HH:mm:ss')
|
const fmtTime = (t: string) => dayjs(toUTC(t)).format('MM-DD HH:mm:ss')
|
||||||
|
|
||||||
@@ -132,11 +136,11 @@ async function loadList() {
|
|||||||
const res = await logsApi.list({
|
const res = await logsApi.list({
|
||||||
status: filterStatus.value || undefined,
|
status: filterStatus.value || undefined,
|
||||||
event_type: filterEvent.value || undefined,
|
event_type: filterEvent.value || undefined,
|
||||||
limit: pageSize + 1,
|
limit: pageSize.value + 1,
|
||||||
offset: offset.value,
|
offset: offset.value,
|
||||||
})
|
})
|
||||||
hasNextPage.value = res.data.length > pageSize
|
hasNextPage.value = res.data.length > pageSize.value
|
||||||
list.value = res.data.slice(0, pageSize)
|
list.value = res.data.slice(0, pageSize.value)
|
||||||
} finally {
|
} finally {
|
||||||
tableLoading.value = false
|
tableLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -152,12 +156,17 @@ function handleFilterChange() {
|
|||||||
loadList()
|
loadList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePageSizeChange() {
|
||||||
|
offset.value = 0
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
|
||||||
function prevPage() {
|
function prevPage() {
|
||||||
offset.value = Math.max(0, offset.value - pageSize)
|
offset.value = Math.max(0, offset.value - pageSize.value)
|
||||||
loadList()
|
loadList()
|
||||||
}
|
}
|
||||||
function nextPage() {
|
function nextPage() {
|
||||||
offset.value += pageSize
|
offset.value += pageSize.value
|
||||||
loadList()
|
loadList()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,8 +178,32 @@ onMounted(loadList)
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.panel-head {
|
||||||
border-radius: var(--radius-shell);
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination {
|
.pagination {
|
||||||
|
|||||||
@@ -1,62 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="shell-page shell-page-fluid 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>
|
|
||||||
<h2 class="page-title">上游管理</h2>
|
|
||||||
<p class="page-desc">
|
|
||||||
管理 API 上游服务、认证方式与轮询策略。这里优先展示健康度、检测节奏和错误信号,减少你在异常发生时的定位成本。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="toolbar-cluster hero-actions">
|
|
||||||
<el-button @click="loadList" :loading="tableLoading">
|
|
||||||
<el-icon><Refresh /></el-icon>
|
|
||||||
刷新列表
|
|
||||||
</el-button>
|
|
||||||
<el-button type="primary" @click="openCreate">
|
|
||||||
<el-icon><Plus /></el-icon>
|
|
||||||
新增上游
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="metric-grid">
|
|
||||||
<article class="surface-card metric-card">
|
|
||||||
<div class="metric-label">Total Sources</div>
|
|
||||||
<div class="metric-value">{{ metrics.total }}</div>
|
|
||||||
<p class="metric-note">当前纳管的上游节点总数</p>
|
|
||||||
</article>
|
|
||||||
<article class="surface-card metric-card">
|
|
||||||
<div class="metric-label">Healthy</div>
|
|
||||||
<div class="metric-value">{{ metrics.healthy }}</div>
|
|
||||||
<p class="metric-note">最近一次检测返回健康状态</p>
|
|
||||||
</article>
|
|
||||||
<article class="surface-card metric-card">
|
|
||||||
<div class="metric-label">Enabled</div>
|
|
||||||
<div class="metric-value">{{ metrics.enabled }}</div>
|
|
||||||
<p class="metric-note">已启用定时检测的上游节点</p>
|
|
||||||
</article>
|
|
||||||
<article class="surface-card metric-card">
|
|
||||||
<div class="metric-label">Attention</div>
|
|
||||||
<div class="metric-value">{{ metrics.unhealthy }}</div>
|
|
||||||
<p class="metric-note">需要处理错误或网络异常的节点</p>
|
|
||||||
</article>
|
|
||||||
<article class="surface-card metric-card">
|
|
||||||
<div class="metric-label">Balance</div>
|
|
||||||
<div class="metric-value">{{ metrics.balanceCount }}</div>
|
|
||||||
<p class="metric-note">已配置余额接口的上游节点数</p>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="upstreams-content">
|
|
||||||
<section class="surface-card data-stage">
|
<section class="surface-card data-stage">
|
||||||
<div class="section-header data-stage-head">
|
<div class="section-header data-stage-head">
|
||||||
<div>
|
<div>
|
||||||
<div class="section-caption">Upstream Registry</div>
|
<div class="section-caption">Upstream Registry</div>
|
||||||
<h3 class="data-stage-title brand-type">检测与变更控制台</h3>
|
<h3 class="data-stage-title brand-type">检测与变更控制台</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="data-stage-note">点击详情可查看快照历史、分组倍率与最近错误。</p>
|
<div class="toolbar-cluster">
|
||||||
|
<el-button size="small" text @click="loadList" :loading="tableLoading">刷新</el-button>
|
||||||
|
<el-button size="small" type="primary" @click="openCreate">新增上游</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table :data="list" v-loading="tableLoading" row-key="id" style="width: 100%">
|
<el-table :data="list" v-loading="tableLoading" row-key="id" style="width: 100%">
|
||||||
@@ -119,109 +72,21 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="操作" width="258" fixed="right">
|
<el-table-column label="操作" width="280">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="action-row">
|
<div class="action-row">
|
||||||
<el-button size="small" text @click="openEdit(row)" title="编辑">
|
<el-button size="small" text @click="openEdit(row)" title="编辑上游配置(认证、接口、余额等)">编辑</el-button>
|
||||||
<el-icon><Edit /></el-icon>
|
<el-button size="small" text @click="testUpstream(row)" :loading="row._testing" title="仅验证连通性:登录 + 拉取分组列表,不写快照、不触发通知">测试连接</el-button>
|
||||||
</el-button>
|
<el-button size="small" text @click="checkNow(row)" :loading="row._checking" title="完整同步:拉取倍率 → 生成快照 → 对比变化 → 触发 Webhook → 同步 Key">立即同步</el-button>
|
||||||
<el-button size="small" text @click="testUpstream(row)" :loading="row._testing">测试</el-button>
|
<el-button size="small" text @click="openKeyGenerate(row)" title="为每个分组确保存在一个 SmartUp 托管 Key">生成Key</el-button>
|
||||||
<el-button size="small" text @click="checkNow(row)" :loading="row._checking">检测</el-button>
|
<el-button size="small" text @click="openDetail(row)" title="查看快照历史、分组倍率与已创建 Key">详情</el-button>
|
||||||
<el-button size="small" text @click="openKeyGenerate(row)" title="确保每个分组有一个 SmartUp Key">
|
<el-button size="small" text type="danger" @click="confirmDelete(row)" title="删除该上游及其所有快照和 Key 记录">删除</el-button>
|
||||||
<el-icon><Key /></el-icon>
|
|
||||||
</el-button>
|
|
||||||
<el-button size="small" text @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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</section>
|
</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>
|
|
||||||
<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 @click="openDetail(row)">查看</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="empty-hint side-empty">还没有检测记录</div>
|
|
||||||
</section>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<el-drawer
|
<el-drawer
|
||||||
v-model="drawerVisible"
|
v-model="drawerVisible"
|
||||||
@@ -314,6 +179,10 @@
|
|||||||
<el-input-number v-model="form.balance_divisor" :min="1" :max="999999999" style="width: 100%" />
|
<el-input-number v-model="form.balance_divisor" :min="1" :max="999999999" style="width: 100%" />
|
||||||
<div class="form-hint">原始值除以该数得到实际余额。New-API 填 <code>500000</code>,Sub2API 填 <code>1</code></div>
|
<div class="form-hint">原始值除以该数得到实际余额。New-API 填 <code>500000</code>,Sub2API 填 <code>1</code></div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="余额告警阈值">
|
||||||
|
<el-input-number v-model="form.balance_alert_threshold" :min="0" :precision="2" :value-on-clear="null" style="width: 100%" />
|
||||||
|
<div class="form-hint">余额低于此值时发送 Webhook 通知,留空/0 表示不监控</div>
|
||||||
|
</el-form-item>
|
||||||
<el-row :gutter="12">
|
<el-row :gutter="12">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="检测间隔(秒)">
|
<el-form-item label="检测间隔(秒)">
|
||||||
@@ -553,6 +422,7 @@ const defaultForm = () => ({
|
|||||||
balance_endpoint: '',
|
balance_endpoint: '',
|
||||||
balance_response_path: '',
|
balance_response_path: '',
|
||||||
balance_divisor: 1.0,
|
balance_divisor: 1.0,
|
||||||
|
balance_alert_threshold: null as number | null,
|
||||||
})
|
})
|
||||||
const form = ref(defaultForm())
|
const form = ref(defaultForm())
|
||||||
const rules = {
|
const rules = {
|
||||||
@@ -775,6 +645,7 @@ function openEdit(row: UpstreamData) {
|
|||||||
balance_endpoint: row.balance_endpoint || '',
|
balance_endpoint: row.balance_endpoint || '',
|
||||||
balance_response_path: row.balance_response_path || '',
|
balance_response_path: row.balance_response_path || '',
|
||||||
balance_divisor: row.balance_divisor ?? 1.0,
|
balance_divisor: row.balance_divisor ?? 1.0,
|
||||||
|
balance_alert_threshold: row.balance_alert_threshold ?? null,
|
||||||
}
|
}
|
||||||
drawerVisible.value = true
|
drawerVisible.value = true
|
||||||
}
|
}
|
||||||
@@ -1366,6 +1237,16 @@ onMounted(loadList)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row .el-button {
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
.action-row .el-button--danger {
|
.action-row .el-button--danger {
|
||||||
--el-button-bg-color: transparent;
|
--el-button-bg-color: transparent;
|
||||||
--el-button-border-color: transparent;
|
--el-button-border-color: transparent;
|
||||||
@@ -1374,4 +1255,12 @@ onMounted(loadList)
|
|||||||
--el-button-bg-color: var(--el-color-danger-light-8, #fef0f0);
|
--el-button-bg-color: var(--el-color-danger-light-8, #fef0f0);
|
||||||
--el-button-border-color: var(--el-color-danger-light-5, #fbc4c4);
|
--el-button-border-color: var(--el-color-danger-light-5, #fbc4c4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__fixed-right) {
|
||||||
|
background: var(--surface-bg, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__fixed-right .el-table__cell) {
|
||||||
|
background: var(--surface-bg, #1a1a1a) !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="shell-page shell-page-fluid page-section">
|
<div class="shell-page shell-page-fluid page-section">
|
||||||
<div class="page-header surface-card page-block">
|
<div class="panel">
|
||||||
<div class="page-heading">
|
<div class="panel-head">
|
||||||
<p class="page-kicker">Delivery Mesh</p>
|
<div class="panel-title">Webhook 接收器</div>
|
||||||
<h2 class="page-title">Webhook 通知</h2>
|
<div class="panel-actions">
|
||||||
<p class="page-desc">配置 Webhook 接收器,支持通用 JSON 和钉钉机器人</p>
|
<el-button size="small" text @click="loadList" :loading="tableLoading">刷新</el-button>
|
||||||
|
<el-button size="small" type="primary" @click="openCreate">新增 Webhook</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" @click="openCreate">
|
|
||||||
<el-icon><Plus /></el-icon> 新增 Webhook
|
|
||||||
</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card page-block">
|
|
||||||
<el-table :data="list" v-loading="tableLoading" style="width:100%">
|
<el-table :data="list" v-loading="tableLoading" style="width:100%">
|
||||||
<el-table-column label="名称" min-width="140">
|
<el-table-column label="名称" min-width="140">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -40,13 +36,13 @@
|
|||||||
<span class="time-text">{{ fmtTime(row.created_at) }}</span>
|
<span class="time-text">{{ fmtTime(row.created_at) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="200" fixed="right">
|
<el-table-column label="操作" width="240">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button size="small" text @click="openEdit(row)"><el-icon><Edit /></el-icon></el-button>
|
<div class="action-row">
|
||||||
<el-button size="small" text type="success" @click="testWebhook(row)" :loading="row._testing">
|
<el-button size="small" text @click="openEdit(row)" title="修改 Webhook 名称、地址或绑定的订阅事件">编辑</el-button>
|
||||||
测试
|
<el-button size="small" text type="success" @click="testWebhook(row)" :loading="row._testing" title="发送一条测试 Payload 到该 Webhook URL 以验证可达性">测试</el-button>
|
||||||
</el-button>
|
<el-button size="small" text type="danger" @click="confirmDelete(row)" title="删除此 Webhook 接收器配置">删除</el-button>
|
||||||
<el-button size="small" text type="danger" @click="confirmDelete(row)"><el-icon><Delete /></el-icon></el-button>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -76,6 +72,7 @@
|
|||||||
<el-checkbox label="website_rate_changed">网站倍率变更</el-checkbox>
|
<el-checkbox label="website_rate_changed">网站倍率变更</el-checkbox>
|
||||||
<el-checkbox label="upstream_unhealthy">服务异常</el-checkbox>
|
<el-checkbox label="upstream_unhealthy">服务异常</el-checkbox>
|
||||||
<el-checkbox label="upstream_recovered">服务恢复</el-checkbox>
|
<el-checkbox label="upstream_recovered">服务恢复</el-checkbox>
|
||||||
|
<el-checkbox label="upstream_balance_low">余额不足</el-checkbox>
|
||||||
</el-checkbox-group>
|
</el-checkbox-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="启用">
|
<el-form-item label="启用">
|
||||||
@@ -123,10 +120,11 @@ const EVENT_LABELS: Record<string, string> = {
|
|||||||
website_rate_changed: '网站倍率变更',
|
website_rate_changed: '网站倍率变更',
|
||||||
upstream_unhealthy: '服务异常',
|
upstream_unhealthy: '服务异常',
|
||||||
upstream_recovered: '服务恢复',
|
upstream_recovered: '服务恢复',
|
||||||
|
upstream_balance_low: '余额不足',
|
||||||
}
|
}
|
||||||
const eventLabel = (e: string) => EVENT_LABELS[e] || e
|
const eventLabel = (e: string) => EVENT_LABELS[e] || e
|
||||||
const eventTagType = (e: string) =>
|
const eventTagType = (e: string) =>
|
||||||
({ upstream_rate_changed: 'primary', website_rate_changed: 'warning', upstream_unhealthy: 'danger', upstream_recovered: 'success' }[e] || '')
|
({ upstream_rate_changed: 'primary', website_rate_changed: 'warning', upstream_unhealthy: 'danger', upstream_recovered: 'success', upstream_balance_low: 'warning' }[e] || '')
|
||||||
|
|
||||||
const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : t + 'Z'
|
const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : t + 'Z'
|
||||||
const fmtTime = (t: string) => dayjs(toUTC(t)).format('MM-DD HH:mm')
|
const fmtTime = (t: string) => dayjs(toUTC(t)).format('MM-DD HH:mm')
|
||||||
@@ -221,7 +219,31 @@ onMounted(loadList)
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.panel-head {
|
||||||
border-radius: var(--radius-shell);
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.action-row .el-button {
|
||||||
|
justify-self: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,23 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="shell-page shell-page-fluid 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>
|
|
||||||
<h2 class="page-title">网站管理</h2>
|
|
||||||
<p class="page-desc">管理自己的 sub2api 网站,并把网站分组倍率同步到上游监听结果</p>
|
|
||||||
</div>
|
|
||||||
<el-button type="primary" @click="openWebsiteCreate">
|
|
||||||
<el-icon><Plus /></el-icon> 新增网站
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div class="panel-title">网站</div>
|
<div class="panel-title">检测与变更控制台</div>
|
||||||
<div class="panel-actions">
|
<div class="panel-actions">
|
||||||
<el-button size="small" text :disabled="websites.length === 0" @click="openImportGroups(selectedWebsite || websites[0])">导入上游分组</el-button>
|
|
||||||
<el-button size="small" text :disabled="websites.length === 0" @click="openImportAccounts(selectedWebsite || websites[0])">导入为账号管理账号</el-button>
|
|
||||||
<el-button size="small" text @click="loadAll">刷新</el-button>
|
<el-button size="small" text @click="loadAll">刷新</el-button>
|
||||||
|
<el-button size="small" type="primary" @click="openWebsiteCreate">新增网站</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="websites" v-loading="websiteLoading" row-key="id" style="width:100%">
|
<el-table :data="websites" v-loading="websiteLoading" row-key="id" style="width:100%">
|
||||||
@@ -48,43 +36,16 @@
|
|||||||
<span v-else class="muted">—</span>
|
<span v-else class="muted">—</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="240" align="right">
|
<el-table-column label="操作" width="340">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="action-row">
|
<div class="action-row">
|
||||||
<el-tooltip content="编辑网站配置" placement="top" :show-after="300">
|
<el-button size="small" text @click="openWebsiteEdit(row)" title="编辑网站配置(API凭证、参数等)">编辑</el-button>
|
||||||
<el-button size="small" text class="btn-edit" @click="openWebsiteEdit(row)">
|
<el-button size="small" text @click="selectWebsite(row)" title="查看当前网站的分组和绑定配置">查看</el-button>
|
||||||
<el-icon class="btn-edit-icon"><Edit /></el-icon><span>编辑</span>
|
<el-button size="small" text @click="testWebsite(row)" :loading="row._testing" title="仅验证连通性,测试API是否正常响应">测试连接</el-button>
|
||||||
</el-button>
|
<el-button size="small" text @click="openBindingCreate(row)" title="为此网站创建一个新的上游倍率绑定规则">新增绑定</el-button>
|
||||||
</el-tooltip>
|
<el-button size="small" text @click="openImportGroups(row)" title="一键从上游导入可用分组到此网站">导入分组</el-button>
|
||||||
<el-tooltip content="查看分组" placement="top" :show-after="300">
|
<el-button size="small" text @click="openImportAccounts(row)" title="将此网站下的所有账户批量导入账号管理">导入账号</el-button>
|
||||||
<el-button size="small" circle text @click="selectWebsite(row)">
|
<el-button size="small" text type="danger" @click="deleteWebsite(row)" title="删除此网站及其所有的绑定配置">删除</el-button>
|
||||||
<el-icon><Grid /></el-icon>
|
|
||||||
</el-button>
|
|
||||||
</el-tooltip>
|
|
||||||
<el-dropdown trigger="click" @command="(cmd: string) => handleMoreAction(cmd, row)">
|
|
||||||
<el-button size="small" text class="btn-more" :loading="row._testing">
|
|
||||||
更多<el-icon v-if="!row._testing" class="el-icon--right"><ArrowDown /></el-icon>
|
|
||||||
</el-button>
|
|
||||||
<template #dropdown>
|
|
||||||
<el-dropdown-menu>
|
|
||||||
<el-dropdown-item command="test" :disabled="row._testing">
|
|
||||||
<el-icon><Connection /></el-icon>连接测试
|
|
||||||
</el-dropdown-item>
|
|
||||||
<el-dropdown-item command="binding">
|
|
||||||
<el-icon><Link /></el-icon>新增绑定
|
|
||||||
</el-dropdown-item>
|
|
||||||
<el-dropdown-item command="importGroups">
|
|
||||||
<el-icon><Upload /></el-icon>导入上游分组
|
|
||||||
</el-dropdown-item>
|
|
||||||
<el-dropdown-item command="importAccounts">
|
|
||||||
<el-icon><Key /></el-icon>导入为账号管理账号
|
|
||||||
</el-dropdown-item>
|
|
||||||
<el-dropdown-item divided command="delete" class="btn-more-delete">
|
|
||||||
<el-icon><Delete /></el-icon>删除
|
|
||||||
</el-dropdown-item>
|
|
||||||
</el-dropdown-menu>
|
|
||||||
</template>
|
|
||||||
</el-dropdown>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -380,7 +341,25 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="优先级">
|
<el-form-item label="优先级">
|
||||||
<el-input-number v-model="importAccountsForm.priority" :min="0" style="width:100%" />
|
<template #label>
|
||||||
|
<span>优先级</span>
|
||||||
|
<el-tooltip content="按倍率自动分配优先级:倍率最低的上游分组优先级最高(priority=1),依次递增" placement="top" :show-after="300">
|
||||||
|
<el-icon style="margin-left:4px;vertical-align:middle;color:var(--text-muted)"><WarningFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px">
|
||||||
|
<el-switch
|
||||||
|
v-model="importAccountsForm.auto_priority_by_rate"
|
||||||
|
active-text="按倍率自动分配"
|
||||||
|
inactive-text="手动设置"
|
||||||
|
/>
|
||||||
|
<el-input-number
|
||||||
|
v-if="!importAccountsForm.auto_priority_by_rate"
|
||||||
|
v-model="importAccountsForm.priority"
|
||||||
|
:min="0"
|
||||||
|
style="width:160px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
@@ -458,7 +437,7 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import type { FormInstance } from 'element-plus'
|
import type { FormInstance } from 'element-plus'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { ArrowDown, Delete, Edit, Plus, Grid, Connection, Link, Upload, Key, Refresh } from '@element-plus/icons-vue'
|
import { ArrowDown, Delete, Edit, Plus, Grid, Connection, Link, Upload, Key, Refresh, WarningFilled } from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
upstreamsApi,
|
upstreamsApi,
|
||||||
websitesApi,
|
websitesApi,
|
||||||
@@ -571,6 +550,7 @@ const importAccountsForm = ref({
|
|||||||
platform_mode: 'auto',
|
platform_mode: 'auto',
|
||||||
concurrency: 10,
|
concurrency: 10,
|
||||||
priority: 1,
|
priority: 1,
|
||||||
|
auto_priority_by_rate: true,
|
||||||
})
|
})
|
||||||
const importAccountResults = ref<ImportAccountItem[]>([])
|
const importAccountResults = ref<ImportAccountItem[]>([])
|
||||||
|
|
||||||
@@ -1027,6 +1007,7 @@ async function openImportAccounts(site?: WebsiteData | null) {
|
|||||||
platform_mode: 'auto',
|
platform_mode: 'auto',
|
||||||
concurrency: 10,
|
concurrency: 10,
|
||||||
priority: 1,
|
priority: 1,
|
||||||
|
auto_priority_by_rate: true,
|
||||||
}
|
}
|
||||||
importAccountResults.value = []
|
importAccountResults.value = []
|
||||||
importSyncStatus.value = null
|
importSyncStatus.value = null
|
||||||
@@ -1151,6 +1132,7 @@ async function submitImportAccounts() {
|
|||||||
platform_mode: importAccountsForm.value.platform_mode,
|
platform_mode: importAccountsForm.value.platform_mode,
|
||||||
concurrency: importAccountsForm.value.concurrency,
|
concurrency: importAccountsForm.value.concurrency,
|
||||||
priority: importAccountsForm.value.priority,
|
priority: importAccountsForm.value.priority,
|
||||||
|
auto_priority_by_rate: importAccountsForm.value.auto_priority_by_rate,
|
||||||
})
|
})
|
||||||
importAccountResults.value = res.data.items
|
importAccountResults.value = res.data.items
|
||||||
ElMessage[res.data.success ? 'success' : 'warning'](res.data.message)
|
ElMessage[res.data.success ? 'success' : 'warning'](res.data.message)
|
||||||
@@ -1205,47 +1187,12 @@ onMounted(loadAll)
|
|||||||
.error-text { font-size: 12px; color: var(--color-danger); cursor: pointer; }
|
.error-text { font-size: 12px; color: var(--color-danger); cursor: pointer; }
|
||||||
.rate-value { font-weight: 600; color: var(--color-primary-strong); font-family: monospace; }
|
.rate-value { font-weight: 600; color: var(--color-primary-strong); font-family: monospace; }
|
||||||
.action-row {
|
.action-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: center;
|
grid-template-columns: repeat(4, 1fr);
|
||||||
justify-content: flex-end;
|
gap: 0;
|
||||||
flex-wrap: nowrap;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
.action-row .el-button.is-circle {
|
.action-row .el-button {
|
||||||
width: 26px;
|
justify-self: center;
|
||||||
height: 26px;
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
.action-row .btn-edit {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
gap: 3px;
|
|
||||||
padding: 0 6px;
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.action-row .btn-edit-icon {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.action-row .btn-edit:hover {
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
background: var(--el-color-primary-light-9);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.action-row .btn-more {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 0 4px;
|
|
||||||
}
|
|
||||||
.action-row .btn-more:hover {
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
}
|
|
||||||
.action-row .btn-more .el-icon--right {
|
|
||||||
margin-left: 1px;
|
|
||||||
}
|
|
||||||
.btn-more-delete {
|
|
||||||
color: var(--el-color-danger);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-switcher {
|
.site-switcher {
|
||||||
|
|||||||
Reference in New Issue
Block a user