diff --git a/electron/src/renderer/src/NetworkDashboard.tsx b/electron/src/renderer/src/NetworkDashboard.tsx index b015052..22703f1 100644 --- a/electron/src/renderer/src/NetworkDashboard.tsx +++ b/electron/src/renderer/src/NetworkDashboard.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' import { apiFetch, apiUrl } from './api' -import { saveWorkspaceSnapshot } from './workspace' +import { saveWorkspaceSnapshot, loadWorkspaceSnapshot } from './workspace' import ProxySettings from './ProxySettings' import { type FlowStep } from './components/worker/WorkerFlowStrip' import LivePulse from './components/ui/LivePulse' @@ -308,74 +308,44 @@ export default function NetworkDashboard() { const [data, setData] = useState(null) const [ipProfile, setIpProfile] = useState(null) const [ipProfileErr, setIpProfileErr] = useState(null) - const [ipProfileLoading, setIpProfileLoading] = useState(true) + const [ipProfileLoading, setIpProfileLoading] = useState(false) const [ipQuality, setIpQuality] = useState(null) const [ipQualityErr, setIpQualityErr] = useState(null) - const [ipQualityLoading, setIpQualityLoading] = useState(true) + const [ipQualityLoading, setIpQualityLoading] = useState(false) const [proxyCfg, setProxyCfg] = useState({ enabled: false, http: '', socks: '' }) const [err, setErr] = useState(null) - const [loading, setLoading] = useState(true) + const [loading, setLoading] = useState(false) const [refreshing, setRefreshing] = useState(false) + const [hasCachedData, setHasCachedData] = useState(false) const { leak: webrtcLeak } = useWebRtcLeak(true) - const loadIpProfile = useCallback(async () => { - setIpProfileErr(null) - setIpProfileLoading(true) - try { - const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || '' - const lang = navigator.language || '' - const q = new URLSearchParams({ tz, lang }) - if (webrtcLeak != null) q.set('webrtc_leak', webrtcLeak ? '1' : '0') - const res = await apiFetch(apiUrl(`/ip_profile?${q.toString()}`)) - const json = (await res.json()) as IpProfilePayload - if (!res.ok) { - setIpProfileErr(json.error || `IP 画像请求失败 ${res.status}`) - setIpProfile(null) - return - } - setIpProfile(json) - saveWorkspaceSnapshot('ip_profile', json) - } catch (e) { - setIpProfileErr(e instanceof Error ? e.message : String(e)) - setIpProfile(null) - } finally { - setIpProfileLoading(false) - } - }, [webrtcLeak]) - - const loadIpQuality = useCallback(async () => { - setIpQualityErr(null) - setIpQualityLoading(true) - try { - const res = await apiFetch(apiUrl('/ip_quality')) - const json = (await res.json()) as IpQualityPayload - if (!res.ok) { - setIpQualityErr(json.error || `IP 质量请求失败 ${res.status}`) - setIpQuality(null) - return - } - setIpQuality(json) - saveWorkspaceSnapshot('ip_quality', json) - } catch (e) { - setIpQualityErr(e instanceof Error ? e.message : String(e)) - setIpQuality(null) - } finally { - setIpQualityLoading(false) - } - }, []) - - const load = useCallback(async (opts?: { silent?: boolean }) => { + const load = useCallback(async (opts?: { silent?: boolean; forceRefresh?: boolean }) => { const silent = opts?.silent ?? false + const forceRefresh = opts?.forceRefresh ?? false + if (!forceRefresh) return setErr(null) if (!silent) setLoading(true) else setRefreshing(true) + setIpProfileLoading(true) + setIpQualityLoading(true) try { - const [netRes, proxyRes] = await Promise.all([ - apiFetch(apiUrl('/network_status')), + const q = '?force_refresh=1' + const [netRes, proxyRes, profileRes, qualityRes] = await Promise.all([ + apiFetch(apiUrl(`/network_status${q}`)), apiFetch(apiUrl('/proxy_config')), + apiFetch( + apiUrl( + `/ip_profile?${new URLSearchParams({ + tz: Intl.DateTimeFormat().resolvedOptions().timeZone || '', + lang: navigator.language || '', + ...(webrtcLeak != null ? { webrtc_leak: webrtcLeak ? '1' : '0' } : {}), + force_refresh: '1', + }).toString()}`, + ), + ), + apiFetch(apiUrl('/ip_quality?force_refresh=1')), ]) - void loadIpProfile() - void loadIpQuality() + const json = (await netRes.json()) as NetworkPayload & { error?: string } if (!netRes.ok) { setErr(json.error || `请求失败 ${netRes.status}`) @@ -383,7 +353,29 @@ export default function NetworkDashboard() { } else { setData(json) saveWorkspaceSnapshot('network_status', json) + setHasCachedData(true) } + + const profileJson = (await profileRes.json()) as IpProfilePayload + if (!profileRes.ok) { + setIpProfileErr(profileJson.error || `IP 画像请求失败 ${profileRes.status}`) + setIpProfile(null) + } else { + setIpProfile(profileJson) + setIpProfileErr(null) + saveWorkspaceSnapshot('ip_profile', profileJson) + } + + const qualityJson = (await qualityRes.json()) as IpQualityPayload + if (!qualityRes.ok) { + setIpQualityErr(qualityJson.error || `IP 质量请求失败 ${qualityRes.status}`) + setIpQuality(null) + } else { + setIpQuality(qualityJson) + setIpQualityErr(null) + saveWorkspaceSnapshot('ip_quality', qualityJson) + } + if (proxyRes.ok) { const proxyJson = (await proxyRes.json()) as ProxyCfg setProxyCfg({ @@ -398,22 +390,44 @@ export default function NetworkDashboard() { } finally { setLoading(false) setRefreshing(false) + setIpProfileLoading(false) + setIpQualityLoading(false) } - }, [loadIpProfile, loadIpQuality]) + }, [webrtcLeak]) useEffect(() => { - void load() - }, [load]) - - useEffect(() => { - const id = window.setInterval(() => void load({ silent: true }), 45000) - return () => clearInterval(id) - }, [load]) - - useEffect(() => { - if (webrtcLeak == null) return - void loadIpProfile() - }, [webrtcLeak, loadIpProfile]) + let cancelled = false + void (async () => { + const [net, profile, quality] = await Promise.all([ + loadWorkspaceSnapshot('network_status'), + loadWorkspaceSnapshot('ip_profile'), + loadWorkspaceSnapshot('ip_quality'), + ]) + if (cancelled) return + if (net) { + setData(net) + setHasCachedData(true) + } + if (profile) setIpProfile(profile) + if (quality) setIpQuality(quality) + try { + const proxyRes = await apiFetch(apiUrl('/proxy_config')) + if (!cancelled && proxyRes.ok) { + const proxyJson = (await proxyRes.json()) as ProxyCfg + setProxyCfg({ + enabled: !!proxyJson.enabled, + http: proxyJson.http || '', + socks: proxyJson.socks || '', + }) + } + } catch { + /* 代理配置读取失败不影响展示缓存结果 */ + } + })() + return () => { + cancelled = true + } + }, []) const view = data ?? DEFAULT_NETWORK const status = overallStatus(view, loading) @@ -479,7 +493,15 @@ export default function NetworkDashboard() { : ['net-routes__pill--no', '境内外出口可能未区分'] const statusLabel = - loading ? '检测中' : status === 'ready' ? '网络就绪' : status === 'warn' ? '需关注' : '出口异常' + loading || refreshing + ? '检测中' + : !hasCachedData + ? '待检测' + : status === 'ready' + ? '网络就绪' + : status === 'warn' + ? '需关注' + : '出口异常' const pulseTone = status === 'ready' ? 'live' : status === 'warn' ? 'warn' : 'idle' @@ -517,10 +539,10 @@ export default function NetworkDashboard() { type="button" className="btn btn-primary btn--sm net-summary__btn" disabled={loading || refreshing || ipProfileLoading || ipQualityLoading} - onClick={() => void load({ silent: !!data })} + onClick={() => void load({ silent: hasCachedData, forceRefresh: true })} > - {loading || refreshing ? '检测中…' : '重新检测'} + {loading || refreshing ? '检测中…' : hasCachedData ? '重新检测' : '开始检测'} + {err ? ( @@ -209,7 +240,7 @@ export default function SystemMetrics() {
磁盘空间
    {data.disks.slice(0, 8).map((d) => ( -
  • +
  • {d.mountpoint} {d.fstype ? ` (${d.fstype})` : ''} diff --git a/electron/src/renderer/src/workspace.ts b/electron/src/renderer/src/workspace.ts index 556c4da..b47c1e5 100644 --- a/electron/src/renderer/src/workspace.ts +++ b/electron/src/renderer/src/workspace.ts @@ -25,6 +25,18 @@ export function saveWorkspaceSnapshot(key: string, data: unknown): void { void getWorkspace()?.saveSnapshot(key, data) } +export async function loadWorkspaceSnapshot(key: string): Promise { + const ws = getWorkspace() + if (!ws) return null + try { + const dash = await ws.getDashboard() + const item = dash.snapshots[key] + return item?.data != null ? (item.data as T) : null + } catch { + return null + } +} + export function logUserAction(action: string, nav?: string, detail?: unknown): void { void getWorkspace()?.logUserAction({ action, nav, detail }) } diff --git a/web2.py b/web2.py index 9af87bf..88850e2 100644 --- a/web2.py +++ b/web2.py @@ -19,9 +19,7 @@ from web2_supervisor import ( strip_ansi_codes, use_builtin, ) -from web2_network import run_network_dashboard -from web2_ip_profile import run_ip_profile -from web2_ip_quality import run_ip_quality +from web2_network_routes import router as network_router from web2_proxy_config import read_proxy_config, write_proxy_config from web2_system_metrics import snapshot as system_metrics_snapshot from web2_youtube_oauth import ( @@ -100,6 +98,9 @@ CONFIG_DIR = BASE_DIR / "config" DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs" RELAY_DB = relay_db_path(BASE_DIR) _PROCESS_STATUS_CACHE: dict[str, str] = {} +_SYSTEM_METRICS_TTL_SECONDS = 10.0 +_SYSTEM_METRICS_CACHE: tuple[float, dict] | None = None +_SYSTEM_METRICS_CACHE_LOCK = asyncio.Lock() MAX_LOG_LINES = 300 @@ -302,6 +303,7 @@ app.add_middleware( allow_methods=["GET", "POST", "OPTIONS"], allow_headers=["*"], ) +app.include_router(network_router) @app.get("/client/overview") async def client_overview(): @@ -1095,40 +1097,6 @@ async def health(): return {"ok": True} -@app.get("/network_status") -async def network_status(): - """Google / 抖音 连通与简易网速(供仪表盘)。""" - try: - return await run_network_dashboard() - except Exception as e: - return JSONResponse({"error": str(e)}, status_code=500) - - -@app.get("/ip_profile") -async def ip_profile( - tz: str = Query("", description="客户端 IANA 时区,如 Asia/Shanghai"), - lang: str = Query("", description="客户端语言,如 zh-CN"), - webrtc_leak: Optional[str] = Query(None, description="1/true 表示检测到 WebRTC 本地泄露"), -): - """静态住宅 IP / 出口画像(参考 whoer、ping0.cc)。""" - leak_flag: Optional[bool] = None - if webrtc_leak is not None: - leak_flag = str(webrtc_leak).strip().lower() in ("1", "true", "yes") - try: - return await run_ip_profile(client_tz=tz.strip(), client_lang=lang.strip(), webrtc_leak=leak_flag) - except Exception as e: - return JSONResponse({"error": str(e)}, status_code=500) - - -@app.get("/ip_quality") -async def ip_quality(): - """IP 质量检测(集成 xykt/IPQuality 数据源与流媒体解锁)。""" - try: - return await run_ip_quality() - except Exception as e: - return JSONResponse({"error": str(e)}, status_code=500) - - @app.get("/proxy_config") async def get_proxy_config(): """仅作用于录制子进程的代理(不写 Windows 系统代理)。""" @@ -1154,10 +1122,25 @@ async def post_proxy_config(request: Request): @app.get("/system_metrics") -async def system_metrics(): +async def system_metrics( + force_refresh: bool = Query(False, description="是否跳过缓存强制重新检测"), +): """CPU/内存/磁盘/网卡速率与 ffmpeg 进程摘要。""" + global _SYSTEM_METRICS_CACHE try: - return system_metrics_snapshot() + now = time.time() + async with _SYSTEM_METRICS_CACHE_LOCK: + if ( + not force_refresh + and _SYSTEM_METRICS_CACHE + and (now - _SYSTEM_METRICS_CACHE[0]) < _SYSTEM_METRICS_TTL_SECONDS + ): + return _SYSTEM_METRICS_CACHE[1] + + payload = await asyncio.to_thread(system_metrics_snapshot) + async with _SYSTEM_METRICS_CACHE_LOCK: + _SYSTEM_METRICS_CACHE = (time.time(), payload) + return payload except Exception as e: return JSONResponse({"error": str(e)}, status_code=500) diff --git a/web2_host_caps.py b/web2_host_caps.py index 7adbe64..ca7cc11 100644 --- a/web2_host_caps.py +++ b/web2_host_caps.py @@ -4,6 +4,8 @@ from __future__ import annotations import platform import socket import subprocess +import threading +import time from typing import Any @@ -20,8 +22,10 @@ def detect_nvidia_gpu() -> bool: return False -def detect_nvenc_available() -> bool: - if not detect_nvidia_gpu(): +def detect_nvenc_available(nvidia_present: bool | None = None) -> bool: + if nvidia_present is None: + nvidia_present = detect_nvidia_gpu() + if not nvidia_present: return False try: _kw = {"capture_output": True, "text": True, "timeout": 8} @@ -33,13 +37,27 @@ def detect_nvenc_available() -> bool: return False -def gpu_status_payload() -> dict[str, Any]: +_GPU_STATUS_CACHE_TTL_SECONDS = 60.0 +_gpu_status_lock = threading.Lock() +_gpu_status_cache: tuple[float, dict[str, Any]] | None = None + + +def gpu_status_payload(force_refresh: bool = False) -> dict[str, Any]: + global _gpu_status_cache + now = time.time() + with _gpu_status_lock: + if not force_refresh and _gpu_status_cache and (now - _gpu_status_cache[0]) < _GPU_STATUS_CACHE_TTL_SECONDS: + return dict(_gpu_status_cache[1]) + nv = detect_nvidia_gpu() - enc = detect_nvenc_available() - return { + enc = detect_nvenc_available(nv) + payload = { "nvidia_detected": nv, "nvenc_available": enc, } + with _gpu_status_lock: + _gpu_status_cache = (time.time(), payload) + return dict(payload) def machine_summary_payload() -> dict[str, Any]: diff --git a/web2_ip_profile.py b/web2_ip_profile.py index 8ba2db3..6db317b 100644 --- a/web2_ip_profile.py +++ b/web2_ip_profile.py @@ -351,7 +351,13 @@ def _build_checks( if client_lang and d.get("country_code"): lang = client_lang.lower() cc = (d.get("country_code") or "").lower() - lang_ok = (cc == "cn" and lang.startswith("zh")) or (cc == "us" and lang.startswith("en")) or True + if cc == "cn": + lang_ok = lang.startswith("zh") + elif cc == "us": + lang_ok = lang.startswith("en") + else: + # 非中美地区不做严格语言判定,避免误报。 + lang_ok = True add("lang_soft", "浏览器语言与 IP 地区不冲突", lang_ok, client_lang) if webrtc_leak is not None: diff --git a/web2_network_routes.py b/web2_network_routes.py new file mode 100644 index 0000000..15d50bb --- /dev/null +++ b/web2_network_routes.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import asyncio +import time +from typing import Any, Awaitable, Callable, Optional + +from fastapi import APIRouter, Query +from fastapi.responses import JSONResponse + +from web2_ip_profile import run_ip_profile +from web2_ip_quality import run_ip_quality +from web2_network import run_network_dashboard + +router = APIRouter() + +_PROFILE_TTL_SECONDS = 300.0 +_QUALITY_TTL_SECONDS = 600.0 +_NETWORK_TTL_SECONDS = 300.0 + +_cache_lock = asyncio.Lock() +_profile_cache: dict[str, tuple[float, dict[str, Any]]] = {} +_quality_cache: tuple[float, dict[str, Any]] | None = None +_network_cache: tuple[float, dict[str, Any]] | None = None +_inflight: dict[str, asyncio.Task[dict[str, Any]]] = {} + + +def _profile_cache_key(tz: str, lang: str, leak_flag: Optional[bool]) -> str: + leak = "none" if leak_flag is None else ("1" if leak_flag else "0") + return f"{tz}|{lang}|{leak}" + + +async def _cached_fetch( + key: str, + *, + ttl: float, + force_refresh: bool, + fetcher: Callable[[], Awaitable[dict[str, Any]]], + store: Callable[[str, float, dict[str, Any]], None], + load: Callable[[str], Optional[tuple[float, dict[str, Any]]]], +) -> dict[str, Any]: + now = time.time() + if not force_refresh: + cached = load(key) + if cached and (now - cached[0]) < ttl: + return cached[1] + + async with _cache_lock: + if not force_refresh: + cached = load(key) + if cached and (time.time() - cached[0]) < ttl: + return cached[1] + inflight = _inflight.get(key) + if inflight is not None: + return await asyncio.shield(inflight) + + task = asyncio.create_task(fetcher()) + _inflight[key] = task + + try: + payload = await task + finally: + async with _cache_lock: + if _inflight.get(key) is task: + _inflight.pop(key, None) + + store(key, time.time(), payload) + return payload + + +def _store_profile(key: str, at: float, payload: dict[str, Any]) -> None: + _profile_cache[key] = (at, payload) + + +def _load_profile(key: str) -> Optional[tuple[float, dict[str, Any]]]: + return _profile_cache.get(key) + + +def _store_quality(key: str, at: float, payload: dict[str, Any]) -> None: + global _quality_cache + _quality_cache = (at, payload) + + +def _load_quality(_key: str) -> Optional[tuple[float, dict[str, Any]]]: + return _quality_cache + + +def _store_network(_key: str, at: float, payload: dict[str, Any]) -> None: + global _network_cache + _network_cache = (at, payload) + + +def _load_network(_key: str) -> Optional[tuple[float, dict[str, Any]]]: + return _network_cache + + +async def _get_ip_profile_cached( + *, + tz: str, + lang: str, + leak_flag: Optional[bool], + force_refresh: bool, +) -> dict[str, Any]: + cache_key = _profile_cache_key(tz, lang, leak_flag) + return await _cached_fetch( + cache_key, + ttl=_PROFILE_TTL_SECONDS, + force_refresh=force_refresh, + fetcher=lambda: run_ip_profile(client_tz=tz, client_lang=lang, webrtc_leak=leak_flag), + store=_store_profile, + load=_load_profile, + ) + + +async def _get_ip_quality_cached(*, force_refresh: bool) -> dict[str, Any]: + return await _cached_fetch( + "quality", + ttl=_QUALITY_TTL_SECONDS, + force_refresh=force_refresh, + fetcher=run_ip_quality, + store=_store_quality, + load=_load_quality, + ) + + +async def _get_network_status_cached(*, force_refresh: bool) -> dict[str, Any]: + return await _cached_fetch( + "network", + ttl=_NETWORK_TTL_SECONDS, + force_refresh=force_refresh, + fetcher=run_network_dashboard, + store=_store_network, + load=_load_network, + ) + + +@router.get("/network_status") +async def network_status( + force_refresh: bool = Query(False, description="是否跳过缓存强制重新检测"), +): + """Google / 抖音 连通与简易网速(供仪表盘)。""" + try: + return await _get_network_status_cached(force_refresh=force_refresh) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + + +@router.get("/ip_profile") +async def ip_profile( + tz: str = Query("", description="客户端 IANA 时区,如 Asia/Shanghai"), + lang: str = Query("", description="客户端语言,如 zh-CN"), + webrtc_leak: Optional[str] = Query(None, description="1/true 表示检测到 WebRTC 本地泄露"), + force_refresh: bool = Query(False, description="是否跳过缓存强制重新检测"), +): + """静态住宅 IP / 出口画像(参考 whoer、ping0.cc)。""" + leak_flag: Optional[bool] = None + if webrtc_leak is not None: + leak_flag = str(webrtc_leak).strip().lower() in ("1", "true", "yes") + try: + return await _get_ip_profile_cached( + tz=tz.strip(), + lang=lang.strip(), + leak_flag=leak_flag, + force_refresh=force_refresh, + ) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + + +@router.get("/ip_quality") +async def ip_quality( + force_refresh: bool = Query(False, description="是否跳过缓存强制重新检测"), +): + """IP 质量检测(集成 xykt/IPQuality 数据源与流媒体解锁)。""" + try: + return await _get_ip_quality_cached(force_refresh=force_refresh) + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500)