优化网络与系统检测为缓存优先,避免页面长时间停留在检测中。
将网络/IP 路由拆分并加入去重缓存,系统检测改为线程执行并支持强制刷新,前端改为先展示缓存快照后按需手动重测,降低阻塞与重复探测开销。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
import { apiFetch, apiUrl } from './api'
|
import { apiFetch, apiUrl } from './api'
|
||||||
import { saveWorkspaceSnapshot } from './workspace'
|
import { saveWorkspaceSnapshot, loadWorkspaceSnapshot } from './workspace'
|
||||||
import ProxySettings from './ProxySettings'
|
import ProxySettings from './ProxySettings'
|
||||||
import { type FlowStep } from './components/worker/WorkerFlowStrip'
|
import { type FlowStep } from './components/worker/WorkerFlowStrip'
|
||||||
import LivePulse from './components/ui/LivePulse'
|
import LivePulse from './components/ui/LivePulse'
|
||||||
@@ -308,74 +308,44 @@ export default function NetworkDashboard() {
|
|||||||
const [data, setData] = useState<NetworkPayload | null>(null)
|
const [data, setData] = useState<NetworkPayload | null>(null)
|
||||||
const [ipProfile, setIpProfile] = useState<IpProfilePayload | null>(null)
|
const [ipProfile, setIpProfile] = useState<IpProfilePayload | null>(null)
|
||||||
const [ipProfileErr, setIpProfileErr] = useState<string | null>(null)
|
const [ipProfileErr, setIpProfileErr] = useState<string | null>(null)
|
||||||
const [ipProfileLoading, setIpProfileLoading] = useState(true)
|
const [ipProfileLoading, setIpProfileLoading] = useState(false)
|
||||||
const [ipQuality, setIpQuality] = useState<IpQualityPayload | null>(null)
|
const [ipQuality, setIpQuality] = useState<IpQualityPayload | null>(null)
|
||||||
const [ipQualityErr, setIpQualityErr] = useState<string | null>(null)
|
const [ipQualityErr, setIpQualityErr] = useState<string | null>(null)
|
||||||
const [ipQualityLoading, setIpQualityLoading] = useState(true)
|
const [ipQualityLoading, setIpQualityLoading] = useState(false)
|
||||||
const [proxyCfg, setProxyCfg] = useState<ProxyCfg>({ enabled: false, http: '', socks: '' })
|
const [proxyCfg, setProxyCfg] = useState<ProxyCfg>({ enabled: false, http: '', socks: '' })
|
||||||
const [err, setErr] = useState<string | null>(null)
|
const [err, setErr] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(false)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [hasCachedData, setHasCachedData] = useState(false)
|
||||||
const { leak: webrtcLeak } = useWebRtcLeak(true)
|
const { leak: webrtcLeak } = useWebRtcLeak(true)
|
||||||
|
|
||||||
const loadIpProfile = useCallback(async () => {
|
const load = useCallback(async (opts?: { silent?: boolean; forceRefresh?: boolean }) => {
|
||||||
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 silent = opts?.silent ?? false
|
const silent = opts?.silent ?? false
|
||||||
|
const forceRefresh = opts?.forceRefresh ?? false
|
||||||
|
if (!forceRefresh) return
|
||||||
setErr(null)
|
setErr(null)
|
||||||
if (!silent) setLoading(true)
|
if (!silent) setLoading(true)
|
||||||
else setRefreshing(true)
|
else setRefreshing(true)
|
||||||
|
setIpProfileLoading(true)
|
||||||
|
setIpQualityLoading(true)
|
||||||
try {
|
try {
|
||||||
const [netRes, proxyRes] = await Promise.all([
|
const q = '?force_refresh=1'
|
||||||
apiFetch(apiUrl('/network_status')),
|
const [netRes, proxyRes, profileRes, qualityRes] = await Promise.all([
|
||||||
|
apiFetch(apiUrl(`/network_status${q}`)),
|
||||||
apiFetch(apiUrl('/proxy_config')),
|
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 }
|
const json = (await netRes.json()) as NetworkPayload & { error?: string }
|
||||||
if (!netRes.ok) {
|
if (!netRes.ok) {
|
||||||
setErr(json.error || `请求失败 ${netRes.status}`)
|
setErr(json.error || `请求失败 ${netRes.status}`)
|
||||||
@@ -383,7 +353,29 @@ export default function NetworkDashboard() {
|
|||||||
} else {
|
} else {
|
||||||
setData(json)
|
setData(json)
|
||||||
saveWorkspaceSnapshot('network_status', 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) {
|
if (proxyRes.ok) {
|
||||||
const proxyJson = (await proxyRes.json()) as ProxyCfg
|
const proxyJson = (await proxyRes.json()) as ProxyCfg
|
||||||
setProxyCfg({
|
setProxyCfg({
|
||||||
@@ -398,22 +390,44 @@ export default function NetworkDashboard() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
|
setIpProfileLoading(false)
|
||||||
|
setIpQualityLoading(false)
|
||||||
}
|
}
|
||||||
}, [loadIpProfile, loadIpQuality])
|
}, [webrtcLeak])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load()
|
let cancelled = false
|
||||||
}, [load])
|
void (async () => {
|
||||||
|
const [net, profile, quality] = await Promise.all([
|
||||||
useEffect(() => {
|
loadWorkspaceSnapshot<NetworkPayload>('network_status'),
|
||||||
const id = window.setInterval(() => void load({ silent: true }), 45000)
|
loadWorkspaceSnapshot<IpProfilePayload>('ip_profile'),
|
||||||
return () => clearInterval(id)
|
loadWorkspaceSnapshot<IpQualityPayload>('ip_quality'),
|
||||||
}, [load])
|
])
|
||||||
|
if (cancelled) return
|
||||||
useEffect(() => {
|
if (net) {
|
||||||
if (webrtcLeak == null) return
|
setData(net)
|
||||||
void loadIpProfile()
|
setHasCachedData(true)
|
||||||
}, [webrtcLeak, loadIpProfile])
|
}
|
||||||
|
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 view = data ?? DEFAULT_NETWORK
|
||||||
const status = overallStatus(view, loading)
|
const status = overallStatus(view, loading)
|
||||||
@@ -479,7 +493,15 @@ export default function NetworkDashboard() {
|
|||||||
: ['net-routes__pill--no', '境内外出口可能未区分']
|
: ['net-routes__pill--no', '境内外出口可能未区分']
|
||||||
|
|
||||||
const statusLabel =
|
const statusLabel =
|
||||||
loading ? '检测中' : status === 'ready' ? '网络就绪' : status === 'warn' ? '需关注' : '出口异常'
|
loading || refreshing
|
||||||
|
? '检测中'
|
||||||
|
: !hasCachedData
|
||||||
|
? '待检测'
|
||||||
|
: status === 'ready'
|
||||||
|
? '网络就绪'
|
||||||
|
: status === 'warn'
|
||||||
|
? '需关注'
|
||||||
|
: '出口异常'
|
||||||
|
|
||||||
const pulseTone = status === 'ready' ? 'live' : status === 'warn' ? 'warn' : 'idle'
|
const pulseTone = status === 'ready' ? 'live' : status === 'warn' ? 'warn' : 'idle'
|
||||||
|
|
||||||
@@ -517,10 +539,10 @@ export default function NetworkDashboard() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary btn--sm net-summary__btn"
|
className="btn btn-primary btn--sm net-summary__btn"
|
||||||
disabled={loading || refreshing || ipProfileLoading || ipQualityLoading}
|
disabled={loading || refreshing || ipProfileLoading || ipQualityLoading}
|
||||||
onClick={() => void load({ silent: !!data })}
|
onClick={() => void load({ silent: hasCachedData, forceRefresh: true })}
|
||||||
>
|
>
|
||||||
<IconRefresh className={loading || refreshing ? 'is-spinning' : ''} />
|
<IconRefresh className={loading || refreshing ? 'is-spinning' : ''} />
|
||||||
{loading || refreshing ? '检测中…' : '重新检测'}
|
{loading || refreshing ? '检测中…' : hasCachedData ? '重新检测' : '开始检测'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-secondary btn--sm" onClick={openProxySettings}>
|
<button type="button" className="btn btn-secondary btn--sm" onClick={openProxySettings}>
|
||||||
Windows 代理
|
Windows 代理
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { apiFetch, apiUrl } from './api'
|
import { apiFetch, apiUrl } from './api'
|
||||||
import { saveWorkspaceSnapshot } from './workspace'
|
import { saveWorkspaceSnapshot, loadWorkspaceSnapshot } from './workspace'
|
||||||
import MetricBar, { toneFromPercent } from './components/ui/MetricBar'
|
import MetricBar, { toneFromPercent } from './components/ui/MetricBar'
|
||||||
|
|
||||||
type Metrics = {
|
type Metrics = {
|
||||||
@@ -81,10 +81,18 @@ function InfoRow({ label, value }: { label: string; value: string }) {
|
|||||||
export default function SystemMetrics() {
|
export default function SystemMetrics() {
|
||||||
const [data, setData] = useState<Metrics | null>(null)
|
const [data, setData] = useState<Metrics | null>(null)
|
||||||
const [err, setErr] = useState<string | null>(null)
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [hasCachedData, setHasCachedData] = useState(false)
|
||||||
|
const inFlightRef = useRef(false)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async (opts?: { forceRefresh?: boolean }) => {
|
||||||
|
const forceRefresh = opts?.forceRefresh ?? false
|
||||||
|
if (!forceRefresh) return
|
||||||
|
if (inFlightRef.current) return
|
||||||
|
inFlightRef.current = true
|
||||||
|
setRefreshing(true)
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch(apiUrl('/system_metrics'))
|
const res = await apiFetch(apiUrl('/system_metrics?force_refresh=1'))
|
||||||
const json = (await res.json()) as Metrics
|
const json = (await res.json()) as Metrics
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setErr(json.error || `HTTP ${res.status}`)
|
setErr(json.error || `HTTP ${res.status}`)
|
||||||
@@ -92,17 +100,30 @@ export default function SystemMetrics() {
|
|||||||
}
|
}
|
||||||
setErr(null)
|
setErr(null)
|
||||||
setData(json)
|
setData(json)
|
||||||
|
setHasCachedData(true)
|
||||||
saveWorkspaceSnapshot('system_metrics', json)
|
saveWorkspaceSnapshot('system_metrics', json)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e instanceof Error ? e.message : String(e))
|
setErr(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false)
|
||||||
|
inFlightRef.current = false
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load()
|
let cancelled = false
|
||||||
const id = window.setInterval(() => void load(), 3000)
|
void (async () => {
|
||||||
return () => clearInterval(id)
|
const cached = await loadWorkspaceSnapshot<Metrics>('system_metrics')
|
||||||
}, [load])
|
if (cancelled) return
|
||||||
|
if (cached) {
|
||||||
|
setData(cached)
|
||||||
|
setHasCachedData(true)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const sys = data?.system
|
const sys = data?.system
|
||||||
const hw = data?.hardware
|
const hw = data?.hardware
|
||||||
@@ -122,7 +143,17 @@ export default function SystemMetrics() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="card__desc">本机操作系统、处理器、显卡与资源占用</p>
|
<p className="card__desc">本机操作系统、处理器、显卡与资源占用</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="metrics-uptime">⏱ 运行 {fmtUptime(sys?.boot_time_unix)}</span>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span className="metrics-uptime">⏱ 运行 {fmtUptime(sys?.boot_time_unix)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn--sm"
|
||||||
|
disabled={refreshing}
|
||||||
|
onClick={() => void load({ forceRefresh: true })}
|
||||||
|
>
|
||||||
|
{refreshing ? '检测中…' : hasCachedData ? '重新检测' : '开始检测'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{err ? (
|
{err ? (
|
||||||
@@ -209,7 +240,7 @@ export default function SystemMetrics() {
|
|||||||
<div className="metrics-disks__title">磁盘空间</div>
|
<div className="metrics-disks__title">磁盘空间</div>
|
||||||
<ul className="metrics-disk-list">
|
<ul className="metrics-disk-list">
|
||||||
{data.disks.slice(0, 8).map((d) => (
|
{data.disks.slice(0, 8).map((d) => (
|
||||||
<li key={d.mountpoint}>
|
<li key={`${d.mountpoint || 'unknown'}-${d.device || 'dev'}`}>
|
||||||
<span className="metrics-disk-list__mp">
|
<span className="metrics-disk-list__mp">
|
||||||
{d.mountpoint}
|
{d.mountpoint}
|
||||||
{d.fstype ? ` (${d.fstype})` : ''}
|
{d.fstype ? ` (${d.fstype})` : ''}
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ export function saveWorkspaceSnapshot(key: string, data: unknown): void {
|
|||||||
void getWorkspace()?.saveSnapshot(key, data)
|
void getWorkspace()?.saveSnapshot(key, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadWorkspaceSnapshot<T>(key: string): Promise<T | null> {
|
||||||
|
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 {
|
export function logUserAction(action: string, nav?: string, detail?: unknown): void {
|
||||||
void getWorkspace()?.logUserAction({ action, nav, detail })
|
void getWorkspace()?.logUserAction({ action, nav, detail })
|
||||||
}
|
}
|
||||||
|
|||||||
61
web2.py
61
web2.py
@@ -19,9 +19,7 @@ from web2_supervisor import (
|
|||||||
strip_ansi_codes,
|
strip_ansi_codes,
|
||||||
use_builtin,
|
use_builtin,
|
||||||
)
|
)
|
||||||
from web2_network import run_network_dashboard
|
from web2_network_routes import router as network_router
|
||||||
from web2_ip_profile import run_ip_profile
|
|
||||||
from web2_ip_quality import run_ip_quality
|
|
||||||
from web2_proxy_config import read_proxy_config, write_proxy_config
|
from web2_proxy_config import read_proxy_config, write_proxy_config
|
||||||
from web2_system_metrics import snapshot as system_metrics_snapshot
|
from web2_system_metrics import snapshot as system_metrics_snapshot
|
||||||
from web2_youtube_oauth import (
|
from web2_youtube_oauth import (
|
||||||
@@ -100,6 +98,9 @@ CONFIG_DIR = BASE_DIR / "config"
|
|||||||
DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs"
|
DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs"
|
||||||
RELAY_DB = relay_db_path(BASE_DIR)
|
RELAY_DB = relay_db_path(BASE_DIR)
|
||||||
_PROCESS_STATUS_CACHE: dict[str, str] = {}
|
_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
|
MAX_LOG_LINES = 300
|
||||||
|
|
||||||
@@ -302,6 +303,7 @@ app.add_middleware(
|
|||||||
allow_methods=["GET", "POST", "OPTIONS"],
|
allow_methods=["GET", "POST", "OPTIONS"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
app.include_router(network_router)
|
||||||
|
|
||||||
@app.get("/client/overview")
|
@app.get("/client/overview")
|
||||||
async def client_overview():
|
async def client_overview():
|
||||||
@@ -1095,40 +1097,6 @@ async def health():
|
|||||||
return {"ok": True}
|
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")
|
@app.get("/proxy_config")
|
||||||
async def get_proxy_config():
|
async def get_proxy_config():
|
||||||
"""仅作用于录制子进程的代理(不写 Windows 系统代理)。"""
|
"""仅作用于录制子进程的代理(不写 Windows 系统代理)。"""
|
||||||
@@ -1154,10 +1122,25 @@ async def post_proxy_config(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/system_metrics")
|
@app.get("/system_metrics")
|
||||||
async def system_metrics():
|
async def system_metrics(
|
||||||
|
force_refresh: bool = Query(False, description="是否跳过缓存强制重新检测"),
|
||||||
|
):
|
||||||
"""CPU/内存/磁盘/网卡速率与 ffmpeg 进程摘要。"""
|
"""CPU/内存/磁盘/网卡速率与 ffmpeg 进程摘要。"""
|
||||||
|
global _SYSTEM_METRICS_CACHE
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
return JSONResponse({"error": str(e)}, status_code=500)
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|||||||
import platform
|
import platform
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
@@ -20,8 +22,10 @@ def detect_nvidia_gpu() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def detect_nvenc_available() -> bool:
|
def detect_nvenc_available(nvidia_present: bool | None = None) -> bool:
|
||||||
if not detect_nvidia_gpu():
|
if nvidia_present is None:
|
||||||
|
nvidia_present = detect_nvidia_gpu()
|
||||||
|
if not nvidia_present:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
_kw = {"capture_output": True, "text": True, "timeout": 8}
|
_kw = {"capture_output": True, "text": True, "timeout": 8}
|
||||||
@@ -33,13 +37,27 @@ def detect_nvenc_available() -> bool:
|
|||||||
return False
|
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()
|
nv = detect_nvidia_gpu()
|
||||||
enc = detect_nvenc_available()
|
enc = detect_nvenc_available(nv)
|
||||||
return {
|
payload = {
|
||||||
"nvidia_detected": nv,
|
"nvidia_detected": nv,
|
||||||
"nvenc_available": enc,
|
"nvenc_available": enc,
|
||||||
}
|
}
|
||||||
|
with _gpu_status_lock:
|
||||||
|
_gpu_status_cache = (time.time(), payload)
|
||||||
|
return dict(payload)
|
||||||
|
|
||||||
|
|
||||||
def machine_summary_payload() -> dict[str, Any]:
|
def machine_summary_payload() -> dict[str, Any]:
|
||||||
|
|||||||
@@ -351,7 +351,13 @@ def _build_checks(
|
|||||||
if client_lang and d.get("country_code"):
|
if client_lang and d.get("country_code"):
|
||||||
lang = client_lang.lower()
|
lang = client_lang.lower()
|
||||||
cc = (d.get("country_code") or "").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)
|
add("lang_soft", "浏览器语言与 IP 地区不冲突", lang_ok, client_lang)
|
||||||
|
|
||||||
if webrtc_leak is not None:
|
if webrtc_leak is not None:
|
||||||
|
|||||||
177
web2_network_routes.py
Normal file
177
web2_network_routes.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user