diff --git a/agent/src/main.rs b/agent/src/main.rs
index 0dcff3d..5e5fe00 100644
--- a/agent/src/main.rs
+++ b/agent/src/main.rs
@@ -2315,20 +2315,16 @@ fn main() {
#[cfg(test)]
mod tests {
use clap::Parser;
- use std::net::TcpListener;
- use std::thread;
use std::time::{Duration, Instant};
use super::{
- ack_interval, advance_deadline, agent_artifact_name, clock_offset_from_http_date,
- corrected_timestamp, executable_format_valid, gpu_name_from_uevent, is_public_probe_ip,
- live_batch_interval, live_endpoint, live_update_payload, median, mirrored_download_url,
+ ack_interval, advance_deadline, clock_offset_from_http_date, corrected_timestamp,
+ gpu_name_from_uevent, is_public_probe_ip, live_endpoint, live_update_payload,
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, valid_sample_schedule, version_triplet, CliOptions, ClockCalibration,
+ sanitize_latency_tasks, valid_endpoint, version_triplet, CliOptions, ClockCalibration,
GithubReleaseAsset, LatencyResult, LatencyTask, LiveAck, Report, CLOCK_CALIBRATION_MAX_AGE,
- MAX_LATENCY_TASKS, MAX_PENDING_LATENCY_RESULTS, PROBE_ATTEMPTS,
+ MAX_LATENCY_TASKS, MAX_PENDING_LATENCY_RESULTS,
};
#[cfg(any(target_os = "windows", target_os = "macos"))]
@@ -2336,13 +2332,6 @@ mod tests {
#[cfg(target_os = "linux")]
use super::disk_device;
- #[test]
- fn selects_network_interfaces() {
- assert!(selected_interface("eth0", ""));
- assert!(!selected_interface("lo", ""));
- 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\
@@ -2421,17 +2410,6 @@ mod tests {
assert!(!valid_endpoint("https://bad host.example"));
}
- #[test]
- fn builds_mirrored_download_urls() {
- let release =
- "https://github.com/imengying/NodeFlare/releases/download/v1.2.3/agent-linux-x86_64";
- assert_eq!(mirrored_download_url("", release), release);
- assert_eq!(
- mirrored_download_url("https://mirror.example.com/", release),
- format!("https://mirror.example.com/{release}")
- );
- }
-
#[test]
fn builds_live_websocket_endpoints() {
assert_eq!(
@@ -2577,30 +2555,6 @@ mod tests {
assert!(!sanitized.iter().any(|task| task.id == "bad"));
}
- #[test]
- fn backs_off_failed_reports() {
- assert_eq!(report_retry_delay(0, 60), Duration::from_secs(60));
- assert_eq!(report_retry_delay(1, 60), Duration::from_secs(5));
- assert_eq!(report_retry_delay(4, 60), Duration::from_secs(40));
- assert_eq!(report_retry_delay(20, 60), Duration::from_secs(300));
- }
-
- #[test]
- fn validates_sample_schedules() {
- assert!(valid_sample_schedule(3600, 5));
- assert!(!valid_sample_schedule(3600, 4));
- assert!(!valid_sample_schedule(60, 0));
- assert!(!valid_sample_schedule(10, 1));
- }
-
- #[test]
- fn batches_live_samples_at_one_fifteenth_of_the_history_interval() {
- assert_eq!(live_batch_interval(60, 1), Duration::from_secs(4));
- assert_eq!(live_batch_interval(60, 5), Duration::from_secs(5));
- assert_eq!(live_batch_interval(120, 1), Duration::from_secs(8));
- assert_eq!(live_batch_interval(3_600, 1), Duration::from_secs(60));
- }
-
#[test]
fn advances_collection_deadlines_without_drift() {
let start = Instant::now();
@@ -2661,23 +2615,6 @@ mod tests {
assert_eq!(connection_counts_from_netstat(output), (2, 1));
}
- #[test]
- fn selects_current_platform_artifact() {
- #[cfg(target_os = "linux")]
- assert!(agent_artifact_name().is_some_and(|name| name.starts_with("agent-linux-")));
- #[cfg(target_os = "windows")]
- assert_eq!(agent_artifact_name(), Some("agent-windows-x86_64.exe"));
- #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
- assert_eq!(agent_artifact_name(), Some("agent-macos-aarch64"));
- }
-
- #[test]
- fn recognizes_current_executable_format() {
- assert!(executable_format_valid(
- &std::env::current_exe().expect("current executable")
- ));
- }
-
#[test]
fn parses_probe_targets() {
assert_eq!(
@@ -2729,27 +2666,6 @@ mod tests {
}
}
- #[test]
- fn calculates_median() {
- assert_eq!(median(&mut [4.0, 1.0, 3.0]), 3.0);
- assert_eq!(median(&mut [4.0, 1.0, 3.0, 2.0]), 2.5);
- }
-
- #[test]
- fn measures_local_tcp_latency() {
- let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener");
- let address = listener.local_addr().expect("listener address");
- let accepter = thread::spawn(move || {
- for _ in 0..PROBE_ATTEMPTS {
- listener.accept().expect("accept probe connection");
- }
- });
- let (latency, loss) = tcp_latency_probe_address(&address);
- accepter.join().expect("join listener");
- assert!(latency >= 0.0);
- assert_eq!(loss, 0.0);
- }
-
#[test]
fn parses_ping_latency() {
assert_eq!(ping_latency("64 bytes time=12.34 ms"), Some(12.34));
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 9c132b8..3560035 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -2,6 +2,7 @@ import { KeyRound, Megaphone, Moon, Search, Sun, UserCircle } from "lucide-react
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { api, ApiError, setToken } from "./api";
import { NodeCard } from "./components/NodeCard";
+import { SiteLogo } from "./components/SiteLogo";
import { StatsBar } from "./components/StatsBar";
import { TurnstileWidget } from "./components/TurnstileWidget";
import { demoConfig, demoExchangeRates, demoServers } from "./demo";
@@ -279,8 +280,8 @@ export default function App() {
link.rel = "icon";
document.head.append(link);
}
- link.href = config.favicon_url || "/logo.svg";
- }, [config.favicon_url]);
+ link.href = config.favicon_url || config.logo_url || "/logo.svg";
+ }, [config.favicon_url, config.logo_url]);
useEffect(() => {
const media = matchMedia("(prefers-color-scheme: dark)");
@@ -446,7 +447,7 @@ export default function App() {
-
{config.site_name}
+
{config.site_name}
@@ -458,21 +459,21 @@ export default function App() {
{config.site_announcement ?
{config.site_announcement}
: null}
{needsLogin ? (
-
+
{ui(config.locale, "登录仪表盘", "Sign in to dashboard")}
{ui(config.locale, "此仪表盘仅限登录后访问", "This dashboard requires an administrator sign-in")}
) : needsVerification ? (
-
+
{ui(config.locale, "访问验证", "Access verification")}
{config.site_name}
- {config.turnstile_site_key ? void verifyDashboard(token)} onError={setError} /> : {ui(config.locale, "Turnstile 尚未正确配置", "Turnstile is not configured")}
}
+ {config.turnstile_site_key ? void verifyDashboard(token)} onError={setError} /> : {ui(config.locale, "Turnstile 尚未正确配置", "Turnstile is not configured")}
}
{verificationBusy ? {ui(config.locale, "正在验证", "Verifying")} : null}
{error ? {error}
: null}
diff --git a/frontend/src/admin-main.tsx b/frontend/src/admin-main.tsx
index d3bfac4..67338e6 100644
--- a/frontend/src/admin-main.tsx
+++ b/frontend/src/admin-main.tsx
@@ -43,6 +43,15 @@ function AdminApp() {
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.href = config?.favicon_url || config?.logo_url || "/logo.svg";
+ }, [config?.favicon_url, config?.logo_url]);
if (error) return {error}
;
if (!config) return 正在加载管理面板
;
diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx
index 2af4775..30ab90f 100644
--- a/frontend/src/components/AdminPanel.tsx
+++ b/frontend/src/components/AdminPanel.tsx
@@ -37,6 +37,8 @@ import { derivePassword } from "../password";
import { ASSET_CURRENCIES, type AdminServer, type CloudflareUsage, type Config, type DatabaseStats, type ExchangeRates, type ServerInput, type Settings, type Theme, type ThemeSettingField, type ThemeSettingsSchema, type ThemeSettingValue } from "../types";
import { Checkbox } from "./Checkbox";
import { TurnstileWidget } from "./TurnstileWidget";
+import { useDialog } from "./useDialog";
+import { SiteLogo } from "./SiteLogo";
import { LatencyManager } from "./LatencyManager";
import { AlertRuleManager } from "./AlertRuleManager";
import { Flag } from "./Flag";
@@ -499,6 +501,8 @@ export function AdminPanel({
}
return `curl -fsSL ${installer}/agent.sh | sudo sh -s -- -e ${shellLiteral(origin)} -t ${shellLiteral(install.agent_token)}${shellMirror}`;
}, [install, installPlatform]);
+ const serverDialog = useDialog(editing !== null, () => setEditing(null));
+ const installDialog = useDialog(Boolean(installCommand), () => setInstall(null));
const toggleSelected = (id: string) => setSelectedIds((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id]);
const allSelected = servers.length > 0 && selectedIds.length === servers.length;
@@ -513,32 +517,33 @@ export function AdminPanel({
setNotice("");
setError("");
};
+ const siteLogoUrl = settings ? settings.logo_url : config.logo_url;
return (
{error ?
{error}
: notice ?
{notice}
: null}
{!authenticated ?
:
-

+
{config.site_name}
-
-
+
+
@@ -605,20 +610,20 @@ export function AdminPanel({
- ) : settings ? (
+ ) : settings && tab !== "about" ? (
) : tab === "about" ? (
-

NodeFlare基于 Rust、WebAssembly 和 Cloudflare Workers 的服务器监控
+
NodeFlare基于 Rust、WebAssembly 和 Cloudflare Workers 的服务器监控
版本v{VERSION}
@@ -674,8 +679,8 @@ export function AdminPanel({
}
- {editing ?
setEditing(null)}>
);
}
diff --git a/frontend/src/components/AlertRuleManager.tsx b/frontend/src/components/AlertRuleManager.tsx
index f190f50..879496c 100644
--- a/frontend/src/components/AlertRuleManager.tsx
+++ b/frontend/src/components/AlertRuleManager.tsx
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react";
import { api } from "../api";
import type { AdminServer, AlertRule, AlertRuleInput } from "../types";
import { Checkbox } from "./Checkbox";
+import { useDialog } from "./useDialog";
const emptyRule: AlertRuleInput = {
name: "",
@@ -32,6 +33,7 @@ export function AlertRuleManager({ servers, onError, onNotice }: {
const [form, setForm] = useState
(emptyRule);
const [allServers, setAllServers] = useState(true);
const [busy, setBusy] = useState(false);
+ const editorDialog = useDialog(editing !== null, () => setEditing(null));
const load = useCallback(async () => {
try { setRules((await api.alertRules()).rules); }
@@ -103,8 +105,8 @@ export function AlertRuleManager({ servers, onError, onNotice }: {
{!rules.length ? 尚未配置资源告警规则
: null}
- {editing ? setEditing(null)}>
event.stopPropagation()}>
-
+ {editing ?
+
diff --git a/frontend/src/components/LatencyManager.tsx b/frontend/src/components/LatencyManager.tsx
index cf2f018..a2e1025 100644
--- a/frontend/src/components/LatencyManager.tsx
+++ b/frontend/src/components/LatencyManager.tsx
@@ -3,6 +3,7 @@ import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react
import { api } from "../api";
import type { AdminServer, LatencyTask, LatencyTaskInput } from "../types";
import { Checkbox } from "./Checkbox";
+import { useDialog } from "./useDialog";
const emptyTask: LatencyTaskInput = {
name: "",
@@ -48,6 +49,7 @@ export function LatencyManager({
const [editing, setEditing] = useState(null);
const [form, setForm] = useState(emptyTask);
const [query, setQuery] = useState("");
+ const editorDialog = useDialog(editing !== null, () => setEditing(null));
const load = useCallback(async () => {
setBusy(true);
@@ -148,8 +150,8 @@ export function LatencyManager({
{busy && !tasks.length ? 正在读取延迟任务
: null}
- {editing ? setEditing(null)}>
event.stopPropagation()}>
-
+ {editing ?
+
diff --git a/frontend/src/components/SiteLogo.tsx b/frontend/src/components/SiteLogo.tsx
new file mode 100644
index 0000000..0ea125f
--- /dev/null
+++ b/frontend/src/components/SiteLogo.tsx
@@ -0,0 +1,15 @@
+import { useState, type ImgHTMLAttributes } from "react";
+
+const DEFAULT_LOGO = "/logo.svg";
+
+interface SiteLogoProps extends Omit, "src"> {
+ src?: string;
+}
+
+export function SiteLogo({ src = "", ...props }: SiteLogoProps) {
+ const requested = src.trim() || DEFAULT_LOGO;
+ const [failedSource, setFailedSource] = useState("");
+ const resolved = failedSource === requested ? DEFAULT_LOGO : requested;
+
+ return
setFailedSource(requested)} />;
+}
diff --git a/frontend/src/components/TurnstileWidget.tsx b/frontend/src/components/TurnstileWidget.tsx
index c7689d2..fac58d9 100644
--- a/frontend/src/components/TurnstileWidget.tsx
+++ b/frontend/src/components/TurnstileWidget.tsx
@@ -34,12 +34,14 @@ function loadScript() {
export function TurnstileWidget({
siteKey,
+ action,
theme,
resetKey,
onVerify,
onError,
}: {
siteKey: string;
+ action: "admin-login" | "public-dashboard";
theme: "light" | "dark";
resetKey?: number;
onVerify: (token: string) => void;
@@ -61,6 +63,7 @@ export function TurnstileWidget({
target.current.replaceChildren();
widgetId.current = window.turnstile.render(target.current, {
sitekey: siteKey,
+ action,
theme,
size: "flexible",
callback: (token: string) => {
@@ -79,7 +82,7 @@ export function TurnstileWidget({
if (widgetId.current && window.turnstile) window.turnstile.remove(widgetId.current);
widgetId.current = "";
};
- }, [siteKey, theme, resetKey]);
+ }, [action, siteKey, theme, resetKey]);
const testing = siteKey.startsWith("1x00000000000000000000");
return ;
diff --git a/frontend/src/components/useDialog.ts b/frontend/src/components/useDialog.ts
new file mode 100644
index 0000000..142bfcb
--- /dev/null
+++ b/frontend/src/components/useDialog.ts
@@ -0,0 +1,74 @@
+import { type MouseEvent as ReactMouseEvent, useCallback, useEffect, useRef } from "react";
+
+const FOCUSABLE_SELECTOR = [
+ "a[href]",
+ "button:not([disabled])",
+ "input:not([disabled])",
+ "select:not([disabled])",
+ "textarea:not([disabled])",
+ '[tabindex]:not([tabindex="-1"])',
+].join(",");
+
+function focusableElements(container: HTMLElement) {
+ return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR))
+ .filter((element) => !element.hidden && element.getClientRects().length > 0);
+}
+
+export function useDialog(open: boolean, onClose: () => void) {
+ const dialogRef = useRef(null);
+ const closeRef = useRef(onClose);
+ closeRef.current = onClose;
+
+ useEffect(() => {
+ if (!open) return;
+ const dialog = dialogRef.current;
+ if (!dialog) return;
+ const dialogElement: HTMLElement = dialog;
+ const previousFocus = document.activeElement instanceof HTMLElement
+ ? document.activeElement
+ : null;
+ const focusFrame = window.requestAnimationFrame(() => {
+ const initial = dialogElement.querySelector("[autofocus]")
+ ?? focusableElements(dialogElement)[0]
+ ?? dialogElement;
+ initial.focus();
+ });
+
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ closeRef.current();
+ return;
+ }
+ if (event.key !== "Tab") return;
+ const focusable = focusableElements(dialogElement);
+ if (!focusable.length) {
+ event.preventDefault();
+ dialogElement.focus();
+ return;
+ }
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (event.shiftKey && (document.activeElement === first || !dialogElement.contains(document.activeElement))) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && (document.activeElement === last || !dialogElement.contains(document.activeElement))) {
+ event.preventDefault();
+ first.focus();
+ }
+ }
+
+ document.addEventListener("keydown", handleKeyDown);
+ return () => {
+ window.cancelAnimationFrame(focusFrame);
+ document.removeEventListener("keydown", handleKeyDown);
+ if (previousFocus?.isConnected) previousFocus.focus();
+ };
+ }, [open]);
+
+ const onBackdropMouseDown = useCallback((event: ReactMouseEvent) => {
+ if (event.target === event.currentTarget) closeRef.current();
+ }, []);
+
+ return { dialogRef, onBackdropMouseDown };
+}
diff --git a/frontend/src/demo.ts b/frontend/src/demo.ts
index e13ee4e..6e645c5 100644
--- a/frontend/src/demo.ts
+++ b/frontend/src/demo.ts
@@ -4,6 +4,7 @@ export const demoConfig: Config = {
site_name: "NodeFlare",
site_description: "边缘节点与核心服务运行状态",
site_announcement: "",
+ logo_url: "",
favicon_url: "",
locale: "zh-CN",
public_dashboard: true,
diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css
index 7ce3956..4ac072f 100644
--- a/frontend/src/styles/admin.css
+++ b/frontend/src/styles/admin.css
@@ -55,7 +55,7 @@
.admin-login-theme:hover { background: var(--admin-hover); }
.login-form { display: flex; width: min(410px, calc(100% - 32px)); align-items: stretch; flex-direction: column; gap: 16px; border: 1px solid var(--admin-border); border-radius: 8px; padding: 32px; background: var(--admin-surface); }
-.login-form img { width: 44px; height: 44px; align-self: center; margin-bottom: 8px; }
+.login-form img { width: 44px; height: 44px; align-self: center; margin-bottom: 8px; object-fit: contain; }
.login-copy { text-align: center; }
.login-copy h1 { margin: 0; color: var(--admin-text); font-size: 22px; }
.login-copy p { margin: 5px 0 0; color: var(--admin-muted); font-size: 13px; }
@@ -68,7 +68,7 @@
.admin-shell { display: grid; width: 100%; max-width: 100vw; height: 100dvh; min-width: 0; min-height: 0; overflow: hidden; border: 0; border-radius: 0; background: var(--admin-surface); grid-template-rows: 64px minmax(0, 1fr); }
.admin-topbar { z-index: 2; display: flex; width: 100%; max-width: 100vw; min-width: 0; align-items: center; justify-content: space-between; gap: 24px; overflow: hidden; border-bottom: 1px solid var(--admin-border); padding: 0 24px; background: var(--admin-surface); }
.admin-brand { display: flex; min-width: 0; align-items: center; gap: 10px; padding: 0; color: var(--admin-text); text-align: left; text-decoration: none; }
-.admin-brand img { width: 32px; height: 32px; flex: 0 0 auto; }
+.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; }
diff --git a/frontend/src/styles/dashboard.css b/frontend/src/styles/dashboard.css
index a68e147..04f2271 100644
--- a/frontend/src/styles/dashboard.css
+++ b/frontend/src/styles/dashboard.css
@@ -106,6 +106,7 @@
.error-band button { font-weight: 700; }
.empty-state, .chart-loading { display: flex; min-height: 340px; align-items: center; justify-content: center; flex-direction: column; gap: 16px; color: var(--muted-foreground); font-size: 13px; }
.verification-gate { display: flex; width: min(430px, 100%); min-height: 380px; align-items: center; justify-content: center; flex-direction: column; gap: 14px; margin: 12vh auto 0; border: 1px solid var(--border); border-radius: 8px; padding: 30px; background: var(--card); box-shadow: var(--shadow); text-align: center; }
+.verification-gate > img, .dashboard-login-gate > img { object-fit: contain; }
.verification-gate h1 { margin: 0; font-size: 19px; }
.verification-gate p { margin: 4px 0 0; color: var(--muted-foreground); font-size: 11px; }
.dashboard-login-gate { display: flex; width: min(420px, 100%); align-items: center; flex-direction: column; gap: 16px; margin: 9vh auto 0; border-radius: 8px; padding: 28px; }
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 71ae026..7fd8a3b 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -2,6 +2,7 @@ export interface Config {
site_name: string;
site_description: string;
site_announcement: string;
+ logo_url: string;
favicon_url: string;
locale: "zh-CN" | "en";
public_dashboard: boolean;
diff --git a/migrations/initial_01.sql b/migrations/initial_01.sql
index e4fa88b..790c80e 100644
--- a/migrations/initial_01.sql
+++ b/migrations/initial_01.sql
@@ -113,6 +113,7 @@ INSERT INTO settings(id, value, updated_at) VALUES (
'{
"site_description": "轻量、实时的服务器运行状态",
"site_announcement": "",
+ "logo_url": "",
"favicon_url": "",
"locale": "zh-CN",
"public_dashboard": "true",
diff --git a/package.json b/package.json
index 1faee20..36fc5e4 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,8 @@
"test:smoke": "sh scripts/smoke-test.sh"
},
"devDependencies": {
- "wrangler": "^4.123.0"
+ "wrangler": "^4.123.0",
+ "ws": "^8.21.0"
},
"overrides": {
"nanoid": "3.3.18"
diff --git a/scripts/build-frontend.sh b/scripts/build-frontend.sh
index a77e2bd..677f7a9 100644
--- a/scripts/build-frontend.sh
+++ b/scripts/build-frontend.sh
@@ -5,7 +5,7 @@ script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
root_dir=$(CDPATH= cd -- "$script_dir/.." && pwd)
NODEFLARE_VERSION=$(sh "$script_dir/resolve-version.sh")
VITE_NODEFLARE_VERSION=$NODEFLARE_VERSION
-export NODEFLARE_VERSION VITE_NODEFLARE_VERSION
+export VITE_NODEFLARE_VERSION
cd "$root_dir"
bun run --cwd frontend build
diff --git a/scripts/build-worker.sh b/scripts/build-worker.sh
index d8b44d5..0dd9ce0 100644
--- a/scripts/build-worker.sh
+++ b/scripts/build-worker.sh
@@ -4,8 +4,7 @@ set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
root_dir=$(CDPATH= cd -- "$script_dir/.." && pwd)
NODEFLARE_VERSION=$(sh "$script_dir/resolve-version.sh")
-VITE_NODEFLARE_VERSION=$NODEFLARE_VERSION
-export NODEFLARE_VERSION VITE_NODEFLARE_VERSION
+export NODEFLARE_VERSION
cd "$root_dir"
current_build_key=$(sh scripts/build-key.sh worker 2>/dev/null || true)
diff --git a/scripts/resolve-version.sh b/scripts/resolve-version.sh
index a936c7d..650b047 100755
--- a/scripts/resolve-version.sh
+++ b/scripts/resolve-version.sh
@@ -11,6 +11,17 @@ fi
if [ -z "$raw" ]; then
raw=$(git -C "$root_dir" describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null || true)
fi
+if [ -z "$raw" ]; then
+ remote_version=$(
+ git -C "$root_dir" ls-remote --tags --refs origin \
+ 'refs/tags/v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null |
+ awk '{ sub(/^refs\/tags\//, "", $2); print $2 }' |
+ grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
+ sort -V |
+ tail -n 1
+ ) || remote_version=
+ raw=$remote_version
+fi
if [ -z "$raw" ]; then
raw=$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",*$/\1/p' "$root_dir/package.json" | sed -n '1p')
fi
diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh
index 6fb430e..f684192 100644
--- a/scripts/smoke-test.sh
+++ b/scripts/smoke-test.sh
@@ -109,6 +109,8 @@ server_json=$(request -H "Authorization: Bearer $admin_token" \
"$MONITOR_BASE_URL/api/admin/servers")
server_id=$(printf '%s' "$server_json" | jq -er '.id')
agent_token=$(printf '%s' "$server_json" | jq -er '.agent_token')
+MONITOR_BASE_URL="$MONITOR_BASE_URL" MONITOR_ADMIN_TOKEN="$admin_token" \
+ MONITOR_AGENT_TOKEN="$agent_token" node scripts/websocket-smoke.mjs
token_without_admin_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
"$MONITOR_BASE_URL/api/admin/servers/$server_id/token")
[ "$token_without_admin_status" = "401" ]
diff --git a/scripts/websocket-smoke.mjs b/scripts/websocket-smoke.mjs
new file mode 100644
index 0000000..d8e1475
--- /dev/null
+++ b/scripts/websocket-smoke.mjs
@@ -0,0 +1,82 @@
+import WebSocket from "ws";
+
+const baseUrl = process.env.MONITOR_BASE_URL;
+const adminToken = process.env.MONITOR_ADMIN_TOKEN;
+const agentToken = process.env.MONITOR_AGENT_TOKEN;
+
+if (!baseUrl || !adminToken || !agentToken) {
+ throw new Error("MONITOR_BASE_URL, MONITOR_ADMIN_TOKEN and MONITOR_AGENT_TOKEN are required");
+}
+
+function websocketUrl(path) {
+ const url = new URL(path, baseUrl);
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
+ return url.toString();
+}
+
+function openSocket(path, token) {
+ return new Promise((resolve, reject) => {
+ const socket = new WebSocket(websocketUrl(path), {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ const timer = setTimeout(() => {
+ socket.terminate();
+ reject(new Error(`${path} WebSocket handshake timed out`));
+ }, 5_000);
+ socket.once("open", () => {
+ clearTimeout(timer);
+ resolve(socket);
+ });
+ socket.once("unexpected-response", (_request, response) => {
+ clearTimeout(timer);
+ reject(new Error(`${path} WebSocket returned HTTP ${response.statusCode}`));
+ });
+ socket.once("error", (error) => {
+ clearTimeout(timer);
+ reject(error);
+ });
+ });
+}
+
+function waitForMessage(socket, expected) {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error(`WebSocket did not receive ${expected}`)), 5_000);
+ socket.once("message", (data) => {
+ clearTimeout(timer);
+ const message = data.toString();
+ if (message !== expected) reject(new Error(`Expected ${expected}, received ${message}`));
+ else resolve();
+ });
+ socket.once("error", (error) => {
+ clearTimeout(timer);
+ reject(error);
+ });
+ });
+}
+
+function closeSocket(socket) {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ socket.terminate();
+ reject(new Error("WebSocket close timed out"));
+ }, 5_000);
+ socket.once("close", () => {
+ clearTimeout(timer);
+ resolve();
+ });
+ socket.once("error", (error) => {
+ clearTimeout(timer);
+ reject(error);
+ });
+ socket.close();
+ });
+}
+
+const dashboard = await openSocket("/api/ws", adminToken);
+const pong = waitForMessage(dashboard, "pong");
+dashboard.send("ping");
+await pong;
+await closeSocket(dashboard);
+
+const agent = await openSocket("/api/agent/live", agentToken);
+await closeSocket(agent);
diff --git a/src/db.rs b/src/db.rs
index 5af9635..cae02ba 100644
--- a/src/db.rs
+++ b/src/db.rs
@@ -1,4 +1,3 @@
-use std::cell::RefCell;
use std::collections::HashMap;
use serde::{Deserialize, Serialize, Serializer};
@@ -71,30 +70,6 @@ FROM servers s
LEFT JOIN latest_state m ON m.server_id = s.id
"#;
-const SETTINGS_CACHE_TTL_SECONDS: i64 = 30;
-
-#[derive(Clone, PartialEq)]
-struct SettingsCacheKey {
- default_name: String,
- default_threshold: i64,
- default_retention: i64,
- default_username: String,
-}
-
-struct SettingsCacheEntry {
- key: SettingsCacheKey,
- expires_at: i64,
- settings: SettingsView,
-}
-
-thread_local! {
- static SETTINGS_CACHE: RefCell