chore: 准备发布 v1.0.0 版本
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import MonitorPanel from './components/MonitorPanel'
|
||||
import ManagePanel from './components/ManagePanel'
|
||||
import DebugPanel from './components/DebugPanel'
|
||||
import SettingsPanel from './components/SettingsPanel'
|
||||
import LoginPage from './components/LoginPage'
|
||||
import { checkAuth, getToken, logout } from './api/client'
|
||||
|
||||
type AuthState = 'loading' | 'need_login' | 'authenticated'
|
||||
type TabId = 'monitor' | 'manage' | 'debug' | 'settings'
|
||||
|
||||
const ALL_THEMES = [
|
||||
'light', 'dark', 'cupcake', 'bumblebee', 'emerald', 'corporate',
|
||||
'synthwave', 'retro', 'cyberpunk', 'valentine', 'halloween', 'garden',
|
||||
'forest', 'aqua', 'lofi', 'pastel', 'fantasy', 'wireframe', 'black',
|
||||
'luxury', 'dracula', 'cmyk', 'autumn', 'business', 'acid', 'lemonade',
|
||||
'night', 'coffee', 'winter', 'dim', 'nord', 'sunset',
|
||||
'caramellatte', 'abyss', 'silk',
|
||||
]
|
||||
|
||||
const MENU_ITEMS: { id: TabId; label: string; icon: React.ReactNode; desc: string }[] = [
|
||||
{
|
||||
id: 'monitor',
|
||||
label: '节点监控',
|
||||
desc: '数据仪表盘',
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'manage',
|
||||
label: '节点管理',
|
||||
desc: '操作与配置',
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'debug',
|
||||
label: '调试面板',
|
||||
desc: '运行时信息',
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: '系统设置',
|
||||
desc: '配置与订阅',
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const VALID_TABS: TabId[] = ['monitor', 'manage', 'debug', 'settings']
|
||||
const THEME_STORAGE_KEY = 'ep-theme'
|
||||
|
||||
const DARK_THEMES = new Set([
|
||||
'dark', 'synthwave', 'halloween', 'forest', 'black', 'luxury',
|
||||
'dracula', 'business', 'night', 'coffee', 'dim', 'sunset', 'abyss',
|
||||
])
|
||||
|
||||
const VIBRANT_THEMES = new Set([
|
||||
'cupcake', 'bumblebee', 'retro', 'cyberpunk', 'valentine', 'aqua',
|
||||
'pastel', 'fantasy', 'cmyk', 'acid', 'lemonade', 'caramellatte', 'silk',
|
||||
])
|
||||
|
||||
function getThemeProfile(themeName: string): { tag: string; icon: string; desc: string } {
|
||||
if (DARK_THEMES.has(themeName)) {
|
||||
return { tag: '深色', icon: '🌙', desc: '低亮度 · 护眼' }
|
||||
}
|
||||
if (VIBRANT_THEMES.has(themeName)) {
|
||||
return { tag: '鲜艳', icon: '🎨', desc: '高对比 · 活力' }
|
||||
}
|
||||
return { tag: '清爽', icon: '☀️', desc: '中性 · 易读' }
|
||||
}
|
||||
|
||||
function getSystemTheme(): string {
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark'
|
||||
}
|
||||
return 'light'
|
||||
}
|
||||
|
||||
function getInitialTheme(): string {
|
||||
const saved = localStorage.getItem(THEME_STORAGE_KEY)
|
||||
if (saved && ALL_THEMES.includes(saved)) {
|
||||
return saved
|
||||
}
|
||||
return getSystemTheme()
|
||||
}
|
||||
|
||||
function getTabFromHash(): TabId {
|
||||
const hash = window.location.hash.replace('#', '')
|
||||
if (VALID_TABS.includes(hash as TabId)) {
|
||||
return hash as TabId
|
||||
}
|
||||
return 'monitor'
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>(getTabFromHash)
|
||||
const [authState, setAuthState] = useState<AuthState>('loading')
|
||||
const [theme, setTheme] = useState(getInitialTheme)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
// Sync hash → state on browser back/forward
|
||||
useEffect(() => {
|
||||
const onHashChange = () => setActiveTab(getTabFromHash())
|
||||
window.addEventListener('hashchange', onHashChange)
|
||||
return () => window.removeEventListener('hashchange', onHashChange)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
localStorage.setItem(THEME_STORAGE_KEY, theme)
|
||||
}, [theme])
|
||||
|
||||
useEffect(() => {
|
||||
const doCheck = async () => {
|
||||
try {
|
||||
const res = await checkAuth()
|
||||
if (res.no_password) {
|
||||
setAuthState('authenticated')
|
||||
} else if (getToken()) {
|
||||
setAuthState('authenticated')
|
||||
} else {
|
||||
setAuthState('need_login')
|
||||
}
|
||||
} catch {
|
||||
if (getToken()) {
|
||||
setAuthState('authenticated')
|
||||
} else {
|
||||
setAuthState('need_login')
|
||||
}
|
||||
}
|
||||
}
|
||||
doCheck()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setAuthState('need_login')
|
||||
window.addEventListener('auth:unauthorized', handler)
|
||||
return () => window.removeEventListener('auth:unauthorized', handler)
|
||||
}, [])
|
||||
|
||||
const handleLogin = useCallback(() => setAuthState('authenticated'), [])
|
||||
const handleLogout = useCallback(() => {
|
||||
logout()
|
||||
setAuthState('need_login')
|
||||
}, [])
|
||||
|
||||
const handleTabClick = useCallback((tab: TabId) => {
|
||||
setActiveTab(tab)
|
||||
window.location.hash = tab
|
||||
setSidebarOpen(false)
|
||||
}, [])
|
||||
|
||||
const renderContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'monitor': return <MonitorPanel />
|
||||
case 'manage': return <ManagePanel />
|
||||
case 'debug': return <DebugPanel />
|
||||
case 'settings': return <SettingsPanel />
|
||||
default: return <MonitorPanel />
|
||||
}
|
||||
}
|
||||
|
||||
if (authState === 'loading') {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-base-200 gap-4">
|
||||
<span className="text-2xl font-black text-primary">Easy Proxies</span>
|
||||
<span className="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (authState === 'need_login') {
|
||||
return <LoginPage onLogin={handleLogin} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen overflow-hidden bg-base-100">
|
||||
{/* ===== Top Navbar ===== */}
|
||||
<div className="navbar bg-base-100/95 backdrop-blur-xl z-50 min-h-[4rem] h-16 px-4 lg:px-6 shrink-0 border-b border-base-300/40 shadow-sm sticky top-0">
|
||||
<div className="flex-none lg:hidden mr-2">
|
||||
<button
|
||||
className="btn btn-square btn-ghost btn-sm rounded-lg"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
aria-label="toggle sidebar"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-5 h-5 stroke-current">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Logo Area */}
|
||||
<div className="flex-1 lg:flex-none lg:w-64 px-2 flex items-center gap-3.5 border-r-0 lg:border-r border-base-300/30">
|
||||
<div className="w-10 h-10 rounded-2xl bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center shadow-sm border border-primary/10">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-black text-lg text-base-content tracking-tight leading-none mb-1">
|
||||
Easy Proxies
|
||||
</div>
|
||||
<div className="text-[11px] font-medium text-base-content/40 tracking-wider uppercase">
|
||||
Dashboard
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Right Actions */}
|
||||
<div className="flex-none flex items-center gap-2 lg:gap-3 ml-auto">
|
||||
{/* Theme Selector */}
|
||||
<div className="dropdown dropdown-end">
|
||||
<div tabIndex={0} role="button" className="btn btn-ghost btn-sm h-9 px-3 gap-2 normal-case rounded-lg border border-base-300/50 hover:border-primary/30 hover:bg-primary/5 transition-all">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
<div className="hidden sm:flex items-center gap-1.5">
|
||||
<span className="text-xs capitalize font-medium">{theme}</span>
|
||||
<span className="badge badge-ghost badge-xs">{getThemeProfile(theme).tag}</span>
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<ul tabIndex={0} className="dropdown-content bg-base-200 border border-base-300/50 rounded-box shadow-2xl mt-2 p-2.5 w-80 max-h-96 overflow-y-auto z-[100] space-y-1">
|
||||
{ALL_THEMES.map((t) => {
|
||||
const profile = getThemeProfile(t)
|
||||
const isActive = theme === t
|
||||
return (
|
||||
<li key={t}>
|
||||
<button
|
||||
className={`w-full rounded-xl px-2 py-2 border transition-all duration-150 ${
|
||||
isActive
|
||||
? 'bg-primary/10 border-primary/30 shadow-sm'
|
||||
: 'border-transparent hover:border-base-300/70 hover:bg-base-300/40'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setTheme(t)
|
||||
const el = document.activeElement as HTMLElement
|
||||
el?.blur()
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="shrink-0 rounded-lg border border-base-300/60 p-1 bg-base-100/50" data-theme={t}>
|
||||
<div className="w-16 h-10 rounded-md bg-base-100 border border-base-300/70 p-1.5 flex flex-col justify-between">
|
||||
<div className="h-1.5 w-9 rounded bg-primary"></div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-2.5 h-2.5 rounded bg-secondary"></span>
|
||||
<span className="w-2.5 h-2.5 rounded bg-accent"></span>
|
||||
<span className="w-2.5 h-2.5 rounded bg-neutral"></span>
|
||||
<span className="w-2.5 h-2.5 rounded bg-info"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className={`text-sm font-semibold capitalize leading-tight ${isActive ? 'text-primary' : ''}`}>{t}</div>
|
||||
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-base-content/55">
|
||||
<span>{profile.icon}</span>
|
||||
<span>{profile.tag}</span>
|
||||
<span className="opacity-40">·</span>
|
||||
<span className="truncate">{profile.desc}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isActive && (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-primary shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-base-300/50 mx-1"></div>
|
||||
|
||||
<button
|
||||
className="btn btn-ghost btn-sm h-9 w-9 p-0 rounded-lg hover:bg-error/10 hover:text-error transition-colors"
|
||||
onClick={handleLogout}
|
||||
title="退出登录"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Body: Sidebar + Content ===== */}
|
||||
<div className="flex flex-1 overflow-hidden relative">
|
||||
{/* Mobile overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-30 lg:hidden backdrop-blur-sm"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`
|
||||
fixed lg:static inset-y-0 left-0 z-40
|
||||
w-64 bg-base-100/95 backdrop-blur-xl border-r border-base-300/40
|
||||
transform transition-transform duration-300 ease-in-out
|
||||
${sidebarOpen ? 'translate-x-0 shadow-2xl' : '-translate-x-full'}
|
||||
lg:translate-x-0 lg:shrink-0 lg:shadow-none
|
||||
flex flex-col
|
||||
pt-16 lg:pt-0
|
||||
`}
|
||||
>
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-4 lg:p-5 overflow-y-auto">
|
||||
<div className="text-[11px] font-bold text-base-content/40 uppercase tracking-widest mb-3 px-2">主要导航</div>
|
||||
<ul className="space-y-1.5">
|
||||
{MENU_ITEMS.map((item) => {
|
||||
const isActive = activeTab === item.id;
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
className={`w-full flex items-center gap-3.5 px-3 py-3 rounded-xl transition-all duration-200 group
|
||||
${isActive
|
||||
? 'bg-primary/10 shadow-sm border border-primary/20'
|
||||
: 'hover:bg-base-200/50 border border-transparent'
|
||||
}
|
||||
`}
|
||||
onClick={() => handleTabClick(item.id)}
|
||||
>
|
||||
<div className={`p-2 rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary text-primary-content shadow-md shadow-primary/20'
|
||||
: 'bg-base-200 text-base-content/50 group-hover:bg-base-300 group-hover:text-base-content'
|
||||
}`}>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="text-left flex-1 min-w-0">
|
||||
<div className={`text-sm truncate ${isActive ? 'font-bold text-primary' : 'font-medium text-base-content/80 group-hover:text-base-content'}`}>
|
||||
{item.label}
|
||||
</div>
|
||||
<div className={`text-xs truncate ${isActive ? 'text-primary/70' : 'text-base-content/40'}`}>
|
||||
{item.desc}
|
||||
</div>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div className="w-1.5 h-6 rounded-full bg-primary shrink-0 opacity-80"></div>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Sidebar Footer */}
|
||||
<div className="p-5 border-t border-base-300/30 bg-base-200/20 m-2 rounded-2xl mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex h-3 w-3 shrink-0">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-success"></span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-base-content/80">系统运行正常</span>
|
||||
<span className="text-xs text-base-content/40">v1.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 overflow-y-auto bg-base-200/40 relative">
|
||||
{renderContent()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,365 @@
|
||||
import type {
|
||||
AuthResponse,
|
||||
NodesResponse,
|
||||
DebugResponse,
|
||||
SettingsData,
|
||||
SettingsUpdateResponse,
|
||||
ConfigNodesResponse,
|
||||
ConfigNodePayload,
|
||||
ConfigNodeMutationResponse,
|
||||
SubscriptionStatus,
|
||||
ProbeSSEEvent,
|
||||
TrafficStreamEvent,
|
||||
} from '../types'
|
||||
|
||||
// ---- Token management ----
|
||||
|
||||
let authToken: string | null = localStorage.getItem('auth_token')
|
||||
|
||||
export function getToken(): string | null {
|
||||
return authToken
|
||||
}
|
||||
|
||||
export function setToken(token: string | null) {
|
||||
authToken = token
|
||||
if (token) {
|
||||
localStorage.setItem('auth_token', token)
|
||||
} else {
|
||||
localStorage.removeItem('auth_token')
|
||||
}
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
setToken(null)
|
||||
}
|
||||
|
||||
// ---- Base request helper ----
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
...(options.headers as Record<string, string> || {}),
|
||||
}
|
||||
|
||||
// Add auth header if we have a token
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`
|
||||
}
|
||||
|
||||
// Set JSON content type for non-GET requests with body
|
||||
if (options.body && typeof options.body === 'string') {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
const res = await fetch(path, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include', // send cookies
|
||||
})
|
||||
|
||||
if (res.status === 401) {
|
||||
clearToken()
|
||||
// Dispatch a custom event so App can react
|
||||
window.dispatchEvent(new CustomEvent('auth:unauthorized'))
|
||||
throw new ApiError('未授权,请重新登录', 401)
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let msg = `HTTP ${res.status}`
|
||||
try {
|
||||
const body = await res.json()
|
||||
if (body.error) msg = body.error
|
||||
} catch { /* ignore parse errors */ }
|
||||
throw new ApiError(msg, res.status)
|
||||
}
|
||||
|
||||
// Handle empty responses
|
||||
const text = await res.text()
|
||||
if (!text) return {} as T
|
||||
return JSON.parse(text) as T
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Auth API ----
|
||||
|
||||
/** Check if password is required & login */
|
||||
export async function checkAuth(): Promise<AuthResponse> {
|
||||
// Use GET-like behavior: /api/auth without POST returns password status
|
||||
const res = await fetch('/api/auth', { credentials: 'include' })
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function login(password: string): Promise<AuthResponse> {
|
||||
const res = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json()
|
||||
throw new ApiError(body.error || '登录失败', res.status)
|
||||
}
|
||||
|
||||
const data: AuthResponse = await res.json()
|
||||
if (data.token) {
|
||||
setToken(data.token)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
clearToken()
|
||||
}
|
||||
|
||||
// ---- Nodes API ----
|
||||
|
||||
export async function fetchNodes(): Promise<NodesResponse> {
|
||||
return request<NodesResponse>('/api/nodes')
|
||||
}
|
||||
|
||||
export async function probeNode(tag: string): Promise<{ message: string; latency_ms: number }> {
|
||||
return request(`/api/nodes/${encodeURIComponent(tag)}/probe`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function releaseNode(tag: string): Promise<{ message: string }> {
|
||||
return request(`/api/nodes/${encodeURIComponent(tag)}/release`, { method: 'POST' })
|
||||
}
|
||||
|
||||
/** Probe all nodes with SSE progress updates */
|
||||
export function probeAllNodes(
|
||||
onEvent: (event: ProbeSSEEvent) => void,
|
||||
onError?: (error: Error) => void
|
||||
): AbortController {
|
||||
const controller = new AbortController()
|
||||
|
||||
const doFetch = async () => {
|
||||
try {
|
||||
const headers: Record<string, string> = {}
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`
|
||||
}
|
||||
|
||||
const res = await fetch('/api/nodes/probe-all', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(`探测失败: HTTP ${res.status}`, res.status)
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) throw new Error('No response body')
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(trimmed.slice(6)) as ProbeSSEEvent
|
||||
onEvent(data)
|
||||
} catch { /* skip malformed events */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== 'AbortError') {
|
||||
onError?.(err as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
doFetch()
|
||||
return controller
|
||||
}
|
||||
|
||||
// ---- Traffic Stream API ----
|
||||
|
||||
/** Subscribe real-time traffic speeds via SSE */
|
||||
export function streamTraffic(
|
||||
onEvent: (event: TrafficStreamEvent) => void,
|
||||
onError?: (error: Error) => void
|
||||
): AbortController {
|
||||
const controller = new AbortController()
|
||||
|
||||
const doFetch = async () => {
|
||||
try {
|
||||
const headers: Record<string, string> = {}
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`
|
||||
}
|
||||
|
||||
const res = await fetch('/api/nodes/traffic/stream', {
|
||||
method: 'GET',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(`流量流订阅失败: HTTP ${res.status}`, res.status)
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) throw new Error('No response body')
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(trimmed.slice(6)) as TrafficStreamEvent
|
||||
if (data.type === 'traffic') {
|
||||
onEvent(data)
|
||||
}
|
||||
} catch { /* skip malformed events */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== 'AbortError') {
|
||||
onError?.(err as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
doFetch()
|
||||
return controller
|
||||
}
|
||||
|
||||
// ---- Debug API ----
|
||||
|
||||
export async function fetchDebug(): Promise<DebugResponse> {
|
||||
return request<DebugResponse>('/api/debug')
|
||||
}
|
||||
|
||||
// ---- Settings API ----
|
||||
|
||||
export async function fetchSettings(): Promise<SettingsData> {
|
||||
return request<SettingsData>('/api/settings')
|
||||
}
|
||||
|
||||
export async function updateSettings(settings: SettingsData): Promise<SettingsUpdateResponse> {
|
||||
return request<SettingsUpdateResponse>('/api/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(settings),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Config Nodes CRUD API ----
|
||||
|
||||
export async function fetchConfigNodes(): Promise<ConfigNodesResponse> {
|
||||
return request<ConfigNodesResponse>('/api/nodes/config')
|
||||
}
|
||||
|
||||
export async function createConfigNode(payload: ConfigNodePayload): Promise<ConfigNodeMutationResponse> {
|
||||
return request<ConfigNodeMutationResponse>('/api/nodes/config', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateConfigNode(name: string, payload: ConfigNodePayload): Promise<ConfigNodeMutationResponse> {
|
||||
return request<ConfigNodeMutationResponse>(`/api/nodes/config/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteConfigNode(name: string): Promise<ConfigNodeMutationResponse> {
|
||||
return request<ConfigNodeMutationResponse>(`/api/nodes/config/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export async function toggleConfigNode(name: string, enabled: boolean): Promise<ConfigNodeMutationResponse> {
|
||||
return request<ConfigNodeMutationResponse>(`/api/nodes/config/${encodeURIComponent(name)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ enabled }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function batchToggleConfigNodes(names: string[], enabled: boolean): Promise<{ message: string; success: number; total: number; errors?: string[] }> {
|
||||
return request('/api/nodes/config/batch-toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ names, enabled }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function batchDeleteConfigNodes(names: string[]): Promise<{ message: string; success: number; total: number; errors?: string[] }> {
|
||||
return request('/api/nodes/config/batch-delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ names }),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Reload API ----
|
||||
|
||||
export async function triggerReload(): Promise<{ message: string }> {
|
||||
return request('/api/reload', { method: 'POST' })
|
||||
}
|
||||
|
||||
// ---- Subscription API ----
|
||||
|
||||
export async function fetchSubscriptionStatus(): Promise<SubscriptionStatus> {
|
||||
return request<SubscriptionStatus>('/api/subscription/status')
|
||||
}
|
||||
|
||||
export async function refreshSubscription(): Promise<{ message: string; node_count: number }> {
|
||||
return request('/api/subscription/refresh', { method: 'POST' })
|
||||
}
|
||||
|
||||
// ---- Export API ----
|
||||
|
||||
export async function exportProxies(): Promise<string> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`
|
||||
}
|
||||
const res = await fetch('/api/export', {
|
||||
headers,
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new ApiError('导出失败', res.status)
|
||||
return res.text()
|
||||
}
|
||||
|
||||
// ---- Import API ----
|
||||
|
||||
export async function importNodes(content: string): Promise<{ message: string; imported: number; errors?: string[] }> {
|
||||
return request('/api/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content }),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,350 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import type { DebugResponse, DebugNode } from '../types'
|
||||
import { fetchDebug } from '../api/client'
|
||||
import { formatBytes } from '../utils/format'
|
||||
|
||||
function formatTime(isoStr: string): string {
|
||||
if (!isoStr || isoStr === '0001-01-01T00:00:00Z') return '-'
|
||||
try {
|
||||
return new Date(isoStr).toLocaleString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return isoStr
|
||||
}
|
||||
}
|
||||
|
||||
function formatLogTime(isoStr: string): string {
|
||||
if (!isoStr || isoStr === '0001-01-01T00:00:00Z') return '--:--:--'
|
||||
try {
|
||||
const d = new Date(isoStr)
|
||||
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
} catch {
|
||||
return isoStr
|
||||
}
|
||||
}
|
||||
|
||||
export default function DebugPanel() {
|
||||
const [data, setData] = useState<DebugResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||
const [filter, setFilter] = useState('')
|
||||
const [expandedNode, setExpandedNode] = useState<string | null>(null)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setError('')
|
||||
const res = await fetchDebug()
|
||||
setData(res)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
// Auto refresh
|
||||
useEffect(() => {
|
||||
if (autoRefresh) {
|
||||
intervalRef.current = setInterval(loadData, 5000)
|
||||
}
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}
|
||||
}, [autoRefresh, loadData])
|
||||
|
||||
const filteredNodes = (data?.nodes || []).filter((node: DebugNode) => {
|
||||
if (!filter) return true
|
||||
return (
|
||||
node.name.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
node.tag.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(node.last_error || '').toLowerCase().includes(filter.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
// Sort: nodes with errors first, then by failure count desc
|
||||
const sortedNodes = [...filteredNodes].sort((a, b) => {
|
||||
if (a.blacklisted !== b.blacklisted) return a.blacklisted ? -1 : 1
|
||||
if (a.failure_count !== b.failure_count) return b.failure_count - a.failure_count
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<span className="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-full animate-in fade-in duration-500">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-30 bg-base-100/80 backdrop-blur-xl px-4 lg:px-8 py-4 border-b border-base-300/60 shadow-sm">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 max-w-[1600px] mx-auto w-full">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary shrink-0 border border-primary/20">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
调试面板
|
||||
</h2>
|
||||
<p className="text-sm text-base-content/50 mt-1.5 ml-[3.25rem]">运行时内部状态及错误信息分析</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer bg-base-200/50 px-3 py-1.5 rounded-lg border border-base-300/50 hover:border-base-300 transition-colors">
|
||||
<span className="text-sm font-medium">自动刷新 (5s)</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="toggle toggle-primary toggle-sm"
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-sm lg:btn-md btn-primary shadow-sm gap-2"
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="loading loading-spinner loading-sm"></span>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
)}
|
||||
手动刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 lg:p-8 space-y-6 flex-1 pb-10 max-w-[1600px] mx-auto w-full">
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div role="alert" className="alert alert-error alert-soft text-sm">
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overview Stats */}
|
||||
{data && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="rounded-xl bg-base-200/50 border border-base-300/30 p-4 hover:border-primary/30 transition-colors">
|
||||
<div className="text-xs text-base-content/50 mb-1">总调用数</div>
|
||||
<div className="text-2xl font-bold tabular-nums">{data.total_calls.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-base-200/50 border border-base-300/30 p-4 hover:border-primary/30 transition-colors">
|
||||
<div className="text-xs text-base-content/50 mb-1">成功数</div>
|
||||
<div className="text-2xl font-bold tabular-nums text-success">{data.total_success.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-base-200/50 border border-base-300/30 p-4 hover:border-primary/30 transition-colors">
|
||||
<div className="text-xs text-base-content/50 mb-1">失败数</div>
|
||||
<div className="text-2xl font-bold tabular-nums text-error">{(data.total_calls - data.total_success).toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-base-200/50 border border-base-300/30 p-4 hover:border-primary/30 transition-colors">
|
||||
<div className="text-xs text-base-content/50 mb-1">成功率</div>
|
||||
<div className={`text-2xl font-bold tabular-nums ${data.success_rate >= 90 ? 'text-success' : data.success_rate >= 70 ? 'text-warning' : 'text-error'}`}>
|
||||
{data.success_rate.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter */}
|
||||
<label className="input input-sm w-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索节点名称、标签或错误信息..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Node Debug List */}
|
||||
<div className="space-y-2 max-h-[60vh] overflow-y-auto pr-1">
|
||||
{sortedNodes.length === 0 ? (
|
||||
<div className="text-center text-base-content/50 py-8">
|
||||
{filter ? '没有匹配的节点' : '暂无调试数据'}
|
||||
</div>
|
||||
) : (
|
||||
sortedNodes.map((node: DebugNode) => (
|
||||
<div
|
||||
key={node.tag}
|
||||
className={`collapse collapse-arrow bg-base-200/60 border rounded-xl transition-colors ${node.blacklisted ? 'border-error/30' : 'border-base-300/40 hover:border-base-300/70'}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={expandedNode === node.tag}
|
||||
onChange={() => setExpandedNode(expandedNode === node.tag ? null : node.tag)}
|
||||
/>
|
||||
<div className="collapse-title flex items-center gap-3 pr-12">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">{node.name || node.tag}</span>
|
||||
{node.blacklisted && <span className="badge badge-error badge-xs">黑名单</span>}
|
||||
<span className="badge badge-ghost badge-xs">{node.mode}</span>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-base-content/60 mt-0.5 flex-wrap">
|
||||
<span>成功: <span className="text-success">{node.success_count}</span></span>
|
||||
<span>失败: <span className="text-error">{node.failure_count}</span></span>
|
||||
<span>活跃: {node.active_connections}</span>
|
||||
{node.last_latency_ms > 0 && (
|
||||
<span>延迟: {node.last_latency_ms}ms</span>
|
||||
)}
|
||||
{((node.total_upload || 0) + (node.total_download || 0)) > 0 && (
|
||||
<span className="text-blue-400/70">↑{formatBytes(node.total_upload || 0)}</span>
|
||||
)}
|
||||
{((node.total_upload || 0) + (node.total_download || 0)) > 0 && (
|
||||
<span className="text-green-400/70">↓{formatBytes(node.total_download || 0)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="collapse-content">
|
||||
<div className="pt-2 space-y-3">
|
||||
{/* Detail Info */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-base-content/50">端口:</span>{' '}
|
||||
<span className="font-mono">{node.port || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-base-content/50">最后成功:</span>{' '}
|
||||
<span>{formatTime(node.last_success)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-base-content/50">最后失败:</span>{' '}
|
||||
<span>{formatTime(node.last_failure)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-base-content/50">延迟:</span>{' '}
|
||||
<span className="font-mono">{node.last_latency_ms > 0 ? `${node.last_latency_ms}ms` : '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-base-content/50">上传:</span>{' '}
|
||||
<span className="font-mono text-blue-400">{formatBytes(node.total_upload || 0)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-base-content/50">下载:</span>{' '}
|
||||
<span className="font-mono text-green-400">{formatBytes(node.total_download || 0)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-base-content/50">总流量:</span>{' '}
|
||||
<span className="font-mono">{formatBytes((node.total_upload || 0) + (node.total_download || 0))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last Error */}
|
||||
{node.last_error && (
|
||||
<div className="bg-error/5 border border-error/20 rounded-lg p-2.5">
|
||||
<span className="text-xs font-semibold text-error">最后错误: </span>
|
||||
<span className="text-xs text-error/70 font-mono break-all">{node.last_error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
{node.timeline && node.timeline.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium mb-2 text-base-content/70">
|
||||
运行日志 (最近 {node.timeline.length} 条)
|
||||
</h4>
|
||||
<div className="bg-[#1a1b26] rounded-lg p-3 overflow-x-auto max-h-56 overflow-y-auto font-mono text-xs leading-5 space-y-px">
|
||||
{node.timeline.map((evt, i) => {
|
||||
const ts = formatLogTime(evt.time)
|
||||
const isOk = evt.success
|
||||
const isTraffic = !!evt.destination
|
||||
return (
|
||||
<div key={i} className="whitespace-nowrap">
|
||||
<span className="text-gray-500">{ts}</span>
|
||||
{' '}
|
||||
{isOk ? (
|
||||
<span className="text-green-400">INFO </span>
|
||||
) : (
|
||||
<span className="text-red-400">ERROR</span>
|
||||
)}
|
||||
{' '}
|
||||
<span className="text-blue-300">[{node.name || node.tag}]</span>
|
||||
{' '}
|
||||
{isTraffic ? (
|
||||
/* 流量事件:显示目标地址 */
|
||||
isOk ? (
|
||||
<>
|
||||
<span className="text-gray-300">→ </span>
|
||||
<span className="text-cyan-300">{evt.destination}</span>
|
||||
<span className="text-green-300 ml-1">✓</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-gray-300">→ </span>
|
||||
<span className="text-cyan-300">{evt.destination}</span>
|
||||
<span className="text-red-300 ml-1">✗</span>
|
||||
{evt.error && (
|
||||
<span className="text-red-400/70 ml-1">— {evt.error}</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
/* 探测事件:显示延迟 */
|
||||
isOk ? (
|
||||
<>
|
||||
<span className="text-gray-300">probe </span>
|
||||
<span className="text-green-300">✓ success</span>
|
||||
{evt.latency_ms > 0 && (
|
||||
<span className={`ml-1 ${evt.latency_ms > 1000 ? 'text-yellow-400' : evt.latency_ms > 500 ? 'text-yellow-300' : 'text-gray-400'}`}>
|
||||
({evt.latency_ms}ms)
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-gray-300">probe </span>
|
||||
<span className="text-red-300">✗ failed</span>
|
||||
{evt.latency_ms > 0 && (
|
||||
<span className="text-gray-500 ml-1">({evt.latency_ms}ms)</span>
|
||||
)}
|
||||
{evt.error && (
|
||||
<span className="text-red-400/70 ml-1">— {evt.error}</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto-refresh indicator */}
|
||||
{autoRefresh && (
|
||||
<div className="text-center text-xs text-base-content/30 flex items-center justify-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-success animate-pulse"></span>
|
||||
每 5 秒自动刷新 · {data?.nodes.length || 0} 个节点
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useState } from 'react'
|
||||
import { login } from '../api/client'
|
||||
|
||||
interface LoginPageProps {
|
||||
onLogin: () => void
|
||||
}
|
||||
|
||||
export default function LoginPage({ onLogin }: LoginPageProps) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!password.trim()) {
|
||||
setError('请输入密码')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await login(password)
|
||||
onLogin()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full flex flex-col lg:flex-row bg-base-100 overflow-hidden">
|
||||
{/* Left Side: Branding / Intro */}
|
||||
<div className="lg:w-5/12 bg-primary/5 p-8 lg:p-16 xl:p-24 flex flex-col justify-between relative overflow-hidden border-b lg:border-b-0 lg:border-r border-base-300/30">
|
||||
{/* Decorative Background Elements */}
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[60%] h-[60%] bg-primary/20 rounded-full blur-[100px] mix-blend-multiply opacity-60"></div>
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[60%] h-[60%] bg-secondary/20 rounded-full blur-[100px] mix-blend-multiply opacity-60"></div>
|
||||
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-base-100 flex items-center justify-center shadow-sm border border-base-300/50">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-2xl font-black text-primary tracking-tight">Easy Proxies</span>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-16 lg:mt-0 flex-1 flex flex-col justify-center">
|
||||
<h1 className="text-4xl lg:text-5xl xl:text-6xl font-black text-base-content tracking-tight leading-tight">
|
||||
高效、轻量的<br className="hidden lg:block" />
|
||||
<span className="text-primary mt-2 block">代理池管理系统</span>
|
||||
</h1>
|
||||
<p className="text-base-content/60 mt-6 text-base lg:text-lg max-w-md font-medium leading-relaxed">
|
||||
为高可用代理服务提供强劲动力。一站式解决多节点负载均衡、健康检查、区域路由与实时监控。
|
||||
</p>
|
||||
|
||||
<div className="mt-12 flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4 text-sm font-medium bg-base-100/60 backdrop-blur-md p-4 rounded-2xl border border-base-300/40 shadow-sm max-w-md transition-transform hover:-translate-y-1 duration-300">
|
||||
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base-content font-bold text-base">高可用架构</div>
|
||||
<div className="text-base-content/60 text-xs mt-0.5">多重调度策略与毫秒级故障转移</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm font-medium bg-base-100/60 backdrop-blur-md p-4 rounded-2xl border border-base-300/40 shadow-sm max-w-md transition-transform hover:-translate-y-1 duration-300">
|
||||
<div className="w-12 h-12 rounded-full bg-secondary/10 flex items-center justify-center text-secondary shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base-content font-bold text-base">自动化管理</div>
|
||||
<div className="text-base-content/60 text-xs mt-0.5">订阅定时更新与 GeoIP 智能分发</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-12 hidden lg:block">
|
||||
<p className="text-sm font-medium text-base-content/40">© {new Date().getFullYear()} Easy Proxies. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side: Login Form */}
|
||||
<div className="lg:w-7/12 p-8 lg:p-24 flex flex-col justify-center items-center bg-base-100 relative">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="mb-12 text-center lg:text-left">
|
||||
<h2 className="text-3xl lg:text-4xl font-bold text-base-content mb-3 tracking-tight">欢迎回来</h2>
|
||||
<p className="text-base text-base-content/50 font-medium">请输入管理面板密码以继续使用系统</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<fieldset className="fieldset">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/70">管理密码</legend>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-base-content/40">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
className={`input input-lg w-full pl-12 bg-base-200/50 focus:bg-base-100 transition-colors text-base ${error ? 'input-error ring-1 ring-error/50' : 'focus:ring-2 focus:ring-primary/20'}`}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
setError('')
|
||||
}}
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{error && (
|
||||
<div role="alert" className="alert alert-error alert-soft py-3 text-sm animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="font-medium">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-full h-14 text-lg font-semibold shadow-lg shadow-primary/20 hover:shadow-primary/30 transition-all active:scale-[0.98] mt-4"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="loading loading-spinner loading-md"></span>
|
||||
) : (
|
||||
<>
|
||||
<span>登录系统</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-16 text-center lg:text-left lg:hidden">
|
||||
<p className="text-xs text-base-content/30 font-medium">
|
||||
© {new Date().getFullYear()} Easy Proxies. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,555 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import type { NodeSnapshot, NodesResponse, ConfigNodeConfig, ConfigNodesResponse, TrafficStreamEvent } from '../types'
|
||||
import { fetchNodes, fetchConfigNodes, streamTraffic } from '../api/client'
|
||||
import { formatBytes, formatSpeed } from '../utils/format'
|
||||
import DonutChart from './charts/DonutChart'
|
||||
import BarChart from './charts/BarChart'
|
||||
import LatencyRanking from './charts/LatencyRanking'
|
||||
import TrafficRanking from './charts/TrafficRanking'
|
||||
import RegionCards from './charts/RegionCards'
|
||||
|
||||
function latencyColor(ms: number): string {
|
||||
if (ms < 0) return 'text-base-content/50'
|
||||
if (ms <= 100) return 'text-success'
|
||||
if (ms <= 300) return 'text-warning'
|
||||
return 'text-error'
|
||||
}
|
||||
|
||||
type AutoRefreshInterval = 0 | 5 | 10 | 30 | 60
|
||||
|
||||
export default function MonitorPanel() {
|
||||
const [data, setData] = useState<NodesResponse | null>(null)
|
||||
const [configData, setConfigData] = useState<ConfigNodesResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [autoRefresh, setAutoRefresh] = useState<AutoRefreshInterval>(5)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const trafficAbortRef = useRef<AbortController | null>(null)
|
||||
const [trafficRealtime, setTrafficRealtime] = useState<{
|
||||
connected: boolean
|
||||
uploadSpeed: number
|
||||
downloadSpeed: number
|
||||
sampledAt: string
|
||||
}>({
|
||||
connected: false,
|
||||
uploadSpeed: 0,
|
||||
downloadSpeed: 0,
|
||||
sampledAt: '',
|
||||
})
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setError('')
|
||||
const [monitorRes, configRes] = await Promise.all([
|
||||
fetchNodes(),
|
||||
fetchConfigNodes().catch(() => null),
|
||||
])
|
||||
|
||||
setData(monitorRes)
|
||||
if (configRes) setConfigData(configRes)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
// Auto-refresh timer
|
||||
useEffect(() => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
if (autoRefresh > 0) {
|
||||
timerRef.current = setInterval(() => {
|
||||
loadData()
|
||||
}, autoRefresh * 1000)
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
}
|
||||
}
|
||||
}, [autoRefresh, loadData])
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setLoading(true)
|
||||
await loadData()
|
||||
}
|
||||
|
||||
// Real-time speed stream (SSE): reconnect automatically on disconnect.
|
||||
useEffect(() => {
|
||||
let stopped = false
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const connect = () => {
|
||||
if (stopped) return
|
||||
|
||||
if (trafficAbortRef.current) {
|
||||
trafficAbortRef.current.abort()
|
||||
trafficAbortRef.current = null
|
||||
}
|
||||
|
||||
trafficAbortRef.current = streamTraffic(
|
||||
(event: TrafficStreamEvent) => {
|
||||
setTrafficRealtime({
|
||||
connected: true,
|
||||
uploadSpeed: event.upload_speed || 0,
|
||||
downloadSpeed: event.download_speed || 0,
|
||||
sampledAt: event.sampled_at || '',
|
||||
})
|
||||
},
|
||||
() => {
|
||||
setTrafficRealtime((prev) => ({ ...prev, connected: false }))
|
||||
if (!stopped) {
|
||||
retryTimer = setTimeout(connect, 2000)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
stopped = true
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer)
|
||||
}
|
||||
if (trafficAbortRef.current) {
|
||||
trafficAbortRef.current.abort()
|
||||
trafficAbortRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ---- Computed stats ----
|
||||
const allNodes = useMemo(() => data?.nodes || [], [data])
|
||||
const allConfigNodes = useMemo(() => configData?.nodes || [], [configData])
|
||||
|
||||
// Config-level counts (includes disabled nodes)
|
||||
const totalConfigNodes = allConfigNodes.length
|
||||
const disabledNodes = useMemo(
|
||||
() => allConfigNodes.filter((n: ConfigNodeConfig) => n.disabled).length,
|
||||
[allConfigNodes]
|
||||
)
|
||||
// enabledConfigNodes not directly displayed but used implicitly in monitor node count
|
||||
|
||||
// Runtime monitor counts (only enabled nodes that are loaded in the box)
|
||||
const availableNodes = useMemo(
|
||||
() => allNodes.filter((n: NodeSnapshot) => n.initial_check_done && n.available && !n.blacklisted).length,
|
||||
[allNodes]
|
||||
)
|
||||
|
||||
const unavailableNodes = useMemo(
|
||||
() => allNodes.filter((n: NodeSnapshot) => n.initial_check_done && !n.available && !n.blacklisted).length,
|
||||
[allNodes]
|
||||
)
|
||||
|
||||
const blacklistedNodes = useMemo(
|
||||
() => allNodes.filter((n: NodeSnapshot) => n.blacklisted).length,
|
||||
[allNodes]
|
||||
)
|
||||
|
||||
const pendingNodes = useMemo(
|
||||
() => allNodes.filter((n: NodeSnapshot) => !n.initial_check_done && !n.blacklisted).length,
|
||||
[allNodes]
|
||||
)
|
||||
|
||||
const healthRate = useMemo(() => {
|
||||
const checked = allNodes.filter((n: NodeSnapshot) => n.initial_check_done).length
|
||||
if (checked === 0) return -1
|
||||
return Math.round((availableNodes / checked) * 100)
|
||||
}, [allNodes, availableNodes])
|
||||
|
||||
const avgLatency = useMemo(() => {
|
||||
const validNodes = allNodes.filter((n: NodeSnapshot) => n.last_latency_ms > 0)
|
||||
if (validNodes.length === 0) return -1
|
||||
return Math.round(validNodes.reduce((sum: number, n: NodeSnapshot) => sum + n.last_latency_ms, 0) / validNodes.length)
|
||||
}, [allNodes])
|
||||
|
||||
const totalConnections = useMemo(
|
||||
() => allNodes.reduce((sum: number, n: NodeSnapshot) => sum + n.active_connections, 0),
|
||||
[allNodes]
|
||||
)
|
||||
|
||||
// Traffic totals
|
||||
const totalUpload = useMemo(() => data?.total_upload || 0, [data])
|
||||
const totalDownload = useMemo(() => data?.total_download || 0, [data])
|
||||
|
||||
// Speed from backend realtime stream, fallback to periodic /api/nodes snapshot values.
|
||||
const uploadSpeed = trafficRealtime.connected
|
||||
? trafficRealtime.uploadSpeed
|
||||
: (data?.upload_speed || 0)
|
||||
const downloadSpeed = trafficRealtime.connected
|
||||
? trafficRealtime.downloadSpeed
|
||||
: (data?.download_speed || 0)
|
||||
|
||||
// ---- Chart data ----
|
||||
|
||||
// Status donut - includes all node states
|
||||
const statusSegments = useMemo(() => [
|
||||
{ label: '正常', value: availableNodes, color: 'oklch(0.72 0.19 142)' },
|
||||
{ label: '不可用', value: unavailableNodes, color: 'oklch(0.63 0.24 29)' },
|
||||
{ label: '黑名单', value: blacklistedNodes, color: 'oklch(0.55 0.20 15)' },
|
||||
{ label: '待检查', value: pendingNodes, color: 'oklch(0.80 0.18 84)' },
|
||||
{ label: '已禁用', value: disabledNodes, color: 'oklch(0.65 0.05 250)' },
|
||||
], [availableNodes, unavailableNodes, blacklistedNodes, pendingNodes, disabledNodes])
|
||||
|
||||
// Latency distribution bars
|
||||
const latencyBars = useMemo(() => {
|
||||
const buckets = [
|
||||
{ label: '0-50', min: 0, max: 50, count: 0, color: 'oklch(0.72 0.19 142)' },
|
||||
{ label: '50-100', min: 50, max: 100, count: 0, color: 'oklch(0.75 0.18 120)' },
|
||||
{ label: '100-200', min: 100, max: 200, count: 0, color: 'oklch(0.80 0.18 84)' },
|
||||
{ label: '200-300', min: 200, max: 300, count: 0, color: 'oklch(0.75 0.18 55)' },
|
||||
{ label: '300+', min: 300, max: Infinity, count: 0, color: 'oklch(0.63 0.24 29)' },
|
||||
{ label: '超时', min: -1, max: 0, count: 0, color: 'oklch(0.50 0.10 250)' },
|
||||
]
|
||||
for (const node of allNodes) {
|
||||
const ms = node.last_latency_ms
|
||||
if (ms < 0 || !node.initial_check_done) {
|
||||
buckets[5].count++
|
||||
} else if (ms <= 50) {
|
||||
buckets[0].count++
|
||||
} else if (ms <= 100) {
|
||||
buckets[1].count++
|
||||
} else if (ms <= 200) {
|
||||
buckets[2].count++
|
||||
} else if (ms <= 300) {
|
||||
buckets[3].count++
|
||||
} else {
|
||||
buckets[4].count++
|
||||
}
|
||||
}
|
||||
return buckets.map(b => ({ label: b.label, value: b.count, color: b.color }))
|
||||
}, [allNodes])
|
||||
|
||||
// Latency ranking
|
||||
const rankingItems = useMemo(() =>
|
||||
allNodes
|
||||
.filter((n: NodeSnapshot) => n.last_latency_ms > 0)
|
||||
.map((n: NodeSnapshot) => ({
|
||||
name: n.name || n.tag,
|
||||
latency: n.last_latency_ms,
|
||||
region: n.region,
|
||||
})),
|
||||
[allNodes]
|
||||
)
|
||||
|
||||
// Traffic ranking
|
||||
const trafficRankItems = useMemo(() =>
|
||||
allNodes
|
||||
.filter((n: NodeSnapshot) => (n.total_upload || 0) + (n.total_download || 0) > 0)
|
||||
.map((n: NodeSnapshot) => ({
|
||||
name: n.name || n.tag,
|
||||
upload: n.total_upload || 0,
|
||||
download: n.total_download || 0,
|
||||
region: n.region,
|
||||
})),
|
||||
[allNodes]
|
||||
)
|
||||
|
||||
// Region stats
|
||||
const regionStats = useMemo(() => {
|
||||
const map = new Map<string, { total: number; healthy: number; latencies: number[] }>()
|
||||
for (const node of allNodes) {
|
||||
const region = node.region || 'other'
|
||||
const entry = map.get(region) || { total: 0, healthy: 0, latencies: [] }
|
||||
entry.total++
|
||||
if (node.initial_check_done && node.available && !node.blacklisted) {
|
||||
entry.healthy++
|
||||
}
|
||||
if (node.last_latency_ms > 0) {
|
||||
entry.latencies.push(node.last_latency_ms)
|
||||
}
|
||||
map.set(region, entry)
|
||||
}
|
||||
return Array.from(map.entries()).map(([region, stats]) => ({
|
||||
region,
|
||||
total: stats.total,
|
||||
healthy: stats.healthy,
|
||||
avgLatency: stats.latencies.length > 0
|
||||
? Math.round(stats.latencies.reduce((a, b) => a + b, 0) / stats.latencies.length)
|
||||
: 0,
|
||||
}))
|
||||
}, [allNodes])
|
||||
|
||||
// ---- Render ----
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<span className="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-full animate-in fade-in duration-500">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-30 bg-base-100/80 backdrop-blur-xl px-4 lg:px-8 py-4 border-b border-base-300/60 shadow-sm">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 max-w-[1600px] mx-auto w-full">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary shrink-0 border border-primary/20">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
节点监控
|
||||
</h2>
|
||||
<p className="text-sm text-base-content/50 mt-1.5 ml-[3.25rem]">实时数据仪表盘 · 可视化节点健康与流量状况</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Auto refresh selector */}
|
||||
<div className="flex items-center gap-2 bg-base-200/50 px-2 py-1 lg:py-1.5 rounded-lg border border-base-300/50 transition-colors">
|
||||
{autoRefresh > 0 ? (
|
||||
<div className="hidden sm:flex items-center gap-1.5 px-2 py-0.5 rounded-md text-xs font-medium text-success whitespace-nowrap">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-success animate-pulse"></div>
|
||||
<span>自动刷新开启</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="hidden sm:inline text-sm font-medium pl-2 whitespace-nowrap text-base-content/70">自动刷新</span>
|
||||
)}
|
||||
<select
|
||||
className="select select-sm border-0 focus:outline-none focus:ring-0 bg-transparent min-w-[90px] text-sm"
|
||||
value={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(Number(e.target.value) as AutoRefreshInterval)}
|
||||
>
|
||||
<option value={0}>关闭</option>
|
||||
<option value={5}>每 5 秒</option>
|
||||
<option value={10}>每 10 秒</option>
|
||||
<option value={30}>每 30 秒</option>
|
||||
<option value={60}>每 60 秒</option>
|
||||
</select>
|
||||
</div>
|
||||
<button className="btn btn-sm lg:btn-md btn-primary shadow-sm gap-2" onClick={handleRefresh} disabled={loading}>
|
||||
{loading ? (
|
||||
<span className="loading loading-spinner loading-sm"></span>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
|
||||
)}
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 lg:p-8 space-y-6 flex-1 pb-10 max-w-[1600px] mx-auto w-full">
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div role="alert" className="alert alert-error alert-soft">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{/* Total Nodes */}
|
||||
<div className="rounded-2xl bg-base-100 border border-base-300/50 p-5 shadow-sm hover:shadow-md transition-all group overflow-hidden relative">
|
||||
<div className="absolute -right-4 -top-4 w-24 h-24 bg-primary/5 rounded-full blur-2xl group-hover:bg-primary/10 transition-colors"></div>
|
||||
<div className="flex items-center gap-3 mb-2 relative z-10">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" /></svg>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-base-content/60">总节点</div>
|
||||
</div>
|
||||
<div className="text-3xl font-black tabular-nums tracking-tight text-base-content mb-2 relative z-10">{totalConfigNodes}</div>
|
||||
<div className="text-xs flex flex-wrap gap-1.5 relative z-10">
|
||||
<span className="badge badge-success badge-sm badge-outline border-success/30 bg-success/5 font-medium">可用 {availableNodes}</span>
|
||||
{unavailableNodes > 0 && <span className="badge badge-error badge-sm badge-outline border-error/30 bg-error/5 font-medium">不可用 {unavailableNodes}</span>}
|
||||
{blacklistedNodes > 0 && <span className="badge badge-error badge-sm badge-outline border-error/30 bg-error/5 font-medium">黑名单 {blacklistedNodes}</span>}
|
||||
{pendingNodes > 0 && <span className="badge badge-warning badge-sm badge-outline border-warning/30 bg-warning/5 font-medium">待检查 {pendingNodes}</span>}
|
||||
{disabledNodes > 0 && <span className="badge badge-ghost badge-sm bg-base-200/50 font-medium text-base-content/50">禁用 {disabledNodes}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Health Rate */}
|
||||
<div className="rounded-2xl bg-base-100 border border-base-300/50 p-5 shadow-sm hover:shadow-md transition-all group overflow-hidden relative">
|
||||
<div className="absolute -right-4 -top-4 w-24 h-24 bg-success/5 rounded-full blur-2xl group-hover:bg-success/10 transition-colors"></div>
|
||||
<div className="flex items-center gap-3 mb-2 relative z-10">
|
||||
<div className="w-8 h-8 rounded-lg bg-success/10 flex items-center justify-center text-success">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-base-content/60">健康率</div>
|
||||
</div>
|
||||
<div className={`text-3xl font-black tabular-nums tracking-tight mb-2 relative z-10 ${
|
||||
healthRate < 0 ? 'text-base-content/30' :
|
||||
healthRate >= 80 ? 'text-success' :
|
||||
healthRate >= 50 ? 'text-warning' : 'text-error'
|
||||
}`}>
|
||||
{healthRate >= 0 ? `${healthRate}%` : '-'}
|
||||
</div>
|
||||
<div className="text-xs font-medium text-base-content/40 relative z-10 bg-base-200/50 w-fit px-2 py-1 rounded-md">
|
||||
{healthRate >= 0 ? `${availableNodes} / ${availableNodes + unavailableNodes} 已检查` : '等待检查'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Average Latency */}
|
||||
<div className="rounded-2xl bg-base-100 border border-base-300/50 p-5 shadow-sm hover:shadow-md transition-all group overflow-hidden relative">
|
||||
<div className="absolute -right-4 -top-4 w-24 h-24 bg-warning/5 rounded-full blur-2xl group-hover:bg-warning/10 transition-colors"></div>
|
||||
<div className="flex items-center gap-3 mb-2 relative z-10">
|
||||
<div className="w-8 h-8 rounded-lg bg-warning/10 flex items-center justify-center text-warning">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-base-content/60">平均延迟</div>
|
||||
</div>
|
||||
<div className={`text-3xl font-black tabular-nums tracking-tight mb-2 relative z-10 ${latencyColor(avgLatency)}`}>
|
||||
{avgLatency > 0 ? `${avgLatency}ms` : '-'}
|
||||
</div>
|
||||
<div className="text-xs font-medium text-base-content/40 relative z-10 bg-base-200/50 w-fit px-2 py-1 rounded-md">
|
||||
{allNodes.filter(n => n.last_latency_ms > 0).length} 个节点有数据
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Connections */}
|
||||
<div className="rounded-2xl bg-base-100 border border-base-300/50 p-5 shadow-sm hover:shadow-md transition-all group overflow-hidden relative">
|
||||
<div className="absolute -right-4 -top-4 w-24 h-24 bg-info/5 rounded-full blur-2xl group-hover:bg-info/10 transition-colors"></div>
|
||||
<div className="flex items-center gap-3 mb-2 relative z-10">
|
||||
<div className="w-8 h-8 rounded-lg bg-info/10 flex items-center justify-center text-info">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-base-content/60">活跃连接</div>
|
||||
</div>
|
||||
<div className="text-3xl font-black tabular-nums tracking-tight text-info mb-2 relative z-10">{totalConnections}</div>
|
||||
<div className="text-xs font-medium text-base-content/40 relative z-10 bg-base-200/50 w-fit px-2 py-1 rounded-md">
|
||||
{allNodes.filter(n => n.active_connections > 0).length} 个节点活跃
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Speed */}
|
||||
<div className="rounded-2xl bg-base-100 border border-base-300/50 p-5 shadow-sm hover:shadow-md transition-all group overflow-hidden relative">
|
||||
<div className="absolute -right-4 -top-4 w-24 h-24 bg-blue-500/5 rounded-full blur-2xl group-hover:bg-blue-500/10 transition-colors"></div>
|
||||
<div className="flex items-center gap-3 mb-2 relative z-10">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center text-blue-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 11l5-5m0 0l5 5m-5-5v12" /></svg>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-base-content/60">上传速度</div>
|
||||
</div>
|
||||
<div className="text-3xl font-black tabular-nums tracking-tight text-blue-500 mb-2 relative z-10">{formatSpeed(uploadSpeed)}</div>
|
||||
<div className="text-xs font-medium text-base-content/40 relative z-10 bg-base-200/50 w-fit px-2 py-1 rounded-md">
|
||||
{trafficRealtime.connected ? '实时流数据' : '轮询数据'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download Speed */}
|
||||
<div className="rounded-2xl bg-base-100 border border-base-300/50 p-5 shadow-sm hover:shadow-md transition-all group overflow-hidden relative">
|
||||
<div className="absolute -right-4 -top-4 w-24 h-24 bg-emerald-500/5 rounded-full blur-2xl group-hover:bg-emerald-500/10 transition-colors"></div>
|
||||
<div className="flex items-center gap-3 mb-2 relative z-10">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-500/10 flex items-center justify-center text-emerald-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 13l-5 5m0 0l-5-5m5 5V6" /></svg>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-base-content/60">下载速度</div>
|
||||
</div>
|
||||
<div className="text-3xl font-black tabular-nums tracking-tight text-emerald-500 mb-2 relative z-10">{formatSpeed(downloadSpeed)}</div>
|
||||
<div className="text-xs font-medium text-base-content/40 relative z-10 bg-base-200/50 w-fit px-2 py-1 rounded-md">
|
||||
{trafficRealtime.connected ? '实时流数据' : '轮询数据'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Traffic */}
|
||||
<div className="rounded-2xl bg-base-100 border border-base-300/50 p-5 shadow-sm hover:shadow-md transition-all group overflow-hidden relative">
|
||||
<div className="absolute -right-4 -top-4 w-24 h-24 bg-blue-500/5 rounded-full blur-2xl group-hover:bg-blue-500/10 transition-colors"></div>
|
||||
<div className="flex items-center gap-3 mb-2 relative z-10">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center text-blue-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" /></svg>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-base-content/60">总上传</div>
|
||||
</div>
|
||||
<div className="text-3xl font-black tabular-nums tracking-tight text-blue-500 mb-2 relative z-10">{formatBytes(totalUpload)}</div>
|
||||
<div className="text-xs font-medium text-base-content/40 relative z-10 bg-base-200/50 w-fit px-2 py-1 rounded-md">累计流量统计</div>
|
||||
</div>
|
||||
|
||||
{/* Download Traffic */}
|
||||
<div className="rounded-2xl bg-base-100 border border-base-300/50 p-5 shadow-sm hover:shadow-md transition-all group overflow-hidden relative">
|
||||
<div className="absolute -right-4 -top-4 w-24 h-24 bg-emerald-500/5 rounded-full blur-2xl group-hover:bg-emerald-500/10 transition-colors"></div>
|
||||
<div className="flex items-center gap-3 mb-2 relative z-10">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-500/10 flex items-center justify-center text-emerald-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" /></svg>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-base-content/60">总下载</div>
|
||||
</div>
|
||||
<div className="text-3xl font-black tabular-nums tracking-tight text-emerald-500 mb-2 relative z-10">{formatBytes(totalDownload)}</div>
|
||||
<div className="text-xs font-medium text-base-content/40 relative z-10 bg-base-200/50 w-fit px-2 py-1 rounded-md">累计流量统计</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts Row: Donut + Bar */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Status Distribution Donut */}
|
||||
<div className="rounded-xl border border-base-300/40 bg-base-200/30 p-5">
|
||||
<h3 className="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||
<span className="w-1 h-4 bg-primary rounded-full"></span>
|
||||
状态分布
|
||||
</h3>
|
||||
<div className="flex justify-center">
|
||||
<DonutChart
|
||||
segments={statusSegments}
|
||||
size={180}
|
||||
strokeWidth={32}
|
||||
centerValue={healthRate >= 0 ? `${healthRate}%` : '-'}
|
||||
centerLabel="健康率"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Latency Distribution Bar */}
|
||||
<div className="rounded-xl border border-base-300/40 bg-base-200/30 p-5">
|
||||
<BarChart
|
||||
bars={latencyBars}
|
||||
maxHeight={130}
|
||||
title="延迟分布"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Region Cards */}
|
||||
<div className="rounded-xl border border-base-300/40 bg-base-200/30 p-5">
|
||||
<RegionCards
|
||||
regions={regionStats}
|
||||
title="地区统计"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rankings Row: Latency + Traffic */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Latency Ranking */}
|
||||
<div className="rounded-xl border border-base-300/40 bg-base-200/30 p-5">
|
||||
<LatencyRanking
|
||||
items={rankingItems}
|
||||
maxItems={10}
|
||||
title="延迟排行 TOP 10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Traffic Ranking */}
|
||||
<div className="rounded-xl border border-base-300/40 bg-base-200/30 p-5">
|
||||
<TrafficRanking
|
||||
items={trafficRankItems}
|
||||
maxItems={10}
|
||||
title="流量排行 TOP 10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer info */}
|
||||
<div className="text-center text-xs text-base-content/30">
|
||||
{data && (
|
||||
<>
|
||||
共 {totalConfigNodes} 个节点 ·
|
||||
{Object.keys(data.region_stats || {}).length} 个地区 ·
|
||||
数据来自运行时监控
|
||||
{autoRefresh > 0 && ` · 每 ${autoRefresh} 秒自动刷新`}
|
||||
{trafficRealtime.connected ? ' · 速度流已连接' : ' · 速度流重连中'}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,865 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { SettingsData, SubscriptionStatus } from '../types'
|
||||
import { fetchSettings, updateSettings, triggerReload, fetchSubscriptionStatus, refreshSubscription } from '../api/client'
|
||||
|
||||
const defaultSettings: SettingsData = {
|
||||
mode: 'pool',
|
||||
log_level: 'info',
|
||||
external_ip: '',
|
||||
skip_cert_verify: false,
|
||||
|
||||
listener_address: '0.0.0.0',
|
||||
listener_port: 2323,
|
||||
listener_username: '',
|
||||
listener_password: '',
|
||||
|
||||
multi_port_address: '0.0.0.0',
|
||||
multi_port_base_port: 24000,
|
||||
multi_port_username: '',
|
||||
multi_port_password: '',
|
||||
|
||||
pool_mode: 'sequential',
|
||||
pool_failure_threshold: 3,
|
||||
pool_blacklist_duration: '24h0m0s',
|
||||
|
||||
management_enabled: true,
|
||||
management_listen: '0.0.0.0:9090',
|
||||
management_probe_target: '',
|
||||
management_password: '',
|
||||
|
||||
sub_refresh_enabled: false,
|
||||
sub_refresh_interval: '1h0m0s',
|
||||
sub_refresh_timeout: '30s',
|
||||
sub_refresh_health_check_timeout: '1m0s',
|
||||
sub_refresh_drain_timeout: '30s',
|
||||
sub_refresh_min_available_nodes: 1,
|
||||
|
||||
geoip_enabled: false,
|
||||
geoip_database_path: './GeoLite2-Country.mmdb',
|
||||
geoip_auto_update_enabled: true,
|
||||
geoip_auto_update_interval: '24h0m0s',
|
||||
|
||||
subscriptions: [],
|
||||
}
|
||||
|
||||
export default function SettingsPanel() {
|
||||
const [settings, setSettings] = useState<SettingsData>(defaultSettings)
|
||||
const [savedSettings, setSavedSettings] = useState<SettingsData>(defaultSettings)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [reloading, setReloading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [needReload, setNeedReload] = useState(false)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
|
||||
// Subscription status
|
||||
const [subStatus, setSubStatus] = useState<SubscriptionStatus | null>(null)
|
||||
const [subRefreshing, setSubRefreshing] = useState(false)
|
||||
|
||||
// New subscription input
|
||||
const [newSubUrl, setNewSubUrl] = useState('')
|
||||
|
||||
const refreshSubStatus = useCallback(async () => {
|
||||
try {
|
||||
const subData = await fetchSubscriptionStatus()
|
||||
if (subData) setSubStatus(subData)
|
||||
} catch {
|
||||
// ignore errors
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const [settingsData] = await Promise.all([
|
||||
fetchSettings(),
|
||||
refreshSubStatus(),
|
||||
])
|
||||
const merged = { ...defaultSettings, ...settingsData }
|
||||
setSettings(merged)
|
||||
setSavedSettings(merged)
|
||||
setIsDirty(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载设置失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [refreshSubStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (success) {
|
||||
const timer = setTimeout(() => setSuccess(''), 5000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [success])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
setSuccess('')
|
||||
try {
|
||||
const res = await updateSettings(settings)
|
||||
setSuccess(res.message || '设置已保存')
|
||||
setSavedSettings({ ...settings })
|
||||
setIsDirty(false)
|
||||
if (res.need_reload) setNeedReload(true)
|
||||
// Refresh subscription status after saving (config may have changed)
|
||||
await refreshSubStatus()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReload = async () => {
|
||||
setReloading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await triggerReload()
|
||||
setSuccess(res.message || '重载成功')
|
||||
setNeedReload(false)
|
||||
// Refresh subscription status after reload (subscription manager config updated)
|
||||
await refreshSubStatus()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '重载失败')
|
||||
} finally {
|
||||
setReloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubRefresh = async () => {
|
||||
setSubRefreshing(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await refreshSubscription()
|
||||
setSuccess(`订阅刷新成功,共 ${res.node_count} 个节点`)
|
||||
await refreshSubStatus()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '刷新订阅失败')
|
||||
} finally {
|
||||
setSubRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const addSubscription = () => {
|
||||
const url = newSubUrl.trim()
|
||||
if (!url) return
|
||||
if (settings.subscriptions.includes(url)) {
|
||||
setError('该订阅地址已存在')
|
||||
return
|
||||
}
|
||||
setSettings(s => {
|
||||
const updated = { ...s, subscriptions: [...s.subscriptions, url] }
|
||||
setIsDirty(JSON.stringify(updated) !== JSON.stringify(savedSettings))
|
||||
return updated
|
||||
})
|
||||
setNewSubUrl('')
|
||||
}
|
||||
|
||||
const removeSubscription = (index: number) => {
|
||||
setSettings(s => {
|
||||
const updated = { ...s, subscriptions: s.subscriptions.filter((_, i) => i !== index) }
|
||||
setIsDirty(JSON.stringify(updated) !== JSON.stringify(savedSettings))
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
const updateField = <K extends keyof SettingsData>(key: K, value: SettingsData[K]) => {
|
||||
setSettings(s => {
|
||||
const updated = { ...s, [key]: value }
|
||||
setIsDirty(JSON.stringify(updated) !== JSON.stringify(savedSettings))
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
// Warn before leaving with unsaved changes
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (isDirty) {
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [isDirty])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<span className="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-full animate-in fade-in duration-500">
|
||||
{/* Header - sticky */}
|
||||
<div className="sticky top-0 z-30 bg-base-100/80 backdrop-blur-xl px-4 lg:px-8 py-4 border-b border-base-300/60 shadow-sm">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 max-w-[1200px] mx-auto w-full">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary shrink-0 border border-primary/20">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
系统设置
|
||||
</h2>
|
||||
<p className="text-sm text-base-content/50 mt-1.5 ml-[3.25rem]">管理系统所有配置项,修改后需保存生效</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className={`btn btn-sm lg:btn-md gap-2 shadow-sm ${isDirty ? 'btn-primary' : 'btn-ghost border border-base-300'}`}
|
||||
onClick={handleSave}
|
||||
disabled={saving || !isDirty}
|
||||
>
|
||||
{saving ? <span className="loading loading-spinner loading-sm"></span> : isDirty ? (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
|
||||
</svg>
|
||||
保存设置
|
||||
</>
|
||||
) : '✅ 已保存'}
|
||||
</button>
|
||||
{needReload && (
|
||||
<button
|
||||
className="btn btn-warning btn-sm lg:btn-md gap-2 shadow-sm animate-pulse"
|
||||
onClick={handleReload}
|
||||
disabled={reloading}
|
||||
>
|
||||
{reloading ? <span className="loading loading-spinner loading-sm"></span> : (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
重载配置
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 lg:p-8 space-y-6 max-w-[1200px] mx-auto w-full pb-10 flex-1">
|
||||
{/* Alerts */}
|
||||
{error && (
|
||||
<div role="alert" className="alert alert-error alert-soft text-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div role="alert" className="alert alert-success alert-soft text-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{success}</span>
|
||||
</div>
|
||||
)}
|
||||
{needReload && (
|
||||
<div role="alert" className="alert alert-warning alert-soft text-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<div>
|
||||
<span>配置已保存。</span>
|
||||
<span className="font-medium">WebUI 密码、探测目标、外部 IP、SSL 验证</span>
|
||||
<span>已立即生效;其他配置(运行模式、监听端口、代理池等)需要点击「重载配置」才能生效。</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings Cards Grid */}
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
|
||||
{/* ===== 全局设置 ===== */}
|
||||
<div className="rounded-2xl border border-base-300/50 bg-base-100 p-6 lg:p-8 space-y-5 shadow-sm transition-shadow hover:shadow-md">
|
||||
<div className="flex items-center gap-3 mb-2 border-b border-base-200 pb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-info/10 flex items-center justify-center text-info shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-base-content">全局设置</h3>
|
||||
<p className="text-xs text-base-content/50 font-medium">系统基础运行参数</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset className="fieldset">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/80">运行模式</legend>
|
||||
<select
|
||||
className="select select-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
value={settings.mode}
|
||||
onChange={(e) => updateField('mode', e.target.value)}
|
||||
>
|
||||
<option value="pool">pool - 单端口代理池</option>
|
||||
<option value="multi-port">multi-port - 多端口模式</option>
|
||||
<option value="hybrid">hybrid - 混合模式</option>
|
||||
</select>
|
||||
<p className="label text-base-content/50 mt-1">pool: 共享端口 | multi-port: 独立端口 | hybrid: 两者并存</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="fieldset">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/80">日志级别</legend>
|
||||
<select
|
||||
className="select select-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
value={settings.log_level}
|
||||
onChange={(e) => updateField('log_level', e.target.value)}
|
||||
>
|
||||
<option value="debug">debug</option>
|
||||
<option value="info">info</option>
|
||||
<option value="warn">warn</option>
|
||||
<option value="error">error</option>
|
||||
</select>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="fieldset">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/80">外部 IP 地址</legend>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="例如: 1.2.3.4"
|
||||
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
value={settings.external_ip}
|
||||
onChange={(e) => updateField('external_ip', e.target.value)}
|
||||
/>
|
||||
<p className="label text-base-content/50 mt-1">用于导出时替换 0.0.0.0</p>
|
||||
</fieldset>
|
||||
|
||||
<label className="flex items-center justify-between cursor-pointer gap-4 bg-base-200/30 p-4 rounded-xl border border-base-200 hover:border-base-300 transition-colors">
|
||||
<div>
|
||||
<span className="font-semibold text-base-content/90 block mb-0.5">跳过 SSL 证书验证</span>
|
||||
<p className="text-xs text-base-content/50 m-0">全局跳过上游代理的 SSL 证书验证</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="toggle toggle-primary toggle-md"
|
||||
checked={settings.skip_cert_verify}
|
||||
onChange={(e) => updateField('skip_cert_verify', e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* ===== 监听配置 (Pool 入口) ===== */}
|
||||
<div className="rounded-2xl border border-base-300/50 bg-base-100 p-6 lg:p-8 space-y-5 shadow-sm transition-shadow hover:shadow-md">
|
||||
<div className="flex items-center gap-3 mb-2 border-b border-base-200 pb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-success/10 flex items-center justify-center text-success shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-base-content">监听配置 (Pool)</h3>
|
||||
<p className="text-xs text-base-content/50 font-medium">代理池入口网络参数</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<fieldset className="fieldset">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/80">监听地址</legend>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
value={settings.listener_address}
|
||||
onChange={(e) => updateField('listener_address', e.target.value)}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset className="fieldset">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/80">监听端口</legend>
|
||||
<input
|
||||
type="number"
|
||||
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
value={settings.listener_port}
|
||||
onChange={(e) => updateField('listener_port', parseInt(e.target.value) || 0)}
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
<fieldset className="fieldset">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/80">代理用户名</legend>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
placeholder="可选,留空表示无验证"
|
||||
value={settings.listener_username}
|
||||
onChange={(e) => updateField('listener_username', e.target.value)}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset className="fieldset">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/80">代理密码</legend>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
placeholder="可选,留空表示无验证"
|
||||
value={settings.listener_password}
|
||||
onChange={(e) => updateField('listener_password', e.target.value)}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== 多端口配置 ===== */}
|
||||
<div className="rounded-2xl border border-base-300/50 bg-base-100 p-6 lg:p-8 space-y-5 shadow-sm transition-shadow hover:shadow-md">
|
||||
<div className="flex items-center gap-3 mb-2 border-b border-base-200 pb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-secondary/10 flex items-center justify-center text-secondary shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-base-content">多端口配置</h3>
|
||||
<p className="text-xs text-base-content/50 font-medium">用于 multi-port 或 hybrid 模式</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<fieldset className="fieldset">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/80">监听地址</legend>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
value={settings.multi_port_address}
|
||||
onChange={(e) => updateField('multi_port_address', e.target.value)}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset className="fieldset">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/80">起始端口</legend>
|
||||
<input
|
||||
type="number"
|
||||
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
value={settings.multi_port_base_port}
|
||||
onChange={(e) => updateField('multi_port_base_port', parseInt(e.target.value) || 0)}
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
<fieldset className="fieldset">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/80">默认用户名</legend>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
placeholder="可选"
|
||||
value={settings.multi_port_username}
|
||||
onChange={(e) => updateField('multi_port_username', e.target.value)}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset className="fieldset">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/80">默认密码</legend>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
placeholder="可选"
|
||||
value={settings.multi_port_password}
|
||||
onChange={(e) => updateField('multi_port_password', e.target.value)}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== 代理池配置 ===== */}
|
||||
<div className="rounded-2xl border border-base-300/50 bg-base-100 p-6 lg:p-8 space-y-5 shadow-sm transition-shadow hover:shadow-md">
|
||||
<div className="flex items-center gap-3 mb-2 border-b border-base-200 pb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center text-accent shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 002-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-base-content">代理池调度</h3>
|
||||
<p className="text-xs text-base-content/50 font-medium">节点选择与高可用策略</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset className="fieldset">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/80">调度模式</legend>
|
||||
<select
|
||||
className="select select-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
value={settings.pool_mode}
|
||||
onChange={(e) => updateField('pool_mode', e.target.value)}
|
||||
>
|
||||
<option value="sequential">sequential - 顺序轮询</option>
|
||||
<option value="random">random - 随机选择</option>
|
||||
<option value="balance">balance - 最小连接数负载均衡</option>
|
||||
</select>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="fieldset">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/80">失败阈值</legend>
|
||||
<input
|
||||
type="number"
|
||||
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
value={settings.pool_failure_threshold}
|
||||
onChange={(e) => updateField('pool_failure_threshold', parseInt(e.target.value) || 1)}
|
||||
min={1}
|
||||
/>
|
||||
<p className="label text-base-content/50 mt-1">连续失败多少次后加入黑名单</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="fieldset">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/80">黑名单持续时间</legend>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
placeholder="例如: 24h, 1h30m"
|
||||
value={settings.pool_blacklist_duration}
|
||||
onChange={(e) => updateField('pool_blacklist_duration', e.target.value)}
|
||||
/>
|
||||
<p className="label text-base-content/50 mt-1">Go duration 格式: 24h, 1h30m, 30m 等</p>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{/* ===== 管理面板 ===== */}
|
||||
<div className="rounded-2xl border border-base-300/50 bg-base-100 p-6 lg:p-8 space-y-5 shadow-sm transition-shadow hover:shadow-md">
|
||||
<div className="flex items-center gap-3 mb-2 border-b border-base-200 pb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-base-content">管理面板</h3>
|
||||
<p className="text-xs text-base-content/50 font-medium">Web 界面及探针设置</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center justify-between cursor-pointer gap-4 bg-base-200/30 p-4 rounded-xl border border-base-200 hover:border-base-300 transition-colors">
|
||||
<span className="font-semibold text-base-content/90">启用管理面板</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="toggle toggle-primary toggle-md"
|
||||
checked={settings.management_enabled}
|
||||
onChange={(e) => updateField('management_enabled', e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<fieldset className="fieldset">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/80">监听地址</legend>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
placeholder="0.0.0.0:9090"
|
||||
value={settings.management_listen}
|
||||
onChange={(e) => updateField('management_listen', e.target.value)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="fieldset">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/80">探测目标</legend>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
placeholder="http://www.google.com"
|
||||
value={settings.management_probe_target}
|
||||
onChange={(e) => updateField('management_probe_target', e.target.value)}
|
||||
/>
|
||||
<p className="label text-base-content/50 mt-1">健康检查的目标地址</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="fieldset">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/80">WebUI 密码</legend>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
placeholder="为空则无需密码"
|
||||
value={settings.management_password}
|
||||
onChange={(e) => updateField('management_password', e.target.value)}
|
||||
/>
|
||||
<p className="label text-base-content/50 mt-1">为空则不需要登录密码</p>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{/* ===== GeoIP ===== */}
|
||||
<div className="rounded-2xl border border-base-300/50 bg-base-100 p-6 lg:p-8 space-y-5 shadow-sm transition-shadow hover:shadow-md">
|
||||
<div className="flex items-center gap-3 mb-2 border-b border-base-200 pb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-info/10 flex items-center justify-center text-info shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-base-content">GeoIP 地域分区</h3>
|
||||
<p className="text-xs text-base-content/50 font-medium">节点地域解析与自动更新</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center justify-between cursor-pointer gap-4 bg-base-200/30 p-4 rounded-xl border border-base-200 hover:border-base-300 transition-colors">
|
||||
<div>
|
||||
<span className="font-semibold text-base-content/90 block mb-0.5">启用 GeoIP</span>
|
||||
<p className="text-xs text-base-content/50 m-0">按地域自动分组节点</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="toggle toggle-primary toggle-md"
|
||||
checked={settings.geoip_enabled}
|
||||
onChange={(e) => updateField('geoip_enabled', e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{settings.geoip_enabled && (
|
||||
<div className="space-y-4 pt-2 animate-in fade-in slide-in-from-top-2">
|
||||
<fieldset className="fieldset">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/80">数据库路径</legend>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
value={settings.geoip_database_path}
|
||||
onChange={(e) => updateField('geoip_database_path', e.target.value)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<label className="flex items-center justify-between cursor-pointer gap-4 bg-base-200/30 p-4 rounded-xl border border-base-200 hover:border-base-300 transition-colors">
|
||||
<span className="font-semibold text-base-content/90">自动更新数据库</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="toggle toggle-primary toggle-md"
|
||||
checked={settings.geoip_auto_update_enabled}
|
||||
onChange={(e) => updateField('geoip_auto_update_enabled', e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{settings.geoip_auto_update_enabled && (
|
||||
<fieldset className="fieldset animate-in fade-in slide-in-from-top-2">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/80">更新间隔</legend>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
placeholder="24h"
|
||||
value={settings.geoip_auto_update_interval}
|
||||
onChange={(e) => updateField('geoip_auto_update_interval', e.target.value)}
|
||||
/>
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== 订阅刷新 ===== */}
|
||||
<div className="rounded-2xl border border-base-300/50 bg-base-100 p-6 lg:p-8 space-y-5 shadow-sm transition-shadow hover:shadow-md">
|
||||
<div className="flex items-center gap-3 mb-2 border-b border-base-200 pb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-warning/10 flex items-center justify-center text-warning shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-base-content">订阅自动刷新</h3>
|
||||
<p className="text-xs text-base-content/50 font-medium">配置订阅定时更新及健康检查</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center justify-between cursor-pointer gap-4 bg-base-200/30 p-4 rounded-xl border border-base-200 hover:border-base-300 transition-colors">
|
||||
<span className="font-semibold text-base-content/90">启用定时刷新</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="toggle toggle-primary toggle-md"
|
||||
checked={settings.sub_refresh_enabled}
|
||||
onChange={(e) => updateField('sub_refresh_enabled', e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{settings.sub_refresh_enabled && (
|
||||
<div className="space-y-4 pt-2 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<fieldset className="fieldset">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/80">刷新间隔</legend>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
placeholder="1h"
|
||||
value={settings.sub_refresh_interval}
|
||||
onChange={(e) => updateField('sub_refresh_interval', e.target.value)}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset className="fieldset">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/80">获取超时</legend>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
placeholder="30s"
|
||||
value={settings.sub_refresh_timeout}
|
||||
onChange={(e) => updateField('sub_refresh_timeout', e.target.value)}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<fieldset className="fieldset">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/80">健康检查超时</legend>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
placeholder="1m"
|
||||
value={settings.sub_refresh_health_check_timeout}
|
||||
onChange={(e) => updateField('sub_refresh_health_check_timeout', e.target.value)}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset className="fieldset">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/80">排空超时</legend>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
placeholder="30s"
|
||||
value={settings.sub_refresh_drain_timeout}
|
||||
onChange={(e) => updateField('sub_refresh_drain_timeout', e.target.value)}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<fieldset className="fieldset">
|
||||
<legend className="fieldset-legend font-semibold text-base-content/80">最少可用节点数</legend>
|
||||
<input
|
||||
type="number"
|
||||
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
value={settings.sub_refresh_min_available_nodes}
|
||||
onChange={(e) => updateField('sub_refresh_min_available_nodes', parseInt(e.target.value) || 1)}
|
||||
min={0}
|
||||
/>
|
||||
<p className="label text-base-content/50 mt-1">低于此值时不切换新节点</p>
|
||||
</fieldset>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== 订阅管理 (full width) ===== */}
|
||||
<div className="rounded-2xl border border-base-300/50 bg-base-100 p-6 lg:p-8 space-y-5 shadow-sm transition-shadow hover:shadow-md lg:col-span-2">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 border-b border-base-200 pb-4 mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-error/10 flex items-center justify-center text-error shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-base-content">订阅链接管理</h3>
|
||||
<p className="text-xs text-base-content/50 font-medium">配置节点获取来源</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show refresh button when subscriptions exist (saved or in current settings) */}
|
||||
{(subStatus?.has_subscriptions || settings.subscriptions.length > 0) && (
|
||||
<div className="flex items-center gap-3 bg-base-200/50 px-3 py-1.5 rounded-lg border border-base-300/50">
|
||||
{subStatus && subStatus.node_count != null && subStatus.node_count > 0 && (
|
||||
<span className="text-sm font-medium text-base-content/70">
|
||||
节点: <strong className="text-base-content">{subStatus.node_count}</strong>
|
||||
</span>
|
||||
)}
|
||||
{subStatus?.enabled && (
|
||||
<span className="badge badge-success badge-sm border-none bg-success/20 text-success font-semibold">自动刷新</span>
|
||||
)}
|
||||
<div className="w-px h-4 bg-base-300 mx-1"></div>
|
||||
<button
|
||||
className="btn btn-sm btn-ghost hover:bg-primary/10 hover:text-primary gap-1.5 px-2"
|
||||
onClick={handleSubRefresh}
|
||||
disabled={subRefreshing || subStatus?.is_refreshing || isDirty}
|
||||
title={isDirty ? '请先保存设置并重载配置' : '立即刷新订阅'}
|
||||
>
|
||||
{subRefreshing || subStatus?.is_refreshing ? (
|
||||
<span className="loading loading-spinner loading-xs"></span>
|
||||
) : (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
立即刷新
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{subStatus?.last_error && (
|
||||
<div role="alert" className="alert alert-error alert-soft text-sm py-3 animate-in fade-in">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{subStatus.last_error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add subscription */}
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-base-content/40">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-md w-full pl-10 font-mono text-sm bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
|
||||
placeholder="https://example.com/subscribe?token=xxx"
|
||||
value={newSubUrl}
|
||||
onChange={(e) => setNewSubUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addSubscription()}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-md btn-primary shadow-sm"
|
||||
onClick={addSubscription}
|
||||
disabled={!newSubUrl.trim()}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Subscription list */}
|
||||
{settings.subscriptions.length > 0 ? (
|
||||
<div className="space-y-3 mt-4">
|
||||
{settings.subscriptions.map((url, index) => (
|
||||
<div key={index} className="flex items-center gap-3 p-3 lg:p-4 rounded-xl border border-base-200 bg-base-200/30 hover:bg-base-200/60 transition-colors group">
|
||||
<div className="flex-1 min-w-0">
|
||||
<code className="text-sm font-mono text-base-content/80 break-all">{url}</code>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm btn-square btn-ghost text-base-content/40 hover:text-error hover:bg-error/10 shrink-0 opacity-0 group-hover:opacity-100 transition-all"
|
||||
onClick={() => removeSubscription(index)}
|
||||
title="删除订阅"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-10 px-4 text-center rounded-xl border border-dashed border-base-300 bg-base-200/20">
|
||||
<div className="w-12 h-12 rounded-full bg-base-200 flex items-center justify-center text-base-content/30 mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-base font-medium text-base-content/60">暂无订阅链接</p>
|
||||
<p className="text-sm text-base-content/40 mt-1">在上方输入框添加您的节点订阅地址</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-base-content/40 text-center mt-4">⚠️ 添加或删除订阅后,需点击顶部「保存设置」并「重载配置」才能生效</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
interface BarData {
|
||||
label: string
|
||||
value: number
|
||||
color: string
|
||||
}
|
||||
|
||||
interface BarChartProps {
|
||||
bars: BarData[]
|
||||
maxHeight?: number
|
||||
title?: string
|
||||
}
|
||||
|
||||
export default function BarChart({ bars, maxHeight = 120, title }: BarChartProps) {
|
||||
const maxValue = Math.max(...bars.map(b => b.value), 1)
|
||||
|
||||
if (bars.every(b => b.value === 0)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<div className="text-base-content/30 text-sm">暂无数据</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{title && (
|
||||
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<span className="w-1 h-4 bg-primary rounded-full"></span>
|
||||
{title}
|
||||
</h4>
|
||||
)}
|
||||
<div className="flex items-end gap-2 justify-center" style={{ height: maxHeight + 32 }}>
|
||||
{bars.map((bar, i) => {
|
||||
const height = (bar.value / maxValue) * maxHeight
|
||||
return (
|
||||
<div key={i} className="flex flex-col items-center gap-1 flex-1 min-w-0">
|
||||
{/* Value label */}
|
||||
<span className="text-xs font-mono font-medium text-base-content/60 tabular-nums">
|
||||
{bar.value > 0 ? bar.value : ''}
|
||||
</span>
|
||||
{/* Bar */}
|
||||
<div
|
||||
className="w-full max-w-12 rounded-t-md transition-all duration-500 min-h-[2px]"
|
||||
style={{
|
||||
height: Math.max(height, bar.value > 0 ? 4 : 2),
|
||||
backgroundColor: bar.value > 0 ? bar.color : 'oklch(var(--bc) / 0.1)',
|
||||
}}
|
||||
/>
|
||||
{/* Label */}
|
||||
<span className="text-[10px] text-base-content/50 text-center leading-tight whitespace-nowrap">
|
||||
{bar.label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
interface DonutSegment {
|
||||
label: string
|
||||
value: number
|
||||
color: string
|
||||
}
|
||||
|
||||
interface DonutChartProps {
|
||||
segments: DonutSegment[]
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
centerLabel?: string
|
||||
centerValue?: string
|
||||
}
|
||||
|
||||
export default function DonutChart({
|
||||
segments,
|
||||
size = 160,
|
||||
strokeWidth = 28,
|
||||
centerLabel,
|
||||
centerValue,
|
||||
}: DonutChartProps) {
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const center = size / 2
|
||||
const total = segments.reduce((sum, s) => sum + s.value, 0)
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
className="text-base-300/50"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute text-center">
|
||||
<div className="text-2xl font-bold text-base-content/30">-</div>
|
||||
<div className="text-xs text-base-content/30">无数据</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const filteredSegments = segments.filter(s => s.value > 0)
|
||||
const arcs = filteredSegments.reduce<Array<DonutSegment & {
|
||||
dashLength: number
|
||||
gapLength: number
|
||||
dashOffset: number
|
||||
percentage: number
|
||||
}>>((acc, segment) => {
|
||||
const accumulated = acc.reduce((sum, a) => sum + a.value / total, 0)
|
||||
const ratio = segment.value / total
|
||||
const dashLength = ratio * circumference
|
||||
const dashOffset = -accumulated * circumference + circumference * 0.25
|
||||
acc.push({
|
||||
...segment,
|
||||
dashLength,
|
||||
gapLength: circumference - dashLength,
|
||||
dashOffset,
|
||||
percentage: Math.round(ratio * 100),
|
||||
})
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
className="text-base-300/30"
|
||||
/>
|
||||
{/* Segments */}
|
||||
{arcs.map((arc, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={arc.color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={`${arc.dashLength} ${arc.gapLength}`}
|
||||
strokeDashoffset={arc.dashOffset}
|
||||
strokeLinecap="butt"
|
||||
className="transition-all duration-500"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
{/* Center text */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
{centerValue && (
|
||||
<div className="text-2xl font-bold">{centerValue}</div>
|
||||
)}
|
||||
{centerLabel && (
|
||||
<div className="text-xs text-base-content/50">{centerLabel}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1">
|
||||
{arcs.map((arc, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 text-xs">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: arc.color }}
|
||||
/>
|
||||
<span className="text-base-content/70">{arc.label}</span>
|
||||
<span className="font-mono font-medium">{arc.value}</span>
|
||||
<span className="text-base-content/40">({arc.percentage}%)</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
interface RankItem {
|
||||
name: string
|
||||
latency: number
|
||||
region?: string
|
||||
flag?: string
|
||||
}
|
||||
|
||||
interface LatencyRankingProps {
|
||||
items: RankItem[]
|
||||
maxItems?: number
|
||||
title?: string
|
||||
}
|
||||
|
||||
function latencyColor(ms: number): string {
|
||||
if (ms <= 100) return 'oklch(0.72 0.19 142)' // green
|
||||
if (ms <= 200) return 'oklch(0.80 0.18 84)' // yellow
|
||||
if (ms <= 300) return 'oklch(0.75 0.18 55)' // orange
|
||||
return 'oklch(0.63 0.24 29)' // red
|
||||
}
|
||||
|
||||
function regionFlag(region?: string): string {
|
||||
const flags: Record<string, string> = {
|
||||
hk: '🇭🇰', jp: '🇯🇵', kr: '🇰🇷', us: '🇺🇸', tw: '🇹🇼',
|
||||
sg: '🇸🇬', de: '🇩🇪', gb: '🇬🇧', fr: '🇫🇷', ca: '🇨🇦',
|
||||
au: '🇦🇺', in: '🇮🇳', br: '🇧🇷', ru: '🇷🇺', nl: '🇳🇱',
|
||||
}
|
||||
return flags[region?.toLowerCase() || ''] || '🌐'
|
||||
}
|
||||
|
||||
export default function LatencyRanking({ items, maxItems = 10, title }: LatencyRankingProps) {
|
||||
const sorted = [...items]
|
||||
.filter(i => i.latency > 0)
|
||||
.sort((a, b) => a.latency - b.latency)
|
||||
.slice(0, maxItems)
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<div className="text-base-content/30 text-sm">暂无延迟数据</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const maxLatency = sorted[sorted.length - 1].latency
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{title && (
|
||||
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<span className="w-1 h-4 bg-accent rounded-full"></span>
|
||||
{title}
|
||||
</h4>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{sorted.map((item, i) => {
|
||||
const widthPct = Math.max((item.latency / maxLatency) * 100, 8)
|
||||
const flag = item.flag || regionFlag(item.region)
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
{/* Rank */}
|
||||
<span className="w-5 text-xs font-mono text-base-content/40 text-right shrink-0">
|
||||
{i + 1}
|
||||
</span>
|
||||
{/* Flag + Name */}
|
||||
<span className="text-sm shrink-0 w-32 truncate">
|
||||
{flag} {item.name}
|
||||
</span>
|
||||
{/* Bar */}
|
||||
<div className="flex-1 h-5 bg-base-300/30 rounded-md overflow-hidden relative">
|
||||
<div
|
||||
className="h-full rounded-md transition-all duration-500"
|
||||
style={{
|
||||
width: `${widthPct}%`,
|
||||
backgroundColor: latencyColor(item.latency),
|
||||
opacity: 0.8,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Latency value */}
|
||||
<span className="text-xs font-mono font-medium w-14 text-right shrink-0 tabular-nums"
|
||||
style={{ color: latencyColor(item.latency) }}
|
||||
>
|
||||
{item.latency}ms
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
interface RegionStat {
|
||||
region: string
|
||||
total: number
|
||||
healthy: number
|
||||
avgLatency: number
|
||||
}
|
||||
|
||||
interface RegionCardsProps {
|
||||
regions: RegionStat[]
|
||||
title?: string
|
||||
}
|
||||
|
||||
function regionFlag(region?: string): string {
|
||||
const flags: Record<string, string> = {
|
||||
hk: '🇭🇰', jp: '🇯🇵', kr: '🇰🇷', us: '🇺🇸', tw: '🇹🇼',
|
||||
sg: '🇸🇬', de: '🇩🇪', gb: '🇬🇧', fr: '🇫🇷', ca: '🇨🇦',
|
||||
au: '🇦🇺', in: '🇮🇳', br: '🇧🇷', ru: '🇷🇺', nl: '🇳🇱',
|
||||
}
|
||||
return flags[region?.toLowerCase() || ''] || '🌐'
|
||||
}
|
||||
|
||||
function healthColor(rate: number): string {
|
||||
if (rate >= 0.8) return 'progress-success'
|
||||
if (rate >= 0.5) return 'progress-warning'
|
||||
return 'progress-error'
|
||||
}
|
||||
|
||||
function latencyColorClass(ms: number): string {
|
||||
if (ms <= 0) return 'text-base-content/40'
|
||||
if (ms <= 100) return 'text-success'
|
||||
if (ms <= 300) return 'text-warning'
|
||||
return 'text-error'
|
||||
}
|
||||
|
||||
export default function RegionCards({ regions, title }: RegionCardsProps) {
|
||||
const sorted = [...regions].sort((a, b) => b.total - a.total)
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<div className="text-base-content/30 text-sm">暂无地区数据</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{title && (
|
||||
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<span className="w-1 h-4 bg-secondary rounded-full"></span>
|
||||
{title}
|
||||
</h4>
|
||||
)}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{sorted.map((r) => {
|
||||
const healthRate = r.total > 0 ? r.healthy / r.total : 0
|
||||
const healthPct = Math.round(healthRate * 100)
|
||||
return (
|
||||
<div
|
||||
key={r.region}
|
||||
className="rounded-xl bg-base-200/50 border border-base-300/30 p-3 hover:border-primary/30 transition-colors"
|
||||
>
|
||||
{/* Header: Flag + Region */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">{regionFlag(r.region)}</span>
|
||||
<span className="font-semibold text-sm uppercase">{r.region}</span>
|
||||
<span className="text-xs text-base-content/40 ml-auto">{r.total} 节点</span>
|
||||
</div>
|
||||
{/* Health bar */}
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<progress
|
||||
className={`progress ${healthColor(healthRate)} h-2 flex-1`}
|
||||
value={healthPct}
|
||||
max={100}
|
||||
/>
|
||||
<span className="text-xs font-mono font-medium w-8 text-right tabular-nums">
|
||||
{healthPct}%
|
||||
</span>
|
||||
</div>
|
||||
{/* Stats row */}
|
||||
<div className="flex justify-between text-xs text-base-content/50">
|
||||
<span>
|
||||
健康 <strong className="text-success">{r.healthy}</strong>/{r.total}
|
||||
</span>
|
||||
<span className={latencyColorClass(r.avgLatency)}>
|
||||
{r.avgLatency > 0 ? `${r.avgLatency}ms` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { formatBytes } from '../../utils/format'
|
||||
|
||||
interface TrafficRankItem {
|
||||
name: string
|
||||
upload: number
|
||||
download: number
|
||||
region?: string
|
||||
}
|
||||
|
||||
interface TrafficRankingProps {
|
||||
items: TrafficRankItem[]
|
||||
maxItems?: number
|
||||
title?: string
|
||||
}
|
||||
|
||||
function regionFlag(region?: string): string {
|
||||
const flags: Record<string, string> = {
|
||||
hk: '🇭🇰', jp: '🇯🇵', kr: '🇰🇷', us: '🇺🇸', tw: '🇹🇼',
|
||||
sg: '🇸🇬', de: '🇩🇪', gb: '🇬🇧', fr: '🇫🇷', ca: '🇨🇦',
|
||||
au: '🇦🇺', in: '🇮🇳', br: '🇧🇷', ru: '🇷🇺', nl: '🇳🇱',
|
||||
}
|
||||
return flags[region?.toLowerCase() || ''] || '🌐'
|
||||
}
|
||||
|
||||
export default function TrafficRanking({ items, maxItems = 10, title }: TrafficRankingProps) {
|
||||
const sorted = [...items]
|
||||
.filter(i => (i.upload + i.download) > 0)
|
||||
.sort((a, b) => (b.upload + b.download) - (a.upload + a.download))
|
||||
.slice(0, maxItems)
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<div className="text-base-content/30 text-sm">暂无流量数据</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const maxTraffic = sorted[0].upload + sorted[0].download
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{title && (
|
||||
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<span className="w-1 h-4 bg-secondary rounded-full"></span>
|
||||
{title}
|
||||
</h4>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{sorted.map((item, i) => {
|
||||
const total = item.upload + item.download
|
||||
const widthPct = Math.max((total / maxTraffic) * 100, 8)
|
||||
const uploadPct = total > 0 ? (item.upload / total) * 100 : 0
|
||||
const flag = regionFlag(item.region)
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
{/* Rank */}
|
||||
<span className="w-5 text-xs font-mono text-base-content/40 text-right shrink-0">
|
||||
{i + 1}
|
||||
</span>
|
||||
{/* Flag + Name */}
|
||||
<span className="text-sm shrink-0 w-32 truncate">
|
||||
{flag} {item.name}
|
||||
</span>
|
||||
{/* Stacked bar: upload + download */}
|
||||
<div className="flex-1 h-5 bg-base-300/30 rounded-md overflow-hidden relative">
|
||||
<div
|
||||
className="h-full rounded-md flex transition-all duration-500"
|
||||
style={{ width: `${widthPct}%` }}
|
||||
>
|
||||
{/* Upload portion */}
|
||||
<div
|
||||
className="h-full"
|
||||
style={{
|
||||
width: `${uploadPct}%`,
|
||||
backgroundColor: 'oklch(0.70 0.15 250)',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
/>
|
||||
{/* Download portion */}
|
||||
<div
|
||||
className="h-full flex-1"
|
||||
style={{
|
||||
backgroundColor: 'oklch(0.72 0.19 142)',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Traffic values */}
|
||||
<span className="text-xs font-mono w-20 text-right shrink-0 tabular-nums text-base-content/70">
|
||||
{formatBytes(total)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Legend */}
|
||||
<div className="flex items-center justify-end gap-4 mt-2 text-xs text-base-content/40">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: 'oklch(0.70 0.15 250)', opacity: 0.8 }}></span>
|
||||
上传
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: 'oklch(0.72 0.19 142)', opacity: 0.8 }}></span>
|
||||
下载
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui" {
|
||||
themes: all;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar Styles */
|
||||
@layer utilities {
|
||||
/* For Chrome, Edge, and Safari */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-base-content/10;
|
||||
border-radius: 10px;
|
||||
transition: background 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-base-content/30;
|
||||
}
|
||||
|
||||
/* For Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
/* First color is thumb, second is track. Cannot use Tailwind arbitrary opacity syntax in plain CSS easily here without var(), so we fallback to a neutral color, or rely on webkit-scrollbar for webkit browsers which covers most */
|
||||
scrollbar-color: var(--fallback-bc, rgba(128, 128, 128, 0.3)) transparent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,234 @@
|
||||
// ---- Node & Snapshot types (maps to monitor.Snapshot) ----
|
||||
|
||||
export interface NodeInfo {
|
||||
tag: string
|
||||
name: string
|
||||
uri: string
|
||||
mode: string
|
||||
listen_address?: string
|
||||
port?: number
|
||||
region?: string
|
||||
country?: string
|
||||
}
|
||||
|
||||
export interface TimelineEvent {
|
||||
time: string
|
||||
success: boolean
|
||||
latency_ms: number
|
||||
error?: string
|
||||
destination?: string
|
||||
}
|
||||
|
||||
export interface NodeSnapshot extends NodeInfo {
|
||||
failure_count: number
|
||||
success_count: number
|
||||
blacklisted: boolean
|
||||
blacklisted_until: string
|
||||
active_connections: number
|
||||
last_error?: string
|
||||
last_failure?: string
|
||||
last_success?: string
|
||||
last_probe_latency?: number
|
||||
last_latency_ms: number
|
||||
available: boolean
|
||||
initial_check_done: boolean
|
||||
total_upload: number
|
||||
total_download: number
|
||||
timeline?: TimelineEvent[]
|
||||
}
|
||||
|
||||
// ---- API Response types ----
|
||||
|
||||
export interface NodesResponse {
|
||||
nodes: NodeSnapshot[]
|
||||
total_nodes: number
|
||||
total_upload: number
|
||||
total_download: number
|
||||
upload_speed?: number
|
||||
download_speed?: number
|
||||
traffic_sampled?: string
|
||||
region_stats: Record<string, number>
|
||||
region_healthy: Record<string, number>
|
||||
}
|
||||
|
||||
export interface DebugNode {
|
||||
tag: string
|
||||
name: string
|
||||
mode: string
|
||||
port: number
|
||||
failure_count: number
|
||||
success_count: number
|
||||
active_connections: number
|
||||
last_latency_ms: number
|
||||
last_success: string
|
||||
last_failure: string
|
||||
last_error: string
|
||||
blacklisted: boolean
|
||||
total_upload: number
|
||||
total_download: number
|
||||
timeline: TimelineEvent[]
|
||||
}
|
||||
|
||||
export interface DebugResponse {
|
||||
nodes: DebugNode[]
|
||||
total_calls: number
|
||||
total_success: number
|
||||
success_rate: number
|
||||
}
|
||||
|
||||
export interface SettingsData {
|
||||
// Global
|
||||
mode: string
|
||||
log_level: string
|
||||
external_ip: string
|
||||
skip_cert_verify: boolean
|
||||
|
||||
// Listener
|
||||
listener_address: string
|
||||
listener_port: number
|
||||
listener_username: string
|
||||
listener_password: string
|
||||
|
||||
// Multi-port
|
||||
multi_port_address: string
|
||||
multi_port_base_port: number
|
||||
multi_port_username: string
|
||||
multi_port_password: string
|
||||
|
||||
// Pool
|
||||
pool_mode: string
|
||||
pool_failure_threshold: number
|
||||
pool_blacklist_duration: string
|
||||
|
||||
// Management
|
||||
management_enabled: boolean
|
||||
management_listen: string
|
||||
management_probe_target: string
|
||||
management_password: string
|
||||
|
||||
// Subscription refresh
|
||||
sub_refresh_enabled: boolean
|
||||
sub_refresh_interval: string
|
||||
sub_refresh_timeout: string
|
||||
sub_refresh_health_check_timeout: string
|
||||
sub_refresh_drain_timeout: string
|
||||
sub_refresh_min_available_nodes: number
|
||||
|
||||
// GeoIP
|
||||
geoip_enabled: boolean
|
||||
geoip_database_path: string
|
||||
geoip_auto_update_enabled: boolean
|
||||
geoip_auto_update_interval: string
|
||||
|
||||
// Subscriptions
|
||||
subscriptions: string[]
|
||||
}
|
||||
|
||||
export interface SettingsUpdateResponse {
|
||||
message: string
|
||||
need_reload: boolean
|
||||
}
|
||||
|
||||
// ---- Auth types ----
|
||||
|
||||
export interface AuthResponse {
|
||||
message: string
|
||||
token?: string
|
||||
no_password?: boolean
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string
|
||||
}
|
||||
|
||||
// ---- Config Node CRUD types ----
|
||||
|
||||
export interface ConfigNodePayload {
|
||||
name: string
|
||||
uri: string
|
||||
port: number
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface ConfigNodeConfig {
|
||||
name: string
|
||||
uri: string
|
||||
port: number
|
||||
username: string
|
||||
password: string
|
||||
source?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface ConfigNodesResponse {
|
||||
nodes: ConfigNodeConfig[]
|
||||
}
|
||||
|
||||
export interface ConfigNodeMutationResponse {
|
||||
node?: ConfigNodeConfig
|
||||
message: string
|
||||
}
|
||||
|
||||
// ---- Subscription types ----
|
||||
|
||||
export interface SubscriptionStatus {
|
||||
enabled: boolean
|
||||
has_subscriptions?: boolean
|
||||
last_refresh?: string
|
||||
next_refresh?: string
|
||||
node_count?: number
|
||||
last_error?: string
|
||||
refresh_count?: number
|
||||
is_refreshing?: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
// ---- SSE Probe types ----
|
||||
|
||||
export interface ProbeSSEStart {
|
||||
type: 'start'
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ProbeSSEProgress {
|
||||
type: 'progress'
|
||||
tag: string
|
||||
name: string
|
||||
latency: number
|
||||
status: 'success' | 'error'
|
||||
error: string
|
||||
current: number
|
||||
total: number
|
||||
progress: number
|
||||
}
|
||||
|
||||
export interface ProbeSSEComplete {
|
||||
type: 'complete'
|
||||
total: number
|
||||
success: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
export type ProbeSSEEvent = ProbeSSEStart | ProbeSSEProgress | ProbeSSEComplete
|
||||
|
||||
// ---- SSE Traffic stream types ----
|
||||
|
||||
export interface TrafficStreamNode {
|
||||
tag: string
|
||||
upload_speed: number
|
||||
download_speed: number
|
||||
total_upload: number
|
||||
total_download: number
|
||||
}
|
||||
|
||||
export interface TrafficStreamEvent {
|
||||
type: 'traffic'
|
||||
node_count: number
|
||||
total_upload: number
|
||||
total_download: number
|
||||
upload_speed: number
|
||||
download_speed: number
|
||||
sampled_at: string
|
||||
nodes: TrafficStreamNode[]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Format a byte count into a human-readable string (e.g., "1.5 GB").
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
if (bytes < 0) return '-'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
const idx = Math.min(i, sizes.length - 1)
|
||||
return `${(bytes / Math.pow(k, idx)).toFixed(idx === 0 ? 0 : 1)} ${sizes[idx]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a speed in bytes/sec into a human-readable string (e.g., "2.3 MB/s").
|
||||
*/
|
||||
export function formatSpeed(bytesPerSec: number): string {
|
||||
if (bytesPerSec <= 0) return '0 B/s'
|
||||
return `${formatBytes(bytesPerSec)}/s`
|
||||
}
|
||||
Reference in New Issue
Block a user