diff --git a/README.md b/README.md index a59c3fb..5947348 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ ## 主题 -GitHub `tree` 地址对应的目录需提供 `index.html` 和 `assets/`,并使用 NodeFlare 公开 API 读取数据。启用第三方主题前请确认来源可信。 +GitHub `tree` 地址对应的目录需提供 `index.html` 和 `assets/`,并使用 NodeFlare 公开 API 读取数据。可选的 `theme.json` 可声明后台显示的主题设置项。启用第三方主题前请确认来源可信。 ## 鸣谢 diff --git a/agent/src/main.rs b/agent/src/main.rs index dadf040..5f88bde 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -11,6 +11,7 @@ use std::path::Path; use std::process::Command; #[cfg(target_os = "windows")] use std::process::Stdio; +use std::sync::{mpsc, Arc, Mutex}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -32,6 +33,8 @@ const PROBE_ATTEMPTS: usize = 4; const PROBE_TIMEOUT: Duration = Duration::from_secs(1); const MAX_AGENT_BINARY_BYTES: u64 = 64 * 1024 * 1024; const MAX_LATENCY_TASKS: usize = 128; +const LATENCY_WORKERS: usize = 4; +const MAX_PENDING_LATENCY_RESULTS: usize = 4096; const MAX_REPORT_AGE_SECONDS: i64 = 7_000; const REPORT_RETRY_MIN: Duration = Duration::from_secs(5); const REPORT_RETRY_MAX: Duration = Duration::from_secs(300); @@ -94,13 +97,80 @@ struct LatencyResult { packet_loss: f64, } +struct LatencyExecutor { + task_tx: mpsc::SyncSender, + result_rx: mpsc::Receiver, + in_flight: HashSet, +} + +impl LatencyExecutor { + fn new() -> Result { + let (task_tx, task_rx) = mpsc::sync_channel::(MAX_LATENCY_TASKS); + let (result_tx, result_rx) = mpsc::channel(); + let task_rx = Arc::new(Mutex::new(task_rx)); + + for index in 0..LATENCY_WORKERS { + let task_rx = Arc::clone(&task_rx); + let result_tx = result_tx.clone(); + thread::Builder::new() + .name(format!("nodeflare-latency-{index}")) + .spawn(move || loop { + let task = match task_rx.lock() { + Ok(receiver) => receiver.try_recv(), + Err(_) => return, + }; + match task { + Ok(task) => { + if result_tx.send(execute_latency_task(task)).is_err() { + return; + } + } + Err(mpsc::TryRecvError::Empty) => { + thread::sleep(Duration::from_millis(25)); + } + Err(mpsc::TryRecvError::Disconnected) => return, + }; + })?; + } + + Ok(Self { + task_tx, + result_rx, + in_flight: HashSet::new(), + }) + } + + fn enqueue(&mut self, task: LatencyTask) -> bool { + if self.in_flight.contains(&task.id) { + return false; + } + let task_id = task.id.clone(); + match self.task_tx.try_send(task) { + Ok(()) => { + self.in_flight.insert(task_id); + true + } + Err(mpsc::TrySendError::Full(_)) => false, + Err(mpsc::TrySendError::Disconnected(_)) => false, + } + } + + fn drain(&mut self) -> Vec { + let results = self.result_rx.try_iter().collect::>(); + for result in &results { + self.in_flight.remove(&result.task_id); + } + results + } +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct RemoteConfig { report_interval: u64, collect_interval: u64, network_interface: String, - auto_update: i64, + auto_update: bool, latency_tasks: Vec, } @@ -117,13 +187,6 @@ struct GithubReleaseAsset { digest: Option, } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct ReportResponse { - success: bool, - config: RemoteConfig, -} - struct SubmitResult { config: Option, config_hash: String, @@ -152,7 +215,7 @@ struct DiskMetric { #[derive(Debug, Clone, Serialize)] struct GpuMetric { model: String, - usage: f64, + usage: Option, memory_used: i64, memory_total: i64, } @@ -195,7 +258,6 @@ struct Report { disk_utilization: f64, disks: Vec, gpus: Vec, - message: String, latency_results: Vec, } @@ -619,38 +681,21 @@ fn unix_timestamp() -> i64 { .as_secs() as i64 } -fn execute_latency_tasks(tasks: &[LatencyTask]) -> Vec { - let mut results = Vec::with_capacity(tasks.len()); - thread::scope(|scope| { - let handles = tasks - .iter() - .cloned() - .map(|task| { - scope.spawn(move || { - let (latency_ms, packet_loss) = match task.task_type.as_str() { - "tcp" => tcp_latency_probe(&task.target), - "icmp" => icmp_latency_probe(&task.target), - _ => (-1.0, 100.0), - }; - LatencyResult { - task_id: task.id, - timestamp: unix_timestamp(), - latency_ms, - packet_loss, - } - }) - }) - .collect::>(); - for handle in handles { - if let Ok(result) = handle.join() { - results.push(result); - } - } - }); - results +fn execute_latency_task(task: LatencyTask) -> LatencyResult { + let (latency_ms, packet_loss) = match task.task_type.as_str() { + "tcp" => tcp_latency_probe(&task.target), + "icmp" => icmp_latency_probe(&task.target), + _ => (-1.0, 100.0), + }; + LatencyResult { + task_id: task.id, + timestamp: unix_timestamp(), + latency_ms, + packet_loss, + } } -fn gpu_info() -> Vec { +fn nvidia_gpu_info() -> Vec { let gpu = command( "nvidia-smi", &[ @@ -664,8 +709,11 @@ fn gpu_info() -> Vec { if fields.len() < 4 { return None; } + if fields[1].is_empty() { + return None; + } Some(GpuMetric { - usage: fields[0].parse().unwrap_or(0.0), + usage: fields[0].parse().ok(), model: fields[1].to_string(), memory_used: fields[2] .parse::() @@ -680,6 +728,172 @@ fn gpu_info() -> Vec { .collect() } +fn basic_gpu_metrics(names: impl IntoIterator) -> Vec { + let mut seen = HashSet::new(); + names + .into_iter() + .filter_map(|name| { + let name = name.split(" (rev ").next().unwrap_or(&name).trim(); + let lower = name.to_ascii_lowercase(); + if name.is_empty() + || [ + "sensor hub", + "management engine", + "ethernet", + "wireless", + "audio controller", + "usb controller", + "sata controller", + "virtio", + "vmware", + "qxl", + "hyper-v", + "cirrus", + "microsoft basic display", + ] + .iter() + .any(|pattern| lower.contains(pattern)) + || !seen.insert(name.to_string()) + { + return None; + } + Some(GpuMetric { + model: name.to_string(), + usage: None, + memory_used: 0, + memory_total: 0, + }) + }) + .collect() +} + +#[cfg(any(target_os = "linux", test))] +fn parse_lspci_gpu_names(output: &str) -> Vec { + output + .lines() + .filter(|line| { + let lower = line.to_ascii_lowercase(); + lower.contains("vga compatible controller") + || lower.contains("3d controller") + || lower.contains("display controller") + }) + .filter_map(|line| line.split_once(": ").map(|(_, name)| name.to_string())) + .collect() +} + +#[cfg(any(target_os = "linux", test))] +fn gpu_name_from_uevent(output: &str) -> Option { + let driver = output + .lines() + .find_map(|line| line.strip_prefix("DRIVER="))?; + let pci_class = output + .lines() + .find_map(|line| line.strip_prefix("PCI_CLASS=")) + .unwrap_or_default(); + if u32::from_str_radix(pci_class, 16) + .ok() + .map(|class| class >> 16) + != Some(3) + { + return None; + } + match driver { + "i915" | "xe" => Some("Intel Integrated Graphics".to_string()), + "amdgpu" | "radeon" => Some("AMD Radeon Graphics".to_string()), + "nvidia" | "nouveau" => Some("NVIDIA GPU".to_string()), + "msm" | "msm_drm" => Some("Qualcomm Adreno GPU".to_string()), + "panfrost" | "lima" => Some("ARM Mali GPU".to_string()), + "vc4" | "v3d" => Some("Broadcom VideoCore Graphics".to_string()), + "virtio-pci" | "virtio_gpu" | "bochs-drm" | "qxl" | "vmwgfx" | "cirrus" | "vboxvideo" + | "hyperv_fb" | "simpledrm" | "simplefb" => None, + other if !other.is_empty() => Some(format!("GPU ({other})")), + _ => None, + } +} + +#[cfg(target_os = "linux")] +fn sysfs_gpu_names() -> Vec { + let Ok(entries) = fs::read_dir("/sys/class/drm") else { + return Vec::new(); + }; + entries + .filter_map(|entry| entry.ok()) + .filter(|entry| { + let name = entry.file_name(); + let name = name.to_string_lossy(); + name.strip_prefix("card").is_some_and(|suffix| { + !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) + }) + }) + .filter_map(|entry| gpu_name_from_uevent(&text(entry.path().join("device/uevent")))) + .collect() +} + +#[cfg(any(target_os = "macos", test))] +fn parse_system_profiler_gpu_names(output: &str) -> Vec { + output + .lines() + .filter_map(|line| { + line.trim() + .strip_prefix("Chipset Model:") + .map(|name| name.trim().to_string()) + }) + .collect() +} + +#[cfg(target_os = "linux")] +fn basic_gpu_info() -> Vec { + let names = parse_lspci_gpu_names(&command("lspci", &[])); + basic_gpu_metrics(if names.is_empty() { + sysfs_gpu_names() + } else { + names + }) +} + +#[cfg(target_os = "windows")] +fn basic_gpu_info() -> Vec { + basic_gpu_metrics( + command( + "powershell.exe", + &[ + "-NoProfile", + "-NonInteractive", + "-Command", + "Get-CimInstance Win32_VideoController | ForEach-Object { $_.Name }", + ], + ) + .lines() + .map(str::to_string), + ) +} + +#[cfg(target_os = "macos")] +fn basic_gpu_info() -> Vec { + basic_gpu_metrics(parse_system_profiler_gpu_names(&command( + "system_profiler", + &["SPDisplaysDataType"], + ))) +} + +fn gpu_info() -> Vec { + let detailed = nvidia_gpu_info(); + if detailed.is_empty() { + basic_gpu_info() + } else { + detailed + } +} + +fn average_gpu_usage(gpus: &[GpuMetric]) -> f64 { + let usages = gpus.iter().filter_map(|gpu| gpu.usage).collect::>(); + if usages.is_empty() { + 0.0 + } else { + usages.iter().sum::() / usages.len() as f64 + } +} + #[cfg(any(target_os = "windows", target_os = "macos"))] fn u64_to_i64(value: u64) -> i64 { value.min(i64::MAX as u64) as i64 @@ -732,11 +946,7 @@ fn collect(config: &RuntimeConfig, latency_results: Vec) -> Repor let disk_used = disks.iter().map(|disk| disk.used).sum(); let disk_total = disks.iter().map(|disk| disk.total).sum(); let gpus = gpu_info(); - let gpu_usage = if gpus.is_empty() { - 0.0 - } else { - gpus.iter().map(|gpu| gpu.usage).sum::() / gpus.len() as f64 - }; + let gpu_usage = average_gpu_usage(&gpus); let gpu_model = gpus .iter() .map(|gpu| gpu.model.as_str()) @@ -781,7 +991,6 @@ fn collect(config: &RuntimeConfig, latency_results: Vec) -> Repor gpu_usage, gpu_model, agent_version: VERSION.to_string(), - message: String::new(), disk_read_bps: io_after .read_sectors .saturating_sub(io_before.read_sectors) @@ -854,11 +1063,7 @@ fn collect(config: &RuntimeConfig, latency_results: Vec) -> Repor let disk_total = disk_metrics.iter().map(|disk| disk.total).sum(); let load = System::load_average(); let gpus = gpu_info(); - let gpu_usage = if gpus.is_empty() { - 0.0 - } else { - gpus.iter().map(|gpu| gpu.usage).sum::() / gpus.len() as f64 - }; + let gpu_usage = average_gpu_usage(&gpus); let gpu_model = gpus .iter() .map(|gpu| gpu.model.as_str()) @@ -907,7 +1112,6 @@ fn collect(config: &RuntimeConfig, latency_results: Vec) -> Repor disk_utilization: 0.0, disks: disk_metrics, gpus, - message: String::new(), latency_results, } } @@ -971,11 +1175,7 @@ fn submit( let remote = if response.status().as_u16() == 204 { None } else { - let payload = response.body_mut().read_json::()?; - if !payload.success { - return Err("Worker rejected the report".into()); - } - Some(payload.config) + Some(response.body_mut().read_json::()?) }; Ok(SubmitResult { config: remote, @@ -993,7 +1193,7 @@ fn apply_remote(config: &mut RuntimeConfig, remote: &RemoteConfig) -> bool { config .network_interface .clone_from(&remote.network_interface); - config.auto_update = remote.auto_update == 1; + config.auto_update = remote.auto_update; let tasks = sanitize_latency_tasks(&remote.latency_tasks); let changed = config.latency_tasks != tasks; config.latency_tasks = tasks; @@ -1063,6 +1263,16 @@ fn report_retry_delay(failures: u32, report_interval: u64) -> Duration { fn prune_report_samples(samples: &mut Vec, now: i64) { samples.retain(|report| (report.timestamp - now).abs() <= MAX_REPORT_AGE_SECONDS); + let mut remaining = MAX_PENDING_LATENCY_RESULTS; + for report in samples.iter_mut().rev() { + if report.latency_results.len() > remaining { + let discard = report.latency_results.len() - remaining; + report.latency_results.drain(..discard); + remaining = 0; + } else { + remaining -= report.latency_results.len(); + } + } } fn normalized_version(value: &str) -> &str { @@ -1280,12 +1490,14 @@ fn run(options: &CliOptions, once: bool, print_only: bool) -> Result<()> { let mut next_report = Instant::now(); let mut next_collect = Instant::now(); let mut next_latency: HashMap = HashMap::new(); + let mut latency_executor = LatencyExecutor::new()?; let mut pending_results = Vec::new(); let mut pending_samples = Vec::new(); let mut report_failures = 0_u32; let mut next_update_check = Instant::now(); let mut config_hash = String::new(); loop { + pending_results.extend(latency_executor.drain()); let current = Instant::now(); let due = config .latency_tasks @@ -1298,13 +1510,13 @@ fn run(options: &CliOptions, once: bool, print_only: bool) -> Result<()> { .cloned() .collect::>(); if !due.is_empty() { - pending_results.extend(execute_latency_tasks(&due)); let scheduled_at = Instant::now(); for task in due { - next_latency.insert( - task.id, - scheduled_at + Duration::from_secs(task.interval_seconds.clamp(30, 3600)), - ); + let task_id = task.id.clone(); + let interval = task.interval_seconds.clamp(30, 3600); + if latency_executor.enqueue(task) { + next_latency.insert(task_id, scheduled_at + Duration::from_secs(interval)); + } } } @@ -1399,11 +1611,12 @@ mod tests { use std::time::Duration; use super::{ - agent_artifact_name, executable_format_valid, is_public_probe_ip, median, - normalized_version, parse_probe_target, ping_latency, prune_report_samples, - release_asset_sha256, report_retry_delay, sanitize_latency_tasks, selected_interface, - tcp_latency_probe_address, valid_endpoint, version_triplet, CliOptions, GithubReleaseAsset, - LatencyTask, Report, MAX_LATENCY_TASKS, PROBE_ATTEMPTS, + agent_artifact_name, executable_format_valid, gpu_name_from_uevent, is_public_probe_ip, + median, normalized_version, parse_lspci_gpu_names, parse_probe_target, + parse_system_profiler_gpu_names, ping_latency, prune_report_samples, release_asset_sha256, + report_retry_delay, sanitize_latency_tasks, selected_interface, tcp_latency_probe_address, + valid_endpoint, version_triplet, CliOptions, GithubReleaseAsset, LatencyResult, + LatencyTask, Report, MAX_LATENCY_TASKS, MAX_PENDING_LATENCY_RESULTS, PROBE_ATTEMPTS, }; #[cfg(any(target_os = "windows", target_os = "macos"))] @@ -1418,6 +1631,32 @@ mod tests { assert!(selected_interface("ens3", "eth0, ens3")); } + #[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\ + 01:00.0 Audio device: NVIDIA Corporation HDMI Audio\n\ + 02:00.0 3D controller: NVIDIA Corporation GA102 [GeForce RTX 3090] (rev a1)"; + assert_eq!( + parse_lspci_gpu_names(lspci), + [ + "Intel Corporation CometLake-S GT2 [UHD Graphics 630] (rev 05)", + "NVIDIA Corporation GA102 [GeForce RTX 3090] (rev a1)", + ] + ); + + let profiler = "Graphics/Displays:\n\n Apple M3 Max:\n\n Chipset Model: Apple M3 Max\n Metal Support: Metal 3"; + assert_eq!(parse_system_profiler_gpu_names(profiler), ["Apple M3 Max"]); + + assert_eq!( + gpu_name_from_uevent("DRIVER=i915\nPCI_CLASS=30000\nPCI_ID=8086:591B"), + Some("Intel Integrated Graphics".to_string()) + ); + assert_eq!( + gpu_name_from_uevent("DRIVER=virtio_gpu\nPCI_CLASS=30000"), + None + ); + } + #[cfg(target_os = "linux")] #[test] fn selects_whole_disk_devices() { @@ -1552,6 +1791,32 @@ mod tests { assert_eq!(samples[0].timestamp, 8_500); } + #[test] + fn bounds_pending_latency_results() { + let mut samples = (0..MAX_PENDING_LATENCY_RESULTS + 10) + .map(|index| Report { + timestamp: index as i64 + 1, + latency_results: vec![LatencyResult { + task_id: format!("task-{index}"), + timestamp: index as i64 + 1, + latency_ms: 10.0, + packet_loss: 0.0, + }], + ..Report::default() + }) + .collect::>(); + prune_report_samples(&mut samples, MAX_PENDING_LATENCY_RESULTS as i64 + 10); + assert_eq!( + samples + .iter() + .map(|sample| sample.latency_results.len()) + .sum::(), + MAX_PENDING_LATENCY_RESULTS + ); + assert!(samples.first().unwrap().latency_results.is_empty()); + assert_eq!(samples.last().unwrap().latency_results.len(), 1); + } + #[cfg(any(target_os = "windows", target_os = "macos"))] #[test] fn parses_netstat_connection_counts() { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 602e9e4..c5c4fca 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -104,7 +104,7 @@ export default function App() { document.documentElement.classList.toggle("dark", dark); document.documentElement.style.colorScheme = dark ? "dark" : "light"; document.documentElement.dataset.blur = blur ? "on" : "off"; - document.documentElement.lang = config.locale || "zh-CN"; + document.documentElement.lang = config.locale; }, [blur, config.locale, dark]); useEffect(() => { @@ -137,7 +137,7 @@ export default function App() { }, [config.site_name, configReady, selectedId, servers]); useEffect(() => { - if (loading || !config.websocket || !config.public_dashboard || demoMode) return; + if (loading || !config.public_dashboard || demoMode) return; if (config.turnstile_enabled && !getTurnstileProof()) return; const reconnects: number[] = []; let cancelled = false; @@ -161,9 +161,9 @@ export default function App() { if (event.data === "pong") return; try { const message = JSON.parse(event.data); - if (message.type !== "metrics") return; - setServers((current) => current.map((server) => server.id === message.server_id - ? { ...server, ...message.metrics, timestamp: message.timestamp } + if (message.type !== "server" || !message.server?.id) return; + setServers((current) => current.map((server) => server.id === message.server.id + ? message.server : server)); } catch { /* Ignore non-protocol messages. */ } }; @@ -177,7 +177,7 @@ export default function App() { wsRef.current.forEach((socket) => socket.close()); wsRef.current = []; }; - }, [config.websocket, config.public_dashboard, config.turnstile_enabled, load, loading, needsVerification, selectedId]); + }, [config.public_dashboard, config.turnstile_enabled, load, loading, needsVerification, selectedId]); const groups = useMemo(() => ["__all__", ...Array.from(new Set(servers.map((server) => server.group_name || "默认")))], [servers]); const visible = useMemo(() => servers.filter((server) => { diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 565322a..1839da2 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -38,6 +38,7 @@ async function request(path: string, init: RequestInit = {}, admin = false): if (response.status === 401 && admin) setToken(""); throw new ApiError(payload.error ?? "请求失败", response.status); } + if (response.status === 204) return undefined as T; return response.json() as Promise; } @@ -47,7 +48,6 @@ export const api = { refreshExchangeRates: () => request("/api/admin/exchange-rates/refresh", { method: "POST" }, true), servers: () => request<{ servers: Server[] }>("/api/servers"), adminServers: () => request<{ servers: AdminServer[] }>("/api/admin/servers", {}, true), - server: (id: string) => request(`/api/servers/${encodeURIComponent(id)}`), history: (id: string, hours: number) => request<{ points: HistoryPoint[] }>(`/api/history/${encodeURIComponent(id)}?hours=${hours}`), latencyHistory: (id: string, hours: number) => @@ -64,26 +64,26 @@ export const api = { createLatencyTask: (input: LatencyTaskInput) => request<{ id: string }>("/api/admin/latency-tasks", { method: "POST", body: JSON.stringify(input) }, true), updateLatencyTask: (id: string, input: LatencyTaskInput) => - request<{ success: boolean }>(`/api/admin/latency-tasks/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(input) }, true), + request(`/api/admin/latency-tasks/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(input) }, true), deleteLatencyTask: (id: string) => - request<{ success: boolean }>(`/api/admin/latency-tasks/${encodeURIComponent(id)}`, { method: "DELETE" }, true), + request(`/api/admin/latency-tasks/${encodeURIComponent(id)}`, { method: "DELETE" }, true), alertRules: () => request<{ rules: AlertRule[] }>("/api/admin/alert-rules", {}, true), createAlertRule: (input: AlertRuleInput) => request<{ id: string }>("/api/admin/alert-rules", { method: "POST", body: JSON.stringify(input) }, true), updateAlertRule: (id: string, input: AlertRuleInput) => - request<{ success: boolean }>(`/api/admin/alert-rules/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(input) }, true), + request(`/api/admin/alert-rules/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(input) }, true), deleteAlertRule: (id: string) => - request<{ success: boolean }>(`/api/admin/alert-rules/${encodeURIComponent(id)}`, { method: "DELETE" }, true), + request(`/api/admin/alert-rules/${encodeURIComponent(id)}`, { method: "DELETE" }, true), themeSettings: () => request("/api/admin/theme-settings", {}, true), themes: () => request<{ themes: Theme[] }>("/api/admin/themes", {}, true), addTheme: (input: Pick) => request<{ id: string }>("/api/admin/themes", { method: "POST", body: JSON.stringify(input) }, true), activateTheme: (id: string) => - request<{ success: boolean }>(`/api/admin/themes/${encodeURIComponent(id)}/activate`, { method: "POST" }, true), + request(`/api/admin/themes/${encodeURIComponent(id)}/activate`, { method: "POST" }, true), previewTheme: (id: string) => request<{ preview_url: string }>(`/api/admin/themes/${encodeURIComponent(id)}/preview`, { method: "POST" }, true), deleteTheme: (id: string) => - request<{ success: boolean }>(`/api/admin/themes/${encodeURIComponent(id)}`, { method: "DELETE" }, true), + request(`/api/admin/themes/${encodeURIComponent(id)}`, { method: "DELETE" }, true), saveSettings: (input: Partial) => request<{ settings: Settings; token: string | null }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(input) }, true), createServer: (input: ServerInput) => @@ -93,19 +93,19 @@ export const api = { true, ), updateServer: (id: string, input: ServerInput) => - request<{ success: boolean }>( + request( `/api/admin/servers/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(input) }, true, ), deleteServer: (id: string) => - request<{ success: boolean }>( + request( `/api/admin/servers/${encodeURIComponent(id)}`, { method: "DELETE" }, true, ), deleteServers: (ids: string[]) => - request<{ success: boolean }>("/api/admin/servers", { + request("/api/admin/servers", { method: "DELETE", body: JSON.stringify({ ids }), }, true), @@ -116,13 +116,13 @@ export const api = { true, ), reorderServers: (ids: string[]) => - request<{ success: boolean }>( + request( "/api/admin/servers/order", { method: "PATCH", body: JSON.stringify({ ids }) }, true, ), databaseStats: () => request("/api/admin/database", {}, true), cloudflareUsage: () => request("/api/admin/cloudflare-usage", {}, true), - clearHistory: () => request<{ success: boolean }>("/api/admin/history", { method: "DELETE" }, true), - testNotification: () => request<{ success: boolean }>("/api/admin/notifications/test", { method: "POST" }, true), + clearHistory: () => request("/api/admin/history", { method: "DELETE" }, true), + testNotification: () => request("/api/admin/notifications/test", { method: "POST" }, true), }; diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index a6f864b..3d00858 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -84,23 +84,23 @@ function toInput(server: AdminServer): ServerInput { group_name: server.group_name, tags: server.tags, note: server.note, - hidden: !!server.hidden, + hidden: server.hidden, expires_at: server.expires_at, traffic_limit: server.traffic_limit, - traffic_limit_type: server.traffic_limit_type || "sum", + traffic_limit_type: server.traffic_limit_type, price: server.price, billing_cycle: server.billing_cycle, currency: server.currency, - auto_renewal: !!server.auto_renewal, + auto_renewal: server.auto_renewal, public_remark: server.public_remark, - network_interface: server.network_interface || "", - reset_day: server.reset_day || 1, - report_interval: server.report_interval || 60, - collect_interval: server.collect_interval || 5, - rx_correction: server.rx_correction || 0, - tx_correction: server.tx_correction || 0, - offline_notify_disabled: !!server.offline_notify_disabled, - auto_update: !!server.auto_update, + network_interface: server.network_interface, + reset_day: server.reset_day, + report_interval: server.report_interval, + collect_interval: server.collect_interval, + rx_correction: server.rx_correction, + tx_correction: server.tx_correction, + offline_notify_disabled: server.offline_notify_disabled, + auto_update: server.auto_update, }; } @@ -167,15 +167,7 @@ export function AdminPanel({ const [serverResult, settingsResult, themesResult] = await Promise.all([api.adminServers(), api.settings(), api.themes()]); setServers(serverResult.servers); setThemes(themesResult.themes); - setSettings({ - ...settingsResult, - site_announcement: settingsResult.site_announcement || "", - theme_options: settingsResult.theme_options || {}, - cloudflare_account_id: settingsResult.cloudflare_account_id || "", - cloudflare_api_token: settingsResult.cloudflare_api_token || "", - favicon_url: settingsResult.favicon_url || "", - locale: settingsResult.locale || "zh-CN", - }); + setSettings(settingsResult); setAuthenticated(true); } catch (reason) { setError(reason instanceof Error ? reason.message : "加载失败"); @@ -533,7 +525,7 @@ export function AdminPanel({ ) : tab === "themes" && settings ? (
-

主题列表

内置主题始终可用,远程主题由 Worker 代理前台页面和资源。
+

主题列表

内置主题始终可用,远程主题由 Worker 代理前台页面和资源;可选的 theme.json 提供主题设置。
{themes.map((theme) =>
@@ -632,7 +624,7 @@ export function AdminPanel({
-
+