diff --git a/agent/src/main.rs b/agent/src/main.rs index 28be00b..7cdad6e 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -300,6 +300,7 @@ impl PublicIpProbe { let mut state = state.lock().unwrap(); state.v4.observe(v4, observed_at); state.v6.observe(v6, observed_at); + drop(state); probing.store(false, Ordering::SeqCst); }); } @@ -753,8 +754,10 @@ fn os_name() -> String { text("/etc/os-release") .lines() .find_map(|line| line.strip_prefix("PRETTY_NAME=")) - .map(|value| value.trim_matches('"').to_string()) - .unwrap_or_else(|| command("uname", &["-s"])) + .map_or_else( + || command("uname", &["-s"]), + |value| value.trim_matches('"').to_string(), + ) } #[cfg(target_os = "linux")] @@ -1143,7 +1146,7 @@ fn sysfs_gpu_names() -> Vec { return Vec::new(); }; entries - .filter_map(|entry| entry.ok()) + .filter_map(std::result::Result::ok) .filter(|entry| { let name = entry.file_name(); let name = name.to_string_lossy(); @@ -1208,10 +1211,7 @@ fn parse_pciconf_gpu_names(output: &str) -> Vec { let mut block = Vec::new(); for line in output.lines() { if !line.is_empty() - && !line - .chars() - .next() - .is_some_and(|character| character.is_whitespace()) + && !line.chars().next().is_some_and(char::is_whitespace) && !block.is_empty() { if let Some(name) = block_name(&block) { @@ -1345,9 +1345,7 @@ impl Collector { fn refresh_basic(&mut self) { let gpus = gpu_info(); self.basic = BasicMetrics { - cpu_cores: thread::available_parallelism() - .map(|value| value.get() as i64) - .unwrap_or(1), + cpu_cores: thread::available_parallelism().map_or(1, |value| value.get() as i64), cpu_model: cpu_model(), os: os_name(), kernel: command("uname", &["-r"]), @@ -1365,19 +1363,17 @@ impl Collector { } fn refresh_slow(&mut self) { - let processes = fs::read_dir("/proc") - .map(|items| { - items - .filter_map(|item| item.ok()) - .filter(|item| { - item.file_name() - .to_string_lossy() - .chars() - .all(|ch| ch.is_ascii_digit()) - }) - .count() as i64 - }) - .unwrap_or(0); + let processes = fs::read_dir("/proc").map_or(0, |items| { + items + .filter_map(std::result::Result::ok) + .filter(|item| { + item.file_name() + .to_string_lossy() + .chars() + .all(|ch| ch.is_ascii_digit()) + }) + .count() as i64 + }); self.slow = SlowMetrics { disks: disk_usage(), processes, @@ -1891,7 +1887,7 @@ fn receive_remote_output( } } -fn remote_result_text(stdout: CapturedOutput, stderr: CapturedOutput) -> String { +fn remote_result_text(stdout: &CapturedOutput, stderr: &CapturedOutput) -> String { let truncated = stdout.truncated || stderr.truncated; let stdout = String::from_utf8_lossy(&stdout.bytes); let stderr = String::from_utf8_lossy(&stderr.bytes); @@ -1989,7 +1985,7 @@ fn execute_remote_task_with_timeout( terminate_remote_process_tree(&mut child); let stdout = receive_remote_output(&stdout, &mut child); let stderr = receive_remote_output(&stderr, &mut child); - let output = remote_result_text(stdout, stderr); + let output = remote_result_text(&stdout, &stderr); return TaskResultMessage { message_type: "task_result".to_string(), task_id, @@ -2005,7 +2001,7 @@ fn execute_remote_task_with_timeout( }; let stdout = receive_remote_output(&stdout, &mut child); let stderr = receive_remote_output(&stderr, &mut child); - let output = remote_result_text(stdout, stderr); + let output = remote_result_text(&stdout, &stderr); let Some(status) = status else { let timeout_seconds = timeout.as_secs().max(1); @@ -2279,8 +2275,7 @@ fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) { let mut socket: Option = None; let mut wss_interval = configured_interval .lock() - .map(|interval| *interval) - .unwrap_or(Duration::from_secs(1)); + .map_or(Duration::from_secs(1), |interval| *interval); let mut accepted_through = persisted_through.load(Ordering::Acquire); let mut next_send_at = Instant::now(); let mut next_probe_at = Instant::now(); @@ -2326,9 +2321,8 @@ fn live_sender_loop(endpoint: &str, token: &str, worker: LiveSenderWorker) { } let (queue_lock, ready) = &*pending; - let mut queue = match queue_lock.lock() { - Ok(queue) => queue, - Err(_) => return, + let Ok(mut queue) = queue_lock.lock() else { + return; }; let batch = loop { let now = Instant::now(); @@ -2684,7 +2678,8 @@ fn rewrite_pending_spool(path: &Path, samples: &[Report]) -> Result<()> { } fn normalized_version(value: &str) -> &str { - value.trim().strip_prefix('v').unwrap_or(value.trim()) + let value = value.trim(); + value.strip_prefix('v').unwrap_or(value) } fn version_triplet(value: &str) -> Option<(u64, u64, u64)> { @@ -3343,11 +3338,11 @@ mod tests { #[test] fn remote_command_output_is_bounded() { let result = remote_result_text( - CapturedOutput { + &CapturedOutput { bytes: vec![b'x'; REMOTE_RESULT_OUTPUT_BYTES + 100], truncated: true, }, - CapturedOutput::default(), + &CapturedOutput::default(), ); assert!(result.len() < REMOTE_RESULT_OUTPUT_BYTES + 64); assert!(result.ends_with("[输出已截断]")); diff --git a/backend/migrations/postgres/01_initial.sql b/backend/migrations/postgres/01_initial.sql index c9ea140..3e1bbcf 100644 --- a/backend/migrations/postgres/01_initial.sql +++ b/backend/migrations/postgres/01_initial.sql @@ -98,7 +98,7 @@ CREATE TABLE remote_tasks ( id TEXT PRIMARY KEY, server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, command TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'sent', 'running', 'success', 'failed')), + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'sent', 'success', 'failed')), requested_by TEXT NOT NULL, requested_at BIGINT NOT NULL, started_at BIGINT, diff --git a/backend/migrations/sqlite/01_initial.sql b/backend/migrations/sqlite/01_initial.sql index 621e43b..9611443 100644 --- a/backend/migrations/sqlite/01_initial.sql +++ b/backend/migrations/sqlite/01_initial.sql @@ -100,7 +100,7 @@ CREATE TABLE remote_tasks ( id TEXT PRIMARY KEY, server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, command TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'sent', 'running', 'success', 'failed')), + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'sent', 'success', 'failed')), requested_by TEXT NOT NULL, requested_at INTEGER NOT NULL, started_at INTEGER, diff --git a/backend/src/backup.rs b/backend/src/backup.rs index efbfcef..d6106e6 100644 --- a/backend/src/backup.rs +++ b/backend/src/backup.rs @@ -168,20 +168,16 @@ fn row_values(row: &AnyRow, columns: &[BackupColumn]) -> Result> { Ok(match column.kind { BackupKind::Bool => row .try_get::, _>(index)? - .map(Value::Bool) - .unwrap_or(Value::Null), + .map_or(Value::Null, Value::Bool), BackupKind::SmallInt => row .try_get::, _>(index)? - .map(|value| Value::Number(i64::from(value).into())) - .unwrap_or(Value::Null), + .map_or(Value::Null, |value| Value::Number(i64::from(value).into())), BackupKind::Integer => row .try_get::, _>(index)? - .map(|value| Value::Number(i64::from(value).into())) - .unwrap_or(Value::Null), + .map_or(Value::Null, |value| Value::Number(i64::from(value).into())), BackupKind::BigInt => row .try_get::, _>(index)? - .map(|value| Value::Number(value.into())) - .unwrap_or(Value::Null), + .map_or(Value::Null, |value| Value::Number(value.into())), BackupKind::Real => match row.try_get::, _>(index)? { Some(value) => finite_json_number(f64::from(value))?, None => Value::Null, @@ -192,12 +188,10 @@ fn row_values(row: &AnyRow, columns: &[BackupColumn]) -> Result> { }, BackupKind::Text => row .try_get::, _>(index)? - .map(Value::String) - .unwrap_or(Value::Null), + .map_or(Value::Null, Value::String), BackupKind::Blob => row .try_get::>, _>(index)? - .map(|value| Value::String(hex::encode(value))) - .unwrap_or(Value::Null), + .map_or(Value::Null, |value| Value::String(hex::encode(value))), }) }) .collect() @@ -518,7 +512,7 @@ pub async fn restore_archive(db: &Database, archive: &[u8]) -> Result { sqlx::query(db.sql( "UPDATE remote_tasks SET status='failed', completed_at=?, \ result=CASE WHEN result='' THEN '数据库恢复后已取消未完成任务' ELSE result END \ - WHERE status IN ('pending','sent','running')", + WHERE status IN ('pending','sent')", )) .bind(crate::db::now()) .execute(&mut *transaction) diff --git a/backend/src/config.rs b/backend/src/config.rs index 4d00916..bd5d9a3 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -10,7 +10,7 @@ pub const VERSION: &str = match option_env!("NODEFLARE_VERSION") { }; #[derive(Debug, Parser)] -#[command(version = VERSION, about = "NodeFlare standalone monitoring server")] +#[command(version = VERSION, about = "NodeFlare monitoring server")] pub struct Args { /// Path to the TOML configuration file. #[arg(short, long, default_value = "/etc/nodeflare/config.toml")] @@ -81,7 +81,7 @@ impl Config { pub fn load(path: &Path) -> Result { let content = std::fs::read_to_string(path) .with_context(|| format!("failed to read configuration {}", path.display()))?; - let mut config: Config = toml::from_str(&content) + let mut config: Self = toml::from_str(&content) .with_context(|| format!("failed to parse configuration {}", path.display()))?; let base = path .parent() diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index 7b5a9f7..5f68056 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -359,7 +359,7 @@ pub async fn initialize(pool: &Database, config: &Config) -> Result<()> { sqlx::query("DELETE FROM settings WHERE key LIKE 'session_%'") .execute(&mut *transaction) .await?; - tracing::info!(timestamp = now, "initialized standalone password scheme"); + tracing::info!(timestamp = now, "initialized password scheme"); } transaction.commit().await?; Ok(()) diff --git a/backend/src/db/queries.rs b/backend/src/db/queries.rs index 5752d68..37fe687 100644 --- a/backend/src/db/queries.rs +++ b/backend/src/db/queries.rs @@ -570,7 +570,7 @@ async fn load_traffic_state( }) }) .transpose() - .map(|state| state.unwrap_or_default()) + .map(Option::unwrap_or_default) .map_err(Into::into) } @@ -870,7 +870,7 @@ pub async fn cleanup_database(db: &Database, retention_days: i64) -> Result<()> sqlx::query(db.sql( "UPDATE remote_tasks SET status='failed', completed_at=?, \ result=CASE WHEN result='' THEN '任务等待超过 24 小时,已自动取消' ELSE result END \ - WHERE status IN ('pending','sent','running') AND requested_at Result Result, _>>() .map_err(Into::into) } fn remote_task_from_row( - row: sqlx::any::AnyRow, + row: &sqlx::any::AnyRow, ) -> std::result::Result { Ok(RemoteTaskInfo { id: row.try_get("id")?, @@ -1604,7 +1605,7 @@ pub async fn update_remote_task_result( ) -> Result { let result = sqlx::query(db.sql( "UPDATE remote_tasks SET status=?, result=?, exit_code=?, completed_at=? \ - WHERE id=? AND server_id=? AND status IN ('pending','sent','running')", + WHERE id=? AND server_id=? AND status IN ('pending','sent')", )) .bind(status) .bind(result) diff --git a/backend/src/exchange.rs b/backend/src/exchange.rs index 0ead939..ffcdc9b 100644 --- a/backend/src/exchange.rs +++ b/backend/src/exchange.rs @@ -75,7 +75,6 @@ async fn fetch_json(client: &reqwest::Client, url: &str) -> Result { let response = client .get(url) .header("Accept", "application/json") - .header("User-Agent", "NodeFlare-Standalone") .timeout(Duration::from_secs(8)) .send() .await? diff --git a/backend/src/main.rs b/backend/src/main.rs index b9d21a3..125d790 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -4,6 +4,7 @@ mod config; mod db; mod exchange; mod middleware; +mod mime; mod models; mod notify; mod routes; @@ -64,7 +65,8 @@ impl AppState { } pub async fn disconnect_agent(&self, server_id: &str) { - if let Some(connection) = self.agents.write().await.remove(server_id) { + let connection = self.agents.write().await.remove(server_id); + if let Some(connection) = connection { let _ = connection.sender.try_send(websocket::AgentCommand::Close); } } @@ -136,7 +138,7 @@ async fn main() -> Result<()> { } let http = reqwest::Client::builder() - .user_agent("NodeFlare-Standalone") + .user_agent(format!("NodeFlare/{}", config::VERSION)) .connect_timeout(std::time::Duration::from_secs(5)) .build()?; let (dashboard_tx, _) = broadcast::channel(2048); diff --git a/backend/src/mime.rs b/backend/src/mime.rs new file mode 100644 index 0000000..46eb496 --- /dev/null +++ b/backend/src/mime.rs @@ -0,0 +1,27 @@ +use std::path::Path; + +pub fn content_type(path: &Path) -> &'static str { + match path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + { + "html" => "text/html; charset=utf-8", + "js" | "mjs" => "application/javascript; charset=utf-8", + "css" => "text/css; charset=utf-8", + "json" | "map" => "application/json; charset=utf-8", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "webp" => "image/webp", + "gif" => "image/gif", + "ico" => "image/x-icon", + "woff" => "font/woff", + "woff2" => "font/woff2", + "ttf" => "font/ttf", + "wasm" => "application/wasm", + "sh" => "text/x-shellscript; charset=utf-8", + "ps1" => "text/plain; charset=utf-8", + _ => "application/octet-stream", + } +} diff --git a/backend/src/routes/admin.rs b/backend/src/routes/admin.rs index 3f189d7..f90c4a6 100644 --- a/backend/src/routes/admin.rs +++ b/backend/src/routes/admin.rs @@ -161,8 +161,7 @@ pub async fn settings_patch( let next_username = input .admin_username .as_deref() - .map(str::trim) - .unwrap_or(¤t.admin_username) + .map_or(current.admin_username.as_str(), str::trim) .to_string(); let username_changed = next_username != user.username; if !username_changed { diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index f5aabb6..16600d7 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -294,14 +294,16 @@ pub(crate) fn session_device(headers: &HeaderMap, peer: SocketAddr) -> crate::db .and_then(|value| value.to_str().ok()) .map(str::trim) .filter(|value| !value.is_empty()) - .map(|value| { - value - .chars() - .filter(|character| !character.is_control()) - .take(512) - .collect() - }) - .unwrap_or_else(|| "未知客户端".to_string()); + .map_or_else( + || "未知客户端".to_string(), + |value| { + value + .chars() + .filter(|character| !character.is_control()) + .take(512) + .collect() + }, + ); crate::db::SessionDevice { ip_address, user_agent, diff --git a/backend/src/routes/site.rs b/backend/src/routes/site.rs index 2dd1225..caf2d98 100644 --- a/backend/src/routes/site.rs +++ b/backend/src/routes/site.rs @@ -44,14 +44,10 @@ pub async fn handle(State(state): State>, OriginalUri(uri): Origin Ok(settings) => settings, Err(error) => return ApiResponse::internal(error).into_response(), }; - let base = match crate::db::queries::theme_resolved_url( - &state.db, - &settings.active_theme_id, - ) - .await - { - Ok(Some(base)) => base, - _ => return StatusCode::NOT_FOUND.into_response(), + let Ok(Some(base)) = + crate::db::queries::theme_resolved_url(&state.db, &settings.active_theme_id).await + else { + return StatusCode::NOT_FOUND.into_response(); }; return theme_file(&state, &base, relative, "/__theme-active").await; } @@ -97,7 +93,7 @@ async fn local_file(root: &Path, relative: &str, cache: bool) -> Response { return StatusCode::NOT_FOUND.into_response(); }; match tokio::fs::read(&path).await { - Ok(body) => response(body, content_type(&path), cache), + Ok(body) => response(body, crate::mime::content_type(&path), cache), Err(_) => StatusCode::NOT_FOUND.into_response(), } } @@ -149,26 +145,3 @@ fn response(body: Vec, content_type: &str, cache: bool) -> Response { } response } - -fn content_type(path: &Path) -> &'static str { - match path - .extension() - .and_then(|value| value.to_str()) - .unwrap_or("") - { - "html" => "text/html; charset=utf-8", - "js" | "mjs" => "application/javascript; charset=utf-8", - "css" => "text/css; charset=utf-8", - "json" => "application/json; charset=utf-8", - "svg" => "image/svg+xml", - "png" => "image/png", - "jpg" | "jpeg" => "image/jpeg", - "webp" => "image/webp", - "ico" => "image/x-icon", - "woff2" => "font/woff2", - "wasm" => "application/wasm", - "sh" => "text/x-shellscript; charset=utf-8", - "ps1" => "text/plain; charset=utf-8", - _ => "application/octet-stream", - } -} diff --git a/backend/src/theme.rs b/backend/src/theme.rs index 69e29e9..561919e 100644 --- a/backend/src/theme.rs +++ b/backend/src/theme.rs @@ -141,7 +141,7 @@ fn validate_release_asset_url(value: &str, owner: &str, repository: &str) -> Res let parsed = Url::parse(value).context("Release ZIP 下载地址无效")?; let parts = parsed .path_segments() - .map(|parts| parts.collect::>()) + .map(Iterator::collect::>) .unwrap_or_default(); if parsed.scheme() != "https" || parsed.host_str() != Some("github.com") @@ -227,7 +227,7 @@ pub async fn fetch_theme_path( }; let root = local_root(theme_dir, base)?; let body = read_local(&root, target, limit).await?; - let content_type = content_type(Path::new(target)).to_string(); + let content_type = crate::mime::content_type(Path::new(target)).to_string(); if target == "index.html" { let html = String::from_utf8(body)?; return Ok((rewrite_index(&html, prefix).into_bytes(), content_type)); @@ -276,7 +276,8 @@ fn empty_settings_schema() -> Value { } fn sanitize_version(value: &str) -> Option { - let version = value.trim().strip_prefix('v').unwrap_or(value.trim()); + let value = value.trim(); + let version = value.strip_prefix('v').unwrap_or(value); (!version.is_empty() && version.len() <= 40 && version @@ -334,31 +335,6 @@ async fn read_local_optional(root: &Path, relative: &str, limit: usize) -> Resul Ok(Some(body)) } -fn content_type(path: &Path) -> &'static str { - match path - .extension() - .and_then(|value| value.to_str()) - .unwrap_or("") - { - "html" => "text/html; charset=utf-8", - "js" | "mjs" => "application/javascript; charset=utf-8", - "css" => "text/css; charset=utf-8", - "json" => "application/json; charset=utf-8", - "svg" => "image/svg+xml", - "png" => "image/png", - "jpg" | "jpeg" => "image/jpeg", - "webp" => "image/webp", - "gif" => "image/gif", - "ico" => "image/x-icon", - "woff" => "font/woff", - "woff2" => "font/woff2", - "ttf" => "font/ttf", - "wasm" => "application/wasm", - "map" => "application/json; charset=utf-8", - _ => "application/octet-stream", - } -} - fn install_archive_blocking(theme_dir: &Path, id: &str, archive: Vec) -> Result<()> { fs::create_dir_all(theme_dir) .with_context(|| format!("无法创建主题目录 {}", theme_dir.display()))?; diff --git a/backend/src/websocket/agent.rs b/backend/src/websocket/agent.rs index 0f7a9f3..859e326 100644 --- a/backend/src/websocket/agent.rs +++ b/backend/src/websocket/agent.rs @@ -180,12 +180,14 @@ async fn run( } } writer.abort(); - let mut agents = state.agents.write().await; - if agents - .get(&identity.server_id) - .is_some_and(|connection| connection.connection_id == connection_id) { - agents.remove(&identity.server_id); + let mut agents = state.agents.write().await; + if agents + .get(&identity.server_id) + .is_some_and(|connection| connection.connection_id == connection_id) + { + agents.remove(&identity.server_id); + } } tracing::info!(server_id = %identity.server_id, "Agent disconnected"); } diff --git a/backend/src/websocket/dashboard.rs b/backend/src/websocket/dashboard.rs index d4da35c..77abe55 100644 --- a/backend/src/websocket/dashboard.rs +++ b/backend/src/websocket/dashboard.rs @@ -55,8 +55,7 @@ async fn run(socket: WebSocket, state: Arc, server_id: Option) break; } } - Ok(_) => {} - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {} + Ok(_) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {} Err(tokio::sync::broadcast::error::RecvError::Closed) => break, }, incoming = receiver.next() => match incoming { diff --git a/frontend/src/AppProvider.tsx b/frontend/src/AppProvider.tsx index 5877890..1d267f3 100644 --- a/frontend/src/AppProvider.tsx +++ b/frontend/src/AppProvider.tsx @@ -10,6 +10,7 @@ import { type PlaybackBuffer, } from "./live"; import { ui } from "./locale"; +import { useFavicon, useStoredAppearance, useSystemDark } from "./hooks/useBrowserAppearance"; import { resolveBackground, themeToggle } from "./theme"; import { BOOTSTRAP_POLL_INTERVAL_MS, @@ -82,11 +83,8 @@ export function AppProvider({ children }: { children: ReactNode }) { const [error, setError] = useState(""); const [access, setAccess] = useState("ok"); const [selectedId, setSelectedId] = useState(routeServerId); - const [appearance, setAppearance] = useState<"light" | "dark" | null>(() => { - const stored = localStorage.getItem("nodeflare-theme"); - return stored === "light" || stored === "dark" ? stored : null; - }); - const [systemDark, setSystemDark] = useState(() => matchMedia("(prefers-color-scheme: dark)").matches); + const [appearance, setAppearance] = useStoredAppearance("nodeflare-theme"); + const systemDark = useSystemDark(); const liveConnectedRef = useRef(false); const playbackRef = useRef(new Map()); const serversRef = useRef([]); @@ -99,6 +97,7 @@ export function AppProvider({ children }: { children: ReactNode }) { const background = resolveBackground(config.background_url, dark); const blur = themeToggle(config, "enableBlur"); const carrierLatency = config.show_latency && themeToggle(config, "showCarrierLatency", false); + useFavicon(config.logo_url); if (!reloadQueueRef.current) { reloadQueueRef.current = createRefreshQueue(async (quiet) => { @@ -266,24 +265,6 @@ export function AppProvider({ children }: { children: ReactNode }) { document.documentElement.lang = config.locale; }, [blur, config.locale, dark]); - useEffect(() => { - let link = document.querySelector('link[rel="icon"]'); - if (!link) { - link = document.createElement("link"); - link.rel = "icon"; - document.head.append(link); - } - link.removeAttribute("type"); - link.href = config.logo_url || "/logo.svg"; - }, [config.logo_url]); - - useEffect(() => { - const media = matchMedia("(prefers-color-scheme: dark)"); - const update = () => setSystemDark(media.matches); - media.addEventListener("change", update); - return () => media.removeEventListener("change", update); - }, []); - useEffect(() => { const onPop = () => setSelectedId(routeServerId()); window.addEventListener("popstate", onPop); @@ -317,7 +298,6 @@ export function AppProvider({ children }: { children: ReactNode }) { setAppearance((current) => { const resolved = current ?? (dark ? "dark" : "light"); const next = resolved === "dark" ? "light" : "dark"; - localStorage.setItem("nodeflare-theme", next); return next; }); }, [dark]); diff --git a/frontend/src/admin-main.tsx b/frontend/src/admin-main.tsx index 4578d4c..557bfed 100644 --- a/frontend/src/admin-main.tsx +++ b/frontend/src/admin-main.tsx @@ -2,22 +2,17 @@ import { StrictMode, useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; import { api } from "./api"; import { AdminPanel } from "./components/AdminPanel"; +import { useFavicon, useStoredAppearance, useSystemDark } from "./hooks/useBrowserAppearance"; import type { Config } from "./types"; import "./styles/admin.css"; -const THEME_KEY = "nodeflare-admin-theme"; - -function initialAppearance(): "light" | "dark" | null { - const stored = localStorage.getItem(THEME_KEY); - return stored === "light" || stored === "dark" ? stored : null; -} - function AdminApp() { const [config, setConfig] = useState(null); const [error, setError] = useState(""); - const [appearance, setAppearance] = useState<"light" | "dark" | null>(initialAppearance); - const [systemDark, setSystemDark] = useState(() => matchMedia("(prefers-color-scheme: dark)").matches); + const [appearance, setAppearance] = useStoredAppearance("nodeflare-admin-theme"); + const systemDark = useSystemDark(); const dark = appearance ? appearance === "dark" : config?.default_theme === "system" || !config ? systemDark : config.default_theme === "dark"; + useFavicon(config?.logo_url); async function loadConfig() { try { @@ -34,31 +29,14 @@ function AdminApp() { document.documentElement.classList.toggle("dark", dark); document.documentElement.style.colorScheme = dark ? "dark" : "light"; }, [dark]); - useEffect(() => { - const media = matchMedia("(prefers-color-scheme: dark)"); - const update = () => setSystemDark(media.matches); - media.addEventListener("change", update); - return () => media.removeEventListener("change", update); - }, []); useEffect(() => { document.title = config ? `管理面板 · ${config.site_name}` : "管理面板"; }, [config]); - useEffect(() => { - let link = document.querySelector('link[rel="icon"]'); - if (!link) { - link = document.createElement("link"); - link.rel = "icon"; - document.head.append(link); - } - link.removeAttribute("type"); - link.href = config?.logo_url || "/logo.svg"; - }, [config?.logo_url]); if (error) return
{error}
; if (!config) return
正在加载管理面板
; return { const next = dark ? "light" : "dark"; - localStorage.setItem(THEME_KEY, next); setAppearance(next); }} onChanged={() => void loadConfig()} />; } diff --git a/frontend/src/format.ts b/frontend/src/format.ts index a2df260..7495ea6 100644 --- a/frontend/src/format.ts +++ b/frontend/src/format.ts @@ -15,7 +15,7 @@ export interface FormattedBytes { unit: string; } -export function formatBytesParts(value: number | null | undefined, decimals = 1): FormattedBytes { +function formatBytesParts(value: number | null | undefined, decimals = 1): FormattedBytes { const size = Math.max(0, number(value)); if (size === 0) return { value: "0", unit: "B" }; const units = ["B", "KB", "MB", "GB", "TB", "PB"]; diff --git a/frontend/src/hooks/useBrowserAppearance.ts b/frontend/src/hooks/useBrowserAppearance.ts new file mode 100644 index 0000000..755ebca --- /dev/null +++ b/frontend/src/hooks/useBrowserAppearance.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from "react"; + +type Appearance = "light" | "dark"; + +export function useStoredAppearance(key: string) { + const [appearance, setAppearance] = useState(() => { + const stored = localStorage.getItem(key); + return stored === "light" || stored === "dark" ? stored : null; + }); + + useEffect(() => { + if (appearance) localStorage.setItem(key, appearance); + }, [appearance, key]); + + return [appearance, setAppearance] as const; +} + +export function useSystemDark() { + const [systemDark, setSystemDark] = useState( + () => matchMedia("(prefers-color-scheme: dark)").matches, + ); + + useEffect(() => { + const media = matchMedia("(prefers-color-scheme: dark)"); + const update = () => setSystemDark(media.matches); + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + }, []); + + return systemDark; +} + +export function useFavicon(logoUrl: string | null | undefined) { + useEffect(() => { + let link = document.querySelector('link[rel="icon"]'); + if (!link) { + link = document.createElement("link"); + link.rel = "icon"; + document.head.append(link); + } + link.removeAttribute("type"); + link.href = logoUrl || "/logo.svg"; + }, [logoUrl]); +} diff --git a/frontend/src/latency.ts b/frontend/src/latency.ts index 6dfe345..abfb781 100644 --- a/frontend/src/latency.ts +++ b/frontend/src/latency.ts @@ -2,7 +2,7 @@ import { clockLabel } from "./chart"; import { ui, type UiLocale } from "./locale"; import type { LatencySample } from "./types"; -export type LatencyTone = "good" | "fair" | "warning" | "poor" | "danger" | "empty"; +type LatencyTone = "good" | "fair" | "warning" | "poor" | "danger" | "empty"; export interface LatencyBar { key: string; @@ -31,7 +31,7 @@ export const CARRIER_SLOTS = [ export type CarrierSlot = typeof CARRIER_SLOTS[number]; -export function latencyTone(value: number): LatencyTone { +function latencyTone(value: number): LatencyTone { if (value <= 60) return "good"; if (value <= 100) return "fair"; if (value <= 160) return "warning"; @@ -39,7 +39,7 @@ export function latencyTone(value: number): LatencyTone { return "danger"; } -export function lossTone(value: number): LatencyTone { +function lossTone(value: number): LatencyTone { if (value <= 1) return "good"; if (value <= 3) return "fair"; if (value <= 6) return "warning"; diff --git a/frontend/src/live.ts b/frontend/src/live.ts index dc30d9e..3007e42 100644 --- a/frontend/src/live.ts +++ b/frontend/src/live.ts @@ -1,7 +1,7 @@ import type { LatencySample, LiveLatencyResult, Server } from "./types"; /** A single Agent report as replayed by the NodeFlare server. */ -export interface LiveSample { +interface LiveSample { ts: number; data: Partial & { latency_results?: LiveLatencyResult[] }; } @@ -30,7 +30,7 @@ export interface BatchUpdate { reportAgeMs?: number; } -export const MAX_PLAYBACK_SAMPLES_PER_SERVER = 600; +const MAX_PLAYBACK_SAMPLES_PER_SERVER = 600; const MAX_LIVE_LATENCY_RESULTS = 4096; export function mergeLiveResults( diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts index 24e434c..ca8d766 100644 --- a/frontend/src/theme.ts +++ b/frontend/src/theme.ts @@ -17,7 +17,7 @@ export function themeToggle(config: Config, key: string, fallback = true) { return typeof value === "boolean" ? value : fallback; } -export function themeText(config: Config, key: string) { +function themeText(config: Config, key: string) { const value = option(config, key); return typeof value === "string" ? value.trim() : ""; } diff --git a/scripts/build-frontend.sh b/scripts/build-frontend.sh index 603d8ce..9fd48a5 100644 --- a/scripts/build-frontend.sh +++ b/scripts/build-frontend.sh @@ -10,7 +10,3 @@ cd "$root_dir" [ -d node_modules ] || bun install --frozen-lockfile bun run --cwd frontend build - -if build_key=$(sh scripts/build-key.sh frontend 2>/dev/null); then - printf '%s\n' "$build_key" > frontend/dist/.nodeflare-revision -fi diff --git a/scripts/build-key.sh b/scripts/build-key.sh deleted file mode 100644 index 3f82ba2..0000000 --- a/scripts/build-key.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/sh -set -eu - -case "${1:-}" in - frontend) - paths="frontend package.json bun.lock scripts/build-frontend.sh scripts/build-key.sh scripts/resolve-version.sh" - ;; - backend) - paths="backend/Cargo.toml backend/Cargo.lock backend/src backend/migrations scripts/build-backend.sh scripts/build-key.sh scripts/resolve-version.sh" - ;; - *) - echo "usage: $0 frontend|backend" >&2 - exit 2 - ;; -esac - -{ - sh scripts/resolve-version.sh - git rev-parse HEAD - git diff --binary -- $paths - git ls-files --others --exclude-standard -- $paths | while IFS= read -r file; do - printf '%s ' "$file" - git hash-object "$file" - done -} | cksum | awk '{ print $1 "-" $2 }' diff --git a/start.sh b/start.sh index 13c828b..abfac41 100755 --- a/start.sh +++ b/start.sh @@ -10,7 +10,7 @@ if [ -z "$config_path" ]; then config_path=/etc/nodeflare/config.toml elif [ -f backend/config.toml ]; then config_path=backend/config.toml - echo "正在使用兼容的本地配置 backend/config.toml" + echo "正在使用本地开发配置 backend/config.toml" else echo "未找到 /etc/nodeflare/config.toml" >&2 echo "请先运行 sudo ./install.sh,安装时会询问管理员用户名和密码。" >&2