优化UI
This commit is contained in:
1 parent
7c76c8cee2
commit
eb49a45ae2
7 files changed
+101
-62
No files matched your search
+63
-21
@@ -63,6 +63,7 @@ const LIVE_REPORT_DIVISOR: u64 = 15;
|
||||
const BASIC_INFO_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
||||
const SLOW_METRICS_REFRESH_INTERVAL: Duration = Duration::from_secs(5);
|
||||
const PUBLIC_IP_REFRESH_INTERVAL: Duration = Duration::from_secs(10 * 60);
|
||||
const PUBLIC_IP_STALE_AFTER: Duration = Duration::from_secs(30 * 60);
|
||||
const PUBLIC_IP_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const PUBLIC_IP_WAIT_BUDGET: Duration = Duration::from_secs(8);
|
||||
const PUBLIC_IP_V4_URL: &str = "https://ipv4.icanhazip.com/";
|
||||
@@ -241,12 +242,31 @@ impl ClockCalibration {
|
||||
type SharedClock = Arc<Mutex<ClockCalibration>>;
|
||||
|
||||
/// 双栈公网地址探测:分别请求按协议族锁死的 icanhazip 端点(Cloudflare 运营,
|
||||
/// 纯文本返回调用方出口 IP),记录最近一次成功的结果。主连接只用一个协议族,
|
||||
/// 这里补齐另一个;探测失败保留旧值,从未成功则为空。
|
||||
/// 纯文本返回调用方出口 IP)。短暂失败保留最近结果,持续失败则清空过期地址。
|
||||
#[derive(Default)]
|
||||
struct PublicIpValue {
|
||||
address: Option<IpAddr>,
|
||||
last_success: Option<Instant>,
|
||||
}
|
||||
|
||||
impl PublicIpValue {
|
||||
fn observe(&mut self, address: Option<IpAddr>, now: Instant) {
|
||||
if let Some(address) = address {
|
||||
self.address = Some(address);
|
||||
self.last_success = Some(now);
|
||||
} else if self
|
||||
.last_success
|
||||
.is_none_or(|at| now.saturating_duration_since(at) >= PUBLIC_IP_STALE_AFTER)
|
||||
{
|
||||
self.address = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PublicIps {
|
||||
v4: Option<IpAddr>,
|
||||
v6: Option<IpAddr>,
|
||||
v4: PublicIpValue,
|
||||
v6: PublicIpValue,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
@@ -273,23 +293,30 @@ impl PublicIpProbe {
|
||||
thread::spawn(move || {
|
||||
let v4 = probe_public_ip(PUBLIC_IP_V4_URL, false);
|
||||
let v6 = probe_public_ip(PUBLIC_IP_V6_URL, true);
|
||||
let observed_at = Instant::now();
|
||||
let mut state = state.lock().unwrap();
|
||||
if let Some(ip) = v4 {
|
||||
state.v4 = Some(ip);
|
||||
}
|
||||
if let Some(ip) = v6 {
|
||||
state.v6 = Some(ip);
|
||||
}
|
||||
state.v4.observe(v4, observed_at);
|
||||
state.v6.observe(v6, observed_at);
|
||||
probing.store(false, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
|
||||
fn v4(&self) -> Option<String> {
|
||||
self.state.lock().unwrap().v4.map(|ip| ip.to_string())
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.v4
|
||||
.address
|
||||
.map(|ip| ip.to_string())
|
||||
}
|
||||
|
||||
fn v6(&self) -> Option<String> {
|
||||
self.state.lock().unwrap().v6.map(|ip| ip.to_string())
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.v6
|
||||
.address
|
||||
.map(|ip| ip.to_string())
|
||||
}
|
||||
|
||||
/// 一次性模式没有后续采样,等首轮探测落地(或预算耗尽)再出报告。
|
||||
@@ -306,7 +333,7 @@ fn probe_public_ip(url: &str, ipv6: bool) -> Option<IpAddr> {
|
||||
.timeout_global(Some(PUBLIC_IP_PROBE_TIMEOUT))
|
||||
.build()
|
||||
.into();
|
||||
let mut response = probe_agent
|
||||
let response = probe_agent
|
||||
.get(url)
|
||||
.header("User-Agent", format!("nodeflare-agent/{VERSION}"))
|
||||
.call()
|
||||
@@ -484,9 +511,7 @@ struct Report {
|
||||
gpu_usage: f64,
|
||||
gpu_model: String,
|
||||
agent_version: String,
|
||||
#[serde(default)]
|
||||
ip_v4: String,
|
||||
#[serde(default)]
|
||||
ip_v6: String,
|
||||
disk_read_bps: f64,
|
||||
disk_write_bps: f64,
|
||||
@@ -2735,12 +2760,12 @@ mod tests {
|
||||
ack_d1_interval, ack_wss_interval, advance_deadline, clock_offset_from_http_date,
|
||||
corrected_timestamp, gpu_name_from_uevent, is_public_probe_ip, live_endpoint,
|
||||
live_update_payload, monotonic_report_timestamp, normalized_version, parse_lspci_gpu_names,
|
||||
parse_pciconf_gpu_names, parse_probe_target, parse_public_ip, parse_system_profiler_gpu_names,
|
||||
ping_latencies, ping_latency, prune_report_samples, release_asset_sha256,
|
||||
sanitize_latency_tasks, update_check_jitter, valid_endpoint, version_triplet, CliOptions,
|
||||
ClockCalibration, GithubReleaseAsset, LatencyResult, LatencyTask, LiveAck, Report,
|
||||
CLOCK_CALIBRATION_MAX_AGE, MAX_LATENCY_TASKS, MAX_PENDING_LATENCY_RESULTS,
|
||||
UPDATE_CHECK_JITTER_MAX_SECONDS,
|
||||
parse_pciconf_gpu_names, parse_probe_target, parse_public_ip,
|
||||
parse_system_profiler_gpu_names, ping_latencies, ping_latency, prune_report_samples,
|
||||
release_asset_sha256, sanitize_latency_tasks, update_check_jitter, valid_endpoint,
|
||||
version_triplet, CliOptions, ClockCalibration, GithubReleaseAsset, LatencyResult,
|
||||
LatencyTask, LiveAck, PublicIpValue, Report, CLOCK_CALIBRATION_MAX_AGE, MAX_LATENCY_TASKS,
|
||||
MAX_PENDING_LATENCY_RESULTS, PUBLIC_IP_STALE_AFTER, UPDATE_CHECK_JITTER_MAX_SECONDS,
|
||||
};
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "freebsd"))]
|
||||
@@ -2761,6 +2786,23 @@ mod tests {
|
||||
assert_eq!(parse_public_ip("", false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expires_public_ip_after_sustained_probe_failures() {
|
||||
let started_at = Instant::now();
|
||||
let address = "203.0.113.7".parse().unwrap();
|
||||
let mut value = PublicIpValue::default();
|
||||
|
||||
value.observe(Some(address), started_at);
|
||||
value.observe(
|
||||
None,
|
||||
started_at + PUBLIC_IP_STALE_AFTER - Duration::from_secs(1),
|
||||
);
|
||||
assert_eq!(value.address, Some(address));
|
||||
|
||||
value.observe(None, started_at + PUBLIC_IP_STALE_AFTER);
|
||||
assert_eq!(value.address, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_platform_gpu_names() {
|
||||
let lspci = "00:02.0 VGA compatible controller: Intel Corporation CometLake-S GT2 [UHD Graphics 630] (rev 05)\n\
|
||||
|
||||
@@ -174,7 +174,7 @@ export default function App() {
|
||||
<div className="brand"><SiteLogo src={config.logo_url} alt="" width="36" height="36" /><strong>{config.site_name}</strong></div>
|
||||
<div className="header-actions">
|
||||
<button className="icon-btn" onClick={toggleTheme} title={dark ? ui(locale, "浅色主题", "Light theme") : ui(locale, "深色主题", "Dark theme")}>{dark ? <Sun size={18} /> : <Moon size={18} />}</button>
|
||||
<button className="icon-btn" onClick={() => window.location.assign("/admin")} title={ui(locale, "进入后台", "Administration")}><UserCircle size={18} /></button>
|
||||
<a className="icon-btn" href="/admin" target="_blank" rel="noopener noreferrer" title={ui(locale, "进入后台", "Administration")} aria-label={ui(locale, "进入后台", "Administration")}><UserCircle size={18} /></a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -60,7 +60,7 @@ function AdminApp() {
|
||||
const next = dark ? "light" : "dark";
|
||||
localStorage.setItem(THEME_KEY, next);
|
||||
setAppearance(next);
|
||||
}} onClose={() => window.location.assign("/")} onChanged={() => void loadConfig()} />;
|
||||
}} onChanged={() => void loadConfig()} />;
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<StrictMode><AdminApp /></StrictMode>);
|
||||
@@ -128,22 +128,10 @@ const BILLING_CYCLES: Array<{ days: number; label: string }> = [
|
||||
{ days: 0, label: "一次性" },
|
||||
];
|
||||
|
||||
// last_ip 来自 CF-Connecting-IP,是裸 IP;v6 带冒号,v4 是四段数字。
|
||||
function ipFamily(ip: string): "v4" | "v6" | null {
|
||||
const value = ip.trim();
|
||||
if (!value) return null;
|
||||
if (value.includes(":")) return "v6";
|
||||
return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value) ? "v4" : null;
|
||||
}
|
||||
|
||||
function ServerIpMeta({ server, agentVersion, onCopy }: { server: AdminServer; agentVersion: string | null; onCopy: (ip: string) => void }) {
|
||||
// 双栈探测上报的地址优先;旧版 agent 还没上报时回落到连接出口地址(last_ip)。
|
||||
const family = ipFamily(server.last_ip);
|
||||
const v4 = server.ip_v4 || (family === "v4" ? server.last_ip : "");
|
||||
const v6 = server.ip_v6 || (family === "v6" ? server.last_ip : "");
|
||||
const entries = [
|
||||
{ family: "v4" as const, ip: v4 },
|
||||
{ family: "v6" as const, ip: v6 },
|
||||
{ family: "v4" as const, ip: server.ip_v4 || "" },
|
||||
{ family: "v6" as const, ip: server.ip_v6 || "" },
|
||||
].filter((entry) => entry.ip);
|
||||
return (
|
||||
<div className="server-name-meta">
|
||||
@@ -153,7 +141,7 @@ function ServerIpMeta({ server, agentVersion, onCopy }: { server: AdminServer; a
|
||||
<button type="button" className="ip-value" title={`点击复制:${entry.ip}`} onClick={() => onCopy(entry.ip)}>{entry.ip}</button>
|
||||
</span>
|
||||
))}
|
||||
{!entries.length ? <span className="meta-item">{server.last_ip || "尚未上报 IP"}</span> : null}
|
||||
{!entries.length ? <span className="meta-item">尚未探测到公网 IP</span> : null}
|
||||
<span className="meta-dot">·</span>
|
||||
<span className="meta-item">{agentVersion ? `Agent v${agentVersion}` : "Agent 未上报版本"}</span>
|
||||
</div>
|
||||
@@ -180,13 +168,11 @@ export function AdminPanel({
|
||||
config,
|
||||
dark,
|
||||
onToggleTheme,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
config: Config;
|
||||
dark: boolean;
|
||||
onToggleTheme: () => void;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [authenticated, setAuthenticated] = useState(!!getToken());
|
||||
@@ -603,7 +589,6 @@ export function AdminPanel({
|
||||
{authenticated ? (error ? <div className="admin-toast error" role="alert" aria-live="assertive"><CircleAlert aria-hidden="true" /><span>{error}</span></div>
|
||||
: notice ? <div className="admin-toast" role="status" aria-live="polite"><CircleCheck aria-hidden="true" /><span>{notice}</span></div> : null) : null}
|
||||
{!authenticated ? <div className="admin-login-stage">
|
||||
<button className="admin-back" type="button" onClick={onClose}><ArrowLeft size={15} />返回</button>
|
||||
<button className="admin-login-theme" type="button" onClick={onToggleTheme} title={dark ? "切换浅色主题" : "切换深色主题"}>{dark ? <Sun size={15} /> : <Moon size={15} />}</button>
|
||||
<form className="login-form glass-panel" onSubmit={login}>
|
||||
<SiteLogo src={siteLogoUrl} alt="" width="48" height="48" />
|
||||
@@ -622,8 +607,8 @@ export function AdminPanel({
|
||||
</div>
|
||||
<div className="admin-topbar-actions">
|
||||
<button type="button" onClick={onToggleTheme} title={dark ? "切换浅色主题" : "切换深色主题"} aria-label={dark ? "切换浅色主题" : "切换深色主题"}>{dark ? <Sun size={15} /> : <Moon size={15} />}</button>
|
||||
<button type="button" onClick={onClose} title="主页" aria-label="主页"><ArrowLeft size={15} />主页</button>
|
||||
<button type="button" onClick={() => void logout()} title="退出" aria-label="退出"><LogOut size={15} />退出</button>
|
||||
<a className="admin-home-link" href="/" target="_blank" rel="noopener noreferrer" title="主页" aria-label="主页"><ArrowLeft size={15} /></a>
|
||||
<button type="button" onClick={() => void logout()} title="退出" aria-label="退出"><LogOut size={15} /></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -56,8 +56,6 @@
|
||||
|
||||
.admin-loading { display: grid; min-height: 100dvh; place-items: center; color: var(--admin-text); background: var(--admin-bg); font-size: 14px; }
|
||||
.admin-login-stage { position: relative; display: grid; min-height: 100dvh; place-items: center; background: var(--admin-bg); }
|
||||
.admin-back { position: absolute; top: 22px; left: 26px; display: inline-flex; min-height: 38px; align-items: center; gap: 6px; border: 1px solid var(--admin-border); border-radius: 7px; padding: 7px 12px; color: var(--admin-text); background: var(--admin-surface); font-size: 13px; }
|
||||
.admin-back:hover { background: var(--admin-hover); }
|
||||
.admin-login-theme { position: absolute; top: 22px; right: 26px; display: inline-flex; width: 38px; height: 38px; align-items: center; justify-content: center; border: 1px solid var(--admin-border); border-radius: 6px; color: var(--admin-text); background: var(--admin-surface); }
|
||||
.admin-login-theme:hover { background: var(--admin-hover); }
|
||||
|
||||
@@ -81,8 +79,8 @@
|
||||
.admin-brand img { width: 32px; height: 32px; flex: 0 0 auto; object-fit: contain; }
|
||||
.admin-brand strong { overflow: hidden; max-width: 320px; font-size: 18px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.admin-topbar-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.admin-topbar-actions button { display: inline-flex; min-height: 36px; align-items: center; gap: 6px; border: 1px solid var(--admin-border); border-radius: 6px; padding: 0 11px; color: var(--admin-text); background: var(--admin-surface); font-size: 13px; font-weight: 600; }
|
||||
.admin-topbar-actions button:hover { border-color: var(--admin-border-hover); background: var(--admin-bg); }
|
||||
.admin-topbar-actions button, .admin-topbar-actions .admin-home-link { display: inline-flex; min-height: 36px; align-items: center; gap: 6px; border: 1px solid var(--admin-border); border-radius: 6px; padding: 0 11px; color: var(--admin-text); background: var(--admin-surface); font-size: 13px; font-weight: 600; text-decoration: none; }
|
||||
.admin-topbar-actions button:hover, .admin-topbar-actions .admin-home-link:hover { border-color: var(--admin-border-hover); background: var(--admin-bg); }
|
||||
.admin-body { display: grid; width: 100%; max-width: 100vw; height: auto; min-width: 0; min-height: 0; overflow: hidden; grid-template-columns: 220px minmax(0, 1fr); }
|
||||
.admin-sidebar { min-height: 0; overflow-y: auto; border-right: 1px solid var(--admin-border); padding: 20px 14px; background: var(--admin-surface); }
|
||||
.admin-nav-label { display: block; margin: 0 11px 8px; color: var(--admin-muted); font-size: 12px; font-weight: 700; }
|
||||
@@ -262,7 +260,7 @@
|
||||
.admin-brand img { width: 29px; height: 29px; }
|
||||
.admin-brand strong { max-width: 125px; font-size: 15px; }
|
||||
.admin-topbar-actions { gap: 5px; }
|
||||
.admin-topbar-actions button { width: 36px; min-height: 36px; justify-content: center; padding: 0; font-size: 0; }
|
||||
.admin-topbar-actions button, .admin-topbar-actions .admin-home-link { width: 36px; min-height: 36px; justify-content: center; padding: 0; font-size: 0; }
|
||||
.admin-body { display: flex; height: auto; flex-direction: column; }
|
||||
.admin-sidebar { width: 100%; max-width: 100vw; min-width: 0; overflow-x: auto; overflow-y: hidden; flex: 0 0 auto; border-right: 0; border-bottom: 1px solid var(--admin-border); padding: 7px 10px; }
|
||||
.admin-nav-label { display: none; }
|
||||
@@ -280,7 +278,6 @@
|
||||
.editor-modal, .install-modal, .latency-editor { padding: 20px 15px; }
|
||||
.alert-rule-editor { padding: 20px 15px; }
|
||||
.alert-server-picker { grid-template-columns: 1fr; }
|
||||
.admin-back { top: 12px; left: 12px; }
|
||||
.admin-login-theme { top: 12px; right: 12px; }
|
||||
}
|
||||
|
||||
|
||||
+27
-10
@@ -12,7 +12,7 @@ mod theme;
|
||||
mod turnstile;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::TryStreamExt;
|
||||
@@ -812,10 +812,12 @@ pub(crate) fn validate_report(report: &AgentReport) -> Option<&'static str> {
|
||||
if report.agent_version.chars().count() > 80 {
|
||||
return Some("Agent 版本字段过长");
|
||||
}
|
||||
for value in [report.ip_v4.as_str(), report.ip_v6.as_str()] {
|
||||
if value.chars().count() > 45 || (!value.is_empty() && value.parse::<IpAddr>().is_err()) {
|
||||
return Some("公网地址字段无效");
|
||||
}
|
||||
if report.ip_v4.chars().count() > 15
|
||||
|| (!report.ip_v4.is_empty() && report.ip_v4.parse::<Ipv4Addr>().is_err())
|
||||
|| report.ip_v6.chars().count() > 45
|
||||
|| (!report.ip_v6.is_empty() && report.ip_v6.parse::<Ipv6Addr>().is_err())
|
||||
{
|
||||
return Some("公网地址字段无效");
|
||||
}
|
||||
if report.disks.len() > 64
|
||||
|| report.gpus.len() > 32
|
||||
@@ -1317,7 +1319,9 @@ mod tests {
|
||||
valid_password_derived, valid_ping_target, valid_public_asset_url, validate_latency_task,
|
||||
validate_report, validate_server,
|
||||
};
|
||||
use crate::{db::SECRET_MASK, models::AgentReport, models::LatencyTaskInput, models::ServerInput};
|
||||
use crate::{
|
||||
db::SECRET_MASK, models::AgentReport, models::LatencyTaskInput, models::ServerInput,
|
||||
};
|
||||
use worker::{Method, Url};
|
||||
|
||||
#[test]
|
||||
@@ -1327,12 +1331,25 @@ mod tests {
|
||||
report.ip_v4 = "203.0.113.7".into();
|
||||
report.ip_v6 = "2001:db8::1".into();
|
||||
assert_eq!(validate_report(&report), None);
|
||||
// 非空字段必须是合法 IP,空串表示该协议族不可用、放行。
|
||||
report.ip_v4 = "2001:db8::1".into();
|
||||
assert_eq!(validate_report(&report), Some("公网地址字段无效"));
|
||||
report.ip_v4 = "203.0.113.7".into();
|
||||
report.ip_v6 = "203.0.113.7".into();
|
||||
assert_eq!(validate_report(&report), Some("公网地址字段无效"));
|
||||
report.ip_v4 = "not-an-ip".into();
|
||||
report.ip_v6.clear();
|
||||
assert_eq!(validate_report(&report), Some("公网地址字段无效"));
|
||||
report.ip_v4 = String::new();
|
||||
report.ip_v6 = "2001:db8::1 extra".into();
|
||||
assert_eq!(validate_report(&report), Some("公网地址字段无效"));
|
||||
report.ip_v4.clear();
|
||||
assert_eq!(validate_report(&report), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_public_ip_fields_in_agent_reports() {
|
||||
for field in ["ip_v4", "ip_v6"] {
|
||||
let mut report = serde_json::to_value(AgentReport::default()).unwrap();
|
||||
report.as_object_mut().unwrap().remove(field);
|
||||
assert!(serde_json::from_value::<AgentReport>(report).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+1
-3
@@ -215,10 +215,8 @@ pub struct AgentReport {
|
||||
pub gpu_usage: f64,
|
||||
pub gpu_model: String,
|
||||
pub agent_version: String,
|
||||
/// 双栈公网地址,agent 双栈探测的结果;空串表示该协议族不可用或未上报。
|
||||
#[serde(default)]
|
||||
/// 双栈公网地址,agent 双栈探测的结果;空串表示当前不可用或探测尚未完成。
|
||||
pub ip_v4: String,
|
||||
#[serde(default)]
|
||||
pub ip_v6: String,
|
||||
pub disk_read_bps: f64,
|
||||
pub disk_write_bps: f64,
|
||||
|
||||
Reference in new issue
Block a user