整体优化
This commit is contained in:
1 parent
467961eedf
commit
e153d0ad10
30 files changed
+554
-317
No files matched your search
+4
-88
@@ -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));
|
||||
|
||||
@@ -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() {
|
||||
<div className="theme-background" style={background ? { backgroundImage: `url(${JSON.stringify(background)})`, opacity: 1 } : undefined} />
|
||||
<header className="site-header">
|
||||
<div className="container header-inner">
|
||||
<div className="brand"><img src="/logo.svg" alt="" width="36" height="36" /><strong>{config.site_name}</strong></div>
|
||||
<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(config.locale, "浅色主题", "Light theme") : ui(config.locale, "深色主题", "Dark theme")}>{dark ? <Sun size={18} /> : <Moon size={18} />}</button>
|
||||
<button className="icon-btn" onClick={openAdmin} title={ui(config.locale, "进入后台", "Administration")}><UserCircle size={18} /></button>
|
||||
@@ -458,21 +459,21 @@ export default function App() {
|
||||
{config.site_announcement ? <div className="site-announcement"><Megaphone size={16} /><span>{config.site_announcement}</span></div> : null}
|
||||
{needsLogin ? (
|
||||
<section className="dashboard-login-gate glass-panel">
|
||||
<img src="/logo.svg" alt="" width="46" height="46" />
|
||||
<SiteLogo src={config.logo_url} alt="" width="46" height="46" />
|
||||
<div className="dashboard-login-copy"><h1>{ui(config.locale, "登录仪表盘", "Sign in to dashboard")}</h1><p>{ui(config.locale, "此仪表盘仅限登录后访问", "This dashboard requires an administrator sign-in")}</p></div>
|
||||
<form className="dashboard-login-form" onSubmit={(event) => void loginDashboard(event)}>
|
||||
<label><span>{ui(config.locale, "用户名", "Username")}</span><input autoFocus autoComplete="username" value={loginUsername} onChange={(event) => setLoginUsername(event.target.value)} required /></label>
|
||||
<label><span>{ui(config.locale, "密码", "Password")}</span><input type="password" autoComplete="current-password" value={loginPassword} onChange={(event) => setLoginPassword(event.target.value)} required /></label>
|
||||
{config.turnstile_login_enabled ? <div className="dashboard-login-turnstile"><TurnstileWidget siteKey={config.turnstile_site_key} theme={dark ? "dark" : "light"} resetKey={loginTurnstileReset} onVerify={setLoginTurnstileToken} onError={setError} /></div> : null}
|
||||
{config.turnstile_login_enabled ? <div className="dashboard-login-turnstile"><TurnstileWidget siteKey={config.turnstile_site_key} action="admin-login" theme={dark ? "dark" : "light"} resetKey={loginTurnstileReset} onVerify={setLoginTurnstileToken} onError={setError} /></div> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<button className="primary-btn dashboard-login-submit" disabled={loginBusy || (config.turnstile_login_enabled && !loginTurnstileToken)} type="submit"><KeyRound size={16} />{loginBusy ? ui(config.locale, "登录中", "Signing in") : ui(config.locale, "登录", "Sign in")}</button>
|
||||
</form>
|
||||
</section>
|
||||
) : needsVerification ? (
|
||||
<section className="verification-gate">
|
||||
<img src="/logo.svg" alt="" width="52" height="52" />
|
||||
<SiteLogo src={config.logo_url} alt="" width="52" height="52" />
|
||||
<div><h1>{ui(config.locale, "访问验证", "Access verification")}</h1><p>{config.site_name}</p></div>
|
||||
{config.turnstile_site_key ? <TurnstileWidget siteKey={config.turnstile_site_key} theme={dark ? "dark" : "light"} onVerify={(token) => void verifyDashboard(token)} onError={setError} /> : <p className="form-error">{ui(config.locale, "Turnstile 尚未正确配置", "Turnstile is not configured")}</p>}
|
||||
{config.turnstile_site_key ? <TurnstileWidget siteKey={config.turnstile_site_key} action="public-dashboard" theme={dark ? "dark" : "light"} onVerify={(token) => void verifyDashboard(token)} onError={setError} /> : <p className="form-error">{ui(config.locale, "Turnstile 尚未正确配置", "Turnstile is not configured")}</p>}
|
||||
{verificationBusy ? <span className="verification-status">{ui(config.locale, "正在验证", "Verifying")}</span> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
</section>
|
||||
|
||||
@@ -43,6 +43,15 @@ function AdminApp() {
|
||||
useEffect(() => {
|
||||
document.title = config ? `管理面板 · ${config.site_name}` : "管理面板";
|
||||
}, [config]);
|
||||
useEffect(() => {
|
||||
let link = document.querySelector<HTMLLinkElement>('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 <div className={`admin-loading ${dark ? "admin-dark" : ""}`}><span>{error}</span><button className="secondary-btn" onClick={() => void loadConfig()}>重试</button></div>;
|
||||
if (!config) return <div className={`admin-loading ${dark ? "admin-dark" : ""}`}>正在加载管理面板</div>;
|
||||
|
||||
@@ -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<HTMLFormElement>(editing !== null, () => setEditing(null));
|
||||
const installDialog = useDialog<HTMLElement>(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 (
|
||||
<div className={`admin-page ${dark ? "admin-dark" : ""}`}>
|
||||
{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}
|
||||
{!authenticated ? <div className="admin-login-stage">
|
||||
<button className="admin-back" type="button" onClick={onClose}><ArrowLeft size={14} />返回仪表盘</button>
|
||||
<button className="admin-back" type="button" onClick={onClose}><ArrowLeft size={14} />返回</button>
|
||||
<button className="admin-login-theme" type="button" onClick={onToggleTheme} title={dark ? "切换浅色主题" : "切换深色主题"}>{dark ? <Sun size={16} /> : <Moon size={16} />}</button>
|
||||
<form className="login-form glass-panel" onSubmit={login}>
|
||||
<img src="/logo.svg" alt="" width="48" height="48" />
|
||||
<SiteLogo src={siteLogoUrl} alt="" width="48" height="48" />
|
||||
<div className="login-copy"><h1>管理员登录</h1></div>
|
||||
<label><span>用户名</span><input autoFocus type="text" autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} required /></label>
|
||||
<label><span>密码</span><input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} required /></label>
|
||||
{config.turnstile_login_enabled || config.turnstile_enabled ? <div className="login-turnstile"><TurnstileWidget siteKey={config.turnstile_site_key} theme={dark ? "dark" : "light"} resetKey={turnstileReset} onVerify={setTurnstileToken} onError={setError} /></div> : null}
|
||||
{config.turnstile_login_enabled || config.turnstile_enabled ? <div className="login-turnstile"><TurnstileWidget siteKey={config.turnstile_site_key} action="admin-login" theme={dark ? "dark" : "light"} resetKey={turnstileReset} onVerify={setTurnstileToken} onError={setError} /></div> : null}
|
||||
<button className="primary-btn login-submit" disabled={busy || ((config.turnstile_login_enabled || config.turnstile_enabled) && !turnstileToken)} type="submit"><KeyRound size={16} />{busy ? "验证中" : "登录"}</button>
|
||||
</form>
|
||||
</div> : <section className="admin-shell" aria-label="管理面板">
|
||||
<header className="admin-topbar">
|
||||
<div className="admin-brand">
|
||||
<img src="/logo.svg" alt="" width="36" height="36" />
|
||||
<SiteLogo src={siteLogoUrl} alt="" width="36" height="36" />
|
||||
<strong>{config.site_name}</strong>
|
||||
</div>
|
||||
<div className="admin-topbar-actions">
|
||||
<button type="button" onClick={onToggleTheme} title={dark ? "切换浅色主题" : "切换深色主题"} aria-label={dark ? "切换浅色主题" : "切换深色主题"}>{dark ? <Sun size={17} /> : <Moon size={17} />}</button>
|
||||
<button type="button" onClick={onClose} title="返回前台" aria-label="返回前台"><ArrowLeft size={17} />返回前台</button>
|
||||
<button type="button" onClick={() => void logout()} title="退出登录" aria-label="退出登录"><LogOut size={17} />退出登录</button>
|
||||
<button type="button" onClick={onClose} title="主页" aria-label="主页"><ArrowLeft size={17} />主页</button>
|
||||
<button type="button" onClick={() => void logout()} title="退出" aria-label="退出"><LogOut size={17} />退出</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -605,20 +610,20 @@ export function AdminPanel({
|
||||
<div className="form-actions"><button className="primary-btn" disabled={busy}><Plus size={16} />添加主题</button></div>
|
||||
</form>
|
||||
</div>
|
||||
) : settings ? (
|
||||
) : settings && tab !== "about" ? (
|
||||
<form className="settings-form" onSubmit={saveSite}>
|
||||
{tab === "appearance" ? <>
|
||||
<div className="section-title"><Eye size={15} />外观与展示</div>
|
||||
<div className="form-grid"><label><span>站点名称</span><input required value={settings.site_name} onChange={(event) => updateSettings("site_name", event.target.value)} /></label><label><span>站点描述</span><input value={settings.site_description} onChange={(event) => updateSettings("site_description", event.target.value)} /></label></div>
|
||||
<label><span>站点公告</span><textarea rows={3} maxLength={1000} value={settings.site_announcement} onChange={(event) => updateSettings("site_announcement", event.target.value)} /></label>
|
||||
<div className="form-grid"><label><span>界面语言</span><select value={settings.locale} onChange={(event) => updateSettings("locale", event.target.value as Settings["locale"])}><option value="zh-CN">简体中文</option><option value="en">English</option></select></label><label><span>站点图标地址</span><input type="url" value={settings.favicon_url} onChange={(event) => updateSettings("favicon_url", event.target.value)} placeholder="https://example.com/favicon.png" /></label></div>
|
||||
<div className="form-grid three"><label><span>界面语言</span><select value={settings.locale} onChange={(event) => updateSettings("locale", event.target.value as Settings["locale"])}><option value="zh-CN">简体中文</option><option value="en">English</option></select></label><label><span>站点 Logo 地址</span><input type="url" maxLength={1000} value={settings.logo_url} onChange={(event) => updateSettings("logo_url", event.target.value)} placeholder="https://example.com/logo.svg" /></label><label><span>浏览器图标地址</span><input type="url" maxLength={1000} value={settings.favicon_url} onChange={(event) => updateSettings("favicon_url", event.target.value)} placeholder="https://example.com/favicon.png" /></label></div>
|
||||
<div className="form-grid"><label><span>离线判定(秒)</span><input type="number" min="30" max="3600" value={settings.offline_threshold_seconds} onChange={(event) => updateSettings("offline_threshold_seconds", Number(event.target.value))} /></label><label><span>历史保留(天)</span><input type="number" min="1" max="365" value={settings.history_retention_days} onChange={(event) => updateSettings("history_retention_days", Number(event.target.value))} /></label></div>
|
||||
</> : null}
|
||||
|
||||
{tab === "themeSettings" ? <>
|
||||
<div className="section-title"><SlidersHorizontal size={15} />通用主题设置</div>
|
||||
<div className="form-grid"><label><span>默认主题</span><select value={settings.default_theme} onChange={(event) => updateSettings("default_theme", event.target.value as Settings["default_theme"])}><option value="system">跟随系统</option><option value="light">浅色</option><option value="dark">深色</option></select></label><label><span>背景图地址</span><input type="text" value={settings.background_url} onChange={(event) => updateSettings("background_url", event.target.value)} /></label></div>
|
||||
<p className="settings-hint">背景图可用 | 分隔浅色和深色地址,例如 light.jpg | dark.jpg。</p>
|
||||
<div className="form-grid"><label><span>默认主题</span><select value={settings.default_theme} onChange={(event) => updateSettings("default_theme", event.target.value as Settings["default_theme"])}><option value="system">跟随系统</option><option value="light">浅色</option><option value="dark">深色</option></select></label><label><span>背景图地址</span><input type="text" maxLength={1000} value={settings.background_url} onChange={(event) => updateSettings("background_url", event.target.value)} placeholder="https://example.com/light.webp | https://example.com/dark.webp" /></label></div>
|
||||
<p className="settings-hint">仅支持 HTTPS 地址;浅色和深色背景可用 | 分隔。</p>
|
||||
<div className="section-subtitle">当前主题选项</div><div className="theme-option-grid"><Toggle label="公开仪表盘" checked={settings.public_dashboard} onChange={(value) => updateSettings("public_dashboard", value)} />{themeSettingsSchema?.settings.map((field) => <ThemeOption key={field.key} field={field} value={settings.theme_options[field.key] ?? field.default} onChange={(value) => updateThemeOption(field.key, value)} />)}</div>
|
||||
<div className="section-subtitle">公开界面元素</div>
|
||||
<div className="settings-toggles"><Toggle label="显示搜索" checked={settings.show_search} onChange={(value) => updateSettings("show_search", value)} /><Toggle label="显示分组" checked={settings.show_groups} onChange={(value) => updateSettings("show_groups", value)} /><Toggle label="总览统计" checked={settings.show_stats} onChange={(value) => updateSettings("show_stats", value)} /><Toggle label="资产统计" checked={settings.show_assets} onChange={(value) => updateSettings("show_assets", value)} /><Toggle label="累计流量" checked={settings.show_traffic} onChange={(value) => updateSettings("show_traffic", value)} /><Toggle label="实时网速" checked={settings.show_speed} onChange={(value) => updateSettings("show_speed", value)} /><Toggle label="价格信息" checked={settings.show_price} onChange={(value) => updateSettings("show_price", value)} /><Toggle label="到期信息" checked={settings.show_expiry} onChange={(value) => updateSettings("show_expiry", value)} /><Toggle label="延迟与丢包" checked={settings.show_latency} onChange={(value) => updateSettings("show_latency", value)} /><Toggle label="在线时长" checked={settings.show_uptime} onChange={(value) => updateSettings("show_uptime", value)} /></div>
|
||||
@@ -658,11 +663,11 @@ export function AdminPanel({
|
||||
<p className="settings-hint">清空历史不会删除节点、密钥或最新状态。</p>
|
||||
</> : null}
|
||||
|
||||
{tab !== "data" && tab !== "about" ? <div className="form-actions"><button className="primary-btn" disabled={busy}><Save size={16} />保存{tab === "security" ? "账号与安全设置" : "设置"}</button></div> : null}
|
||||
{tab !== "data" ? <div className="form-actions"><button className="primary-btn" disabled={busy}><Save size={16} />保存{tab === "security" ? "账号与安全设置" : "设置"}</button></div> : null}
|
||||
</form>
|
||||
) : tab === "about" ? (
|
||||
<div className="admin-section about-page">
|
||||
<div className="about-brand"><img src="/logo.svg" alt="" width="52" height="52" /><div><strong>NodeFlare</strong><small>基于 Rust、WebAssembly 和 Cloudflare Workers 的服务器监控</small></div></div>
|
||||
<div className="about-brand"><SiteLogo alt="" width="52" height="52" /><div><strong>NodeFlare</strong><small>基于 Rust、WebAssembly 和 Cloudflare Workers 的服务器监控</small></div></div>
|
||||
<div className="about-rows">
|
||||
<div className="about-row"><span>版本</span><strong>v{VERSION}</strong></div>
|
||||
<div className="about-row"><span>项目地址</span><a href="https://github.com/imengying/NodeFlare" target="_blank" rel="noreferrer">github.com/imengying/NodeFlare<ExternalLink size={13} /></a></div>
|
||||
@@ -674,8 +679,8 @@ export function AdminPanel({
|
||||
</div>
|
||||
</section>}
|
||||
|
||||
{editing ? <div className="submodal-backdrop" role="presentation" onMouseDown={() => setEditing(null)}><form className="editor-modal glass-panel" onSubmit={saveServer} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<header><div><span className="eyebrow">节点配置</span><h3>{editing === "new" ? "添加节点" : `编辑 · ${editing.name}`}</h3></div></header>
|
||||
{editing ? <div className="submodal-backdrop" role="presentation" onMouseDown={serverDialog.onBackdropMouseDown}><form ref={serverDialog.dialogRef} className="editor-modal glass-panel" role="dialog" aria-modal="true" aria-labelledby="server-editor-title" tabIndex={-1} onSubmit={saveServer}>
|
||||
<header><div><span className="eyebrow">节点配置</span><h3 id="server-editor-title">{editing === "new" ? "添加节点" : `编辑 · ${editing.name}`}</h3></div></header>
|
||||
<div className="form-grid"><label><span>名称</span><input autoFocus required value={form.name} onChange={(event) => updateForm("name", event.target.value)} /></label><label><span>地区代码</span><input maxLength={16} placeholder="CN / JP / DE" value={form.region} onChange={(event) => updateForm("region", event.target.value.toUpperCase())} /></label><label><span>分组</span><input value={form.group_name} onChange={(event) => updateForm("group_name", event.target.value)} /></label><label><span>标签</span><input placeholder="主力, 线路:BGP" value={form.tags} onChange={(event) => updateForm("tags", event.target.value)} /></label></div>
|
||||
<div className="form-grid three"><label><span>流量限额(GB)</span><input min="0" type="number" value={Math.round(form.traffic_limit / 1024 ** 3)} onChange={(event) => updateForm("traffic_limit", Number(event.target.value) * 1024 ** 3)} /></label><label><span>流量口径</span><select value={form.traffic_limit_type} onChange={(event) => updateForm("traffic_limit_type", event.target.value as ServerInput["traffic_limit_type"])}><option value="sum">上下行合计</option><option value="max">取较大值</option><option value="min">取较小值</option><option value="up">仅上行</option><option value="down">仅下行</option></select></label><label><span>流量重置日</span><input min="1" max="31" type="number" value={form.reset_day} onChange={(event) => updateForm("reset_day", Number(event.target.value))} /></label></div>
|
||||
<div className="form-grid three"><label><span>价格(0 隐藏,-1 免费)</span><input min="-1" step="0.01" type="number" value={form.price} onChange={(event) => updateForm("price", Number(event.target.value))} /></label><label><span>币种</span><select value={form.currency} onChange={(event) => updateForm("currency", event.target.value)}>{ASSET_CURRENCIES.map((code) => <option key={code}>{code}</option>)}</select></label><label><span>计费周期(天)</span><input min="1" max="3650" type="number" value={form.billing_cycle} onChange={(event) => updateForm("billing_cycle", Number(event.target.value))} /></label></div>
|
||||
@@ -685,7 +690,7 @@ export function AdminPanel({
|
||||
<div className="form-actions"><button type="button" className="secondary-btn" onClick={() => setEditing(null)}>取消</button><button className="primary-btn" disabled={busy}><Save size={16} />保存节点</button></div>
|
||||
</form></div> : null}
|
||||
|
||||
{installCommand ? <div className="submodal-backdrop" role="presentation" onMouseDown={() => setInstall(null)}><section className="install-modal glass-panel" onMouseDown={(event) => event.stopPropagation()}><header><div><span className="eyebrow">Agent 部署</span><h3>安装命令</h3></div><div className="segmented install-platform" aria-label="Agent 平台"><button type="button" className={installPlatform === "linux" ? "active" : ""} onClick={() => setInstallPlatform("linux")}>Linux</button><button type="button" className={installPlatform === "windows" ? "active" : ""} onClick={() => setInstallPlatform("windows")}>Windows</button><button type="button" className={installPlatform === "macos" ? "active" : ""} onClick={() => setInstallPlatform("macos")}>macOS ARM</button></div></header><div className="install-list"><pre>{installCommand}</pre></div><div className="form-actions"><button className="secondary-btn" type="button" onClick={() => setInstall(null)}>关闭</button><button className="primary-btn" type="button" onClick={() => void copyInstallCommand()}><Copy size={16} />复制</button></div></section></div> : null}
|
||||
{installCommand ? <div className="submodal-backdrop" role="presentation" onMouseDown={installDialog.onBackdropMouseDown}><section ref={installDialog.dialogRef} className="install-modal glass-panel" role="dialog" aria-modal="true" aria-labelledby="install-dialog-title" tabIndex={-1}><header><div><span className="eyebrow">Agent 部署</span><h3 id="install-dialog-title">安装命令</h3></div><div className="segmented install-platform" aria-label="Agent 平台"><button type="button" className={installPlatform === "linux" ? "active" : ""} onClick={() => setInstallPlatform("linux")}>Linux</button><button type="button" className={installPlatform === "windows" ? "active" : ""} onClick={() => setInstallPlatform("windows")}>Windows</button><button type="button" className={installPlatform === "macos" ? "active" : ""} onClick={() => setInstallPlatform("macos")}>macOS ARM</button></div></header><div className="install-list"><pre>{installCommand}</pre></div><div className="form-actions"><button className="secondary-btn" type="button" onClick={() => setInstall(null)}>关闭</button><button className="primary-btn" type="button" onClick={() => void copyInstallCommand()}><Copy size={16} />复制</button></div></section></div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<AlertRuleInput>(emptyRule);
|
||||
const [allServers, setAllServers] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const editorDialog = useDialog<HTMLElement>(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 ? <div className="list-empty">尚未配置资源告警规则</div> : null}
|
||||
</div>
|
||||
|
||||
{editing ? <div className="submodal-backdrop" role="presentation" onMouseDown={() => setEditing(null)}><section className="alert-rule-editor glass-panel" onMouseDown={(event) => event.stopPropagation()}>
|
||||
<header><div><span className="eyebrow">通知规则</span><h3>{editing === "new" ? "新建资源告警" : "编辑资源告警"}</h3></div></header>
|
||||
{editing ? <div className="submodal-backdrop" role="presentation" onMouseDown={editorDialog.onBackdropMouseDown}><section ref={editorDialog.dialogRef} className="alert-rule-editor glass-panel" role="dialog" aria-modal="true" aria-labelledby="alert-rule-editor-title" tabIndex={-1}>
|
||||
<header><div><span className="eyebrow">通知规则</span><h3 id="alert-rule-editor-title">{editing === "new" ? "新建资源告警" : "编辑资源告警"}</h3></div></header>
|
||||
<label><span>规则名称</span><input required maxLength={80} value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} /></label>
|
||||
<div className="form-grid"><label><span>监控指标</span><select value={form.metric} onChange={(event) => setForm((current) => ({ ...current, metric: event.target.value as AlertRuleInput["metric"] }))}>{Object.entries(metricLabels).map(([value, label]) => <option value={value} key={value}>{label}</option>)}</select></label><label><span>阈值({unit})</span><input required type="number" min="0.01" max={unit === "%" ? 100 : 1000000} step="0.01" value={form.threshold} onChange={(event) => setForm((current) => ({ ...current, threshold: Number(event.target.value) }))} /></label></div>
|
||||
<div className="form-grid"><label><span>时间窗口(分钟)</span><input required type="number" min="1" max="1440" value={form.duration_minutes} onChange={(event) => setForm((current) => ({ ...current, duration_minutes: Number(event.target.value) }))} /></label><label><span>判断方式</span><select value={form.aggregation} onChange={(event) => setForm((current) => ({ ...current, aggregation: event.target.value as AlertRuleInput["aggregation"] }))}><option value="average">窗口平均值</option><option value="continuous">窗口内持续超限</option></select></label></div>
|
||||
|
||||
@@ -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<LatencyTask | "new" | null>(null);
|
||||
const [form, setForm] = useState<LatencyTaskInput>(emptyTask);
|
||||
const [query, setQuery] = useState("");
|
||||
const editorDialog = useDialog<HTMLFormElement>(editing !== null, () => setEditing(null));
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setBusy(true);
|
||||
@@ -148,8 +150,8 @@ export function LatencyManager({
|
||||
{busy && !tasks.length ? <div className="list-empty">正在读取延迟任务</div> : null}
|
||||
</div>
|
||||
|
||||
{editing ? <div className="submodal-backdrop" role="presentation" onMouseDown={() => setEditing(null)}><form className="latency-editor glass-panel" onSubmit={save} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<header><div><span className="eyebrow">延迟检测</span><h3>{editing === "new" ? "添加任务" : `编辑 · ${editing.name}`}</h3></div></header>
|
||||
{editing ? <div className="submodal-backdrop" role="presentation" onMouseDown={editorDialog.onBackdropMouseDown}><form ref={editorDialog.dialogRef} className="latency-editor glass-panel" role="dialog" aria-modal="true" aria-labelledby="latency-editor-title" tabIndex={-1} onSubmit={save}>
|
||||
<header><div><span className="eyebrow">延迟检测</span><h3 id="latency-editor-title">{editing === "new" ? "添加任务" : `编辑 · ${editing.name}`}</h3></div></header>
|
||||
<div className="form-grid"><label><span>名称</span><input autoFocus required maxLength={80} value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} /></label><label><span>类型</span><div className="segmented task-type-control"><button type="button" className={form.task_type === "icmp" ? "active" : ""} onClick={() => setForm((current) => ({ ...current, task_type: "icmp", port: null }))}>ICMP</button><button type="button" className={form.task_type === "tcp" ? "active" : ""} onClick={() => setForm((current) => ({ ...current, task_type: "tcp", port: current.port ?? 80 }))}>TCP</button></div></label></div>
|
||||
<div className="form-grid"><label><span>节点</span><input required maxLength={50} value={form.target} onChange={(event) => setForm((current) => ({ ...current, target: event.target.value }))} placeholder={form.task_type === "tcp" ? "example.com" : "1.1.1.1"} /></label>{form.task_type === "tcp" ? <label><span>端口</span><input type="number" min="1" max="65535" required value={form.port ?? ""} onChange={(event) => setForm((current) => ({ ...current, port: event.target.value ? Number(event.target.value) : null }))} placeholder="80" /></label> : <div />}</div>
|
||||
<label><span>检测间隔(秒)</span><input type="number" min="30" max="3600" required value={form.interval_seconds} onChange={(event) => setForm((current) => ({ ...current, interval_seconds: Number(event.target.value) }))} /></label>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useState, type ImgHTMLAttributes } from "react";
|
||||
|
||||
const DEFAULT_LOGO = "/logo.svg";
|
||||
|
||||
interface SiteLogoProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, "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 <img {...props} src={resolved} onError={() => setFailedSource(requested)} />;
|
||||
}
|
||||
@@ -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 <div className={`turnstile-widget${verified ? " verified" : ""}${testing ? " testing" : ""}`} ref={target} />;
|
||||
|
||||
@@ -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<HTMLElement>(FOCUSABLE_SELECTOR))
|
||||
.filter((element) => !element.hidden && element.getClientRects().length > 0);
|
||||
}
|
||||
|
||||
export function useDialog<T extends HTMLElement>(open: boolean, onClose: () => void) {
|
||||
const dialogRef = useRef<T>(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<HTMLElement>("[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<HTMLDivElement>) => {
|
||||
if (event.target === event.currentTarget) closeRef.current();
|
||||
}, []);
|
||||
|
||||
return { dialogRef, onBackdropMouseDown };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" ]
|
||||
|
||||
@@ -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);
|
||||
@@ -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<Option<SettingsCacheEntry>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn invalidate_settings_cache() {
|
||||
SETTINGS_CACHE.with(|cache| *cache.borrow_mut() = None);
|
||||
}
|
||||
|
||||
pub const SECRET_MASK: &str = "********";
|
||||
|
||||
fn secret_for_api(value: &str) -> &str {
|
||||
@@ -347,6 +322,7 @@ pub struct SettingsView {
|
||||
pub site_name: String,
|
||||
pub site_description: String,
|
||||
pub site_announcement: String,
|
||||
pub logo_url: String,
|
||||
pub favicon_url: String,
|
||||
pub locale: String,
|
||||
pub public_dashboard: bool,
|
||||
@@ -1299,22 +1275,6 @@ pub async fn settings(
|
||||
default_retention: i64,
|
||||
default_username: &str,
|
||||
) -> Result<SettingsView> {
|
||||
let cache_key = SettingsCacheKey {
|
||||
default_name: default_name.to_string(),
|
||||
default_threshold,
|
||||
default_retention,
|
||||
default_username: default_username.to_string(),
|
||||
};
|
||||
let current = now();
|
||||
if let Some(settings) = SETTINGS_CACHE.with(|cache| {
|
||||
cache
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.filter(|entry| entry.key == cache_key && entry.expires_at > current)
|
||||
.map(|entry| entry.settings.clone())
|
||||
}) {
|
||||
return Ok(settings);
|
||||
}
|
||||
let raw = db
|
||||
.prepare("SELECT value FROM settings WHERE id = 1")
|
||||
.first::<String>(Some("value"))
|
||||
@@ -1322,13 +1282,14 @@ pub async fn settings(
|
||||
.ok_or_else(|| worker::Error::RustError("站点设置尚未初始化".to_string()))?;
|
||||
let values: HashMap<String, String> = serde_json::from_str(&raw)
|
||||
.map_err(|_| worker::Error::RustError("站点设置格式无效".to_string()))?;
|
||||
let settings = SettingsView {
|
||||
Ok(SettingsView {
|
||||
site_name: values
|
||||
.get("site_name")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| default_name.to_string()),
|
||||
site_description: string_setting(&values, "site_description", "轻量、实时的服务器运行状态"),
|
||||
site_announcement: string_setting(&values, "site_announcement", ""),
|
||||
logo_url: string_setting(&values, "logo_url", ""),
|
||||
favicon_url: string_setting(&values, "favicon_url", ""),
|
||||
locale: string_setting(&values, "locale", "zh-CN"),
|
||||
public_dashboard: bool_setting(&values, "public_dashboard", true),
|
||||
@@ -1377,15 +1338,7 @@ pub async fn settings(
|
||||
expiry_alert_days: integer_setting(&values, "expiry_alert_days", 7),
|
||||
cloudflare_account_id: string_setting(&values, "cloudflare_account_id", ""),
|
||||
cloudflare_api_token: string_setting(&values, "cloudflare_api_token", ""),
|
||||
};
|
||||
SETTINGS_CACHE.with(|cache| {
|
||||
*cache.borrow_mut() = Some(SettingsCacheEntry {
|
||||
key: cache_key,
|
||||
expires_at: current + SETTINGS_CACHE_TTL_SECONDS,
|
||||
settings: settings.clone(),
|
||||
});
|
||||
});
|
||||
Ok(settings)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn update_settings(
|
||||
@@ -1409,6 +1362,9 @@ pub async fn update_settings(
|
||||
if let Some(value) = input.site_announcement.as_deref() {
|
||||
push_setting!("site_announcement", value.trim());
|
||||
}
|
||||
if let Some(value) = input.logo_url.as_deref() {
|
||||
push_setting!("logo_url", value.trim());
|
||||
}
|
||||
if let Some(value) = input.favicon_url.as_deref() {
|
||||
push_setting!("favicon_url", value.trim());
|
||||
}
|
||||
@@ -1545,7 +1501,6 @@ pub async fn update_settings(
|
||||
])?
|
||||
.run()
|
||||
.await?;
|
||||
invalidate_settings_cache();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1894,7 +1849,6 @@ pub async fn save_setting(db: &D1Database, key: &str, value: &str) -> Result<()>
|
||||
.bind(&[text(&format!("$.{key}")), text(value), number(now())])?
|
||||
.run()
|
||||
.await?;
|
||||
invalidate_settings_cache();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1907,7 +1861,6 @@ pub async fn increment_setting(db: &D1Database, key: &str) -> Result<()> {
|
||||
.bind(&[text(&format!("$.{key}")), number(now())])?
|
||||
.run()
|
||||
.await?;
|
||||
invalidate_settings_cache();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1922,12 +1875,10 @@ pub async fn update_expiry(db: &D1Database, id: &str, expires_at: i64) -> Result
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
alert_window_covered, history_cutoffs, non_empty_string_setting, secret_for_api,
|
||||
traffic_cycle_key, AlertMetricRow, HistoryMetricAggregate, TrafficCounterState,
|
||||
SECRET_MASK,
|
||||
alert_window_covered, secret_for_api, traffic_cycle_key, AlertMetricRow,
|
||||
HistoryMetricAggregate, TrafficCounterState, SECRET_MASK,
|
||||
};
|
||||
use crate::models::AgentReport;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn row(samples: i64, last_timestamp: i64, report_interval: i64) -> AlertMetricRow {
|
||||
AlertMetricRow {
|
||||
@@ -1968,33 +1919,6 @@ mod tests {
|
||||
assert_eq!(secret_for_api("stored-secret"), SECRET_MASK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_an_explicit_admin_username() {
|
||||
let mut values = HashMap::from([("admin_username".to_string(), String::new())]);
|
||||
assert_eq!(
|
||||
non_empty_string_setting(&values, "admin_username", "operator"),
|
||||
"operator"
|
||||
);
|
||||
assert_eq!(non_empty_string_setting(&values, "admin_username", ""), "");
|
||||
values.insert("admin_username".to_string(), "owner".to_string());
|
||||
assert_eq!(
|
||||
non_empty_string_setting(&values, "admin_username", "operator"),
|
||||
"owner"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn honors_short_and_long_history_retention() {
|
||||
let current = 10_000_000;
|
||||
let (one_day, one_day_recent) = history_cutoffs(current, 1);
|
||||
assert_eq!(one_day, current - 86_400);
|
||||
assert_eq!(one_day_recent, one_day);
|
||||
|
||||
let (thirty_days, thirty_days_recent) = history_cutoffs(current, 30);
|
||||
assert_eq!(thirty_days, current - 30 * 86_400);
|
||||
assert_eq!(thirty_days_recent, current - 86_400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assigns_monthly_traffic_periods_with_short_months() {
|
||||
let timestamp = |year: i64, month: i64, day: i64| {
|
||||
@@ -2141,26 +2065,4 @@ mod tests {
|
||||
assert_eq!(point.net_rx_total, 2_000);
|
||||
assert_eq!(point.net_tx_total, 3_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_reports_already_persisted() {
|
||||
let reports = [
|
||||
AgentReport {
|
||||
timestamp: 99,
|
||||
..AgentReport::default()
|
||||
},
|
||||
AgentReport {
|
||||
timestamp: 100,
|
||||
..AgentReport::default()
|
||||
},
|
||||
AgentReport {
|
||||
timestamp: 101,
|
||||
..AgentReport::default()
|
||||
},
|
||||
];
|
||||
|
||||
let fresh = super::reports_after(&reports, 100);
|
||||
assert_eq!(fresh.len(), 1);
|
||||
assert_eq!(fresh[0].timestamp, 101);
|
||||
}
|
||||
}
|
||||
+9
-19
@@ -2,7 +2,9 @@ use std::{collections::BTreeMap, time::Duration};
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use worker::{AbortController, D1Database, Error, Fetch, Method, Request, Result};
|
||||
use worker::{D1Database, Error, Method, Request, Result};
|
||||
|
||||
use crate::outbound::fetch_with_timeout;
|
||||
|
||||
use crate::{cloudflare, db};
|
||||
|
||||
@@ -136,13 +138,11 @@ async fn fetch_json(url: &str) -> Result<Value> {
|
||||
request
|
||||
.headers()
|
||||
.set("User-Agent", "NodeFlare-Exchange-Rates")?;
|
||||
let controller = AbortController::default();
|
||||
let signal = controller.signal();
|
||||
worker::wasm_bindgen_futures::spawn_local(async move {
|
||||
worker::Delay::from(Duration::from_secs(8)).await;
|
||||
controller.abort();
|
||||
});
|
||||
let mut response = Fetch::Request(request).send_with_signal(&signal).await?;
|
||||
let Some(mut response) = fetch_with_timeout(request, Duration::from_secs(8)).await? else {
|
||||
return Err(Error::RustError(
|
||||
"exchange-rate upstream request timed out".to_string(),
|
||||
));
|
||||
};
|
||||
let status = response.status_code();
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(Error::RustError(format!(
|
||||
@@ -246,7 +246,7 @@ pub async fn refresh(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{default_rates, due, parse_er_api, parse_frankfurter};
|
||||
use super::{default_rates, parse_er_api, parse_frankfurter};
|
||||
|
||||
#[test]
|
||||
fn provides_all_frontend_asset_currencies() {
|
||||
@@ -307,14 +307,4 @@ mod tests {
|
||||
});
|
||||
assert!(parse_frankfurter(&wrong_base).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refreshes_daily_and_retries_hourly() {
|
||||
let current = 200_000;
|
||||
assert!(due(0, 0, current, false));
|
||||
assert!(!due(current - 10, 0, current, false));
|
||||
assert!(!due(0, current - 10, current, false));
|
||||
assert!(due(current - 86_400, current - 3_600, current, false));
|
||||
assert!(due(current, current, current, true));
|
||||
}
|
||||
}
|
||||
+104
-23
@@ -6,6 +6,7 @@ mod latency;
|
||||
mod live;
|
||||
mod models;
|
||||
mod notify;
|
||||
mod outbound;
|
||||
mod routes;
|
||||
mod theme;
|
||||
mod turnstile;
|
||||
@@ -18,7 +19,7 @@ use serde::{de::DeserializeOwned, Serialize};
|
||||
use worker::*;
|
||||
|
||||
use crate::auth::{
|
||||
bearer_token, is_admin, sha256_hex, verify_turnstile_proof, ADMIN_SESSION_SECONDS,
|
||||
bearer_token, is_admin, random_salt, sha256_hex, verify_turnstile_proof, ADMIN_SESSION_SECONDS,
|
||||
};
|
||||
use crate::models::{
|
||||
AgentDiskMetric, AgentGpuMetric, AgentReport, AlertRuleInput, ApiError, LatencyTaskInput,
|
||||
@@ -205,7 +206,35 @@ async fn request_json<T: DeserializeOwned>(req: &mut Request, limit: usize) -> R
|
||||
}
|
||||
|
||||
pub(crate) fn error(message: &str, status: u16) -> Result<Response> {
|
||||
json(&ApiError { error: message }, status)
|
||||
json(
|
||||
&ApiError {
|
||||
error: message,
|
||||
request_id: None,
|
||||
},
|
||||
status,
|
||||
)
|
||||
}
|
||||
|
||||
fn internal_error(request_id: &str) -> Result<Response> {
|
||||
let mut response = json(
|
||||
&ApiError {
|
||||
error: "服务暂时不可用,请稍后重试",
|
||||
request_id: Some(request_id),
|
||||
},
|
||||
500,
|
||||
)?;
|
||||
response.headers_mut().set("X-Request-Id", request_id)?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn request_id(req: &Request) -> String {
|
||||
req.headers()
|
||||
.get("CF-Ray")
|
||||
.ok()
|
||||
.flatten()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(random_salt)
|
||||
.unwrap_or_else(|| format!("local-{}", Date::now().as_millis() as i64))
|
||||
}
|
||||
|
||||
fn rate_limited() -> Result<Response> {
|
||||
@@ -492,6 +521,38 @@ pub(crate) fn valid_password_derived(value: &str) -> bool {
|
||||
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
|
||||
}
|
||||
|
||||
pub(crate) fn valid_public_asset_url(value: &str) -> bool {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if value.chars().count() > 1000 {
|
||||
return false;
|
||||
}
|
||||
Url::parse(value).is_ok_and(|url| {
|
||||
url.scheme() == "https"
|
||||
&& url.host_str().is_some_and(|host| !host.is_empty())
|
||||
&& url.username().is_empty()
|
||||
&& url.password().is_none()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn valid_background_urls(value: &str) -> bool {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if value.chars().count() > 1000 {
|
||||
return false;
|
||||
}
|
||||
let parts = value.split('|').collect::<Vec<_>>();
|
||||
parts.len() <= 2
|
||||
&& parts.iter().any(|part| !part.trim().is_empty())
|
||||
&& parts
|
||||
.iter()
|
||||
.all(|part| part.trim().is_empty() || valid_public_asset_url(part))
|
||||
}
|
||||
|
||||
fn valid_server_price(value: f64) -> bool {
|
||||
value.is_finite() && (-1.0..=1_000_000_000.0).contains(&value)
|
||||
}
|
||||
@@ -1106,7 +1167,9 @@ async fn handle(req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||
#[event(fetch, respond_with_errors)]
|
||||
async fn fetch(req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||
let method = req.method();
|
||||
let method_name = format!("{method:?}");
|
||||
let path = req.path();
|
||||
let request_id = request_id(&req);
|
||||
let request_url = req.url().ok();
|
||||
let origin = req.headers().get("Origin").ok().flatten();
|
||||
|
||||
@@ -1133,8 +1196,17 @@ async fn fetch(req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||
match handle(req, env, ctx).await {
|
||||
Ok(response) => Ok(response),
|
||||
Err(err) => {
|
||||
console_error!("request failed: {err}");
|
||||
error("服务暂时不可用,请检查 D1 迁移与绑定", 500)
|
||||
console_error!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"event": "request_failed",
|
||||
"request_id": request_id,
|
||||
"method": method_name,
|
||||
"path": path,
|
||||
"error": err.to_string(),
|
||||
})
|
||||
);
|
||||
internal_error(&request_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1198,9 +1270,9 @@ mod tests {
|
||||
use super::{
|
||||
allow_on_rate_limit_failure, history_cache_key, history_cache_seconds, rate_limit_binding,
|
||||
rewrite_remote_theme_index, same_origin, submitted_secret, valid_agent_mirror,
|
||||
valid_cloudflare_account_id, valid_cloudflare_api_token, valid_password_derived,
|
||||
valid_ping_target, valid_server_price, validate_latency_task, validate_server, ADMIN_HTML,
|
||||
ADMIN_SCRIPT, ADMIN_STYLE,
|
||||
valid_background_urls, valid_cloudflare_account_id, valid_cloudflare_api_token,
|
||||
valid_password_derived, valid_ping_target, valid_public_asset_url, validate_latency_task,
|
||||
validate_server,
|
||||
};
|
||||
use crate::{db::SECRET_MASK, models::LatencyTaskInput, models::ServerInput};
|
||||
use worker::{Method, Url};
|
||||
@@ -1248,15 +1320,6 @@ mod tests {
|
||||
assert_eq!(history_cache_seconds(120), 600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_server_prices() {
|
||||
assert!(valid_server_price(-1.0));
|
||||
assert!(valid_server_price(0.0));
|
||||
assert!(valid_server_price(1_000_000_000.0));
|
||||
assert!(!valid_server_price(-1.01));
|
||||
assert!(!valid_server_price(f64::NAN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_samples_buffered_between_reports() {
|
||||
let mut server = ServerInput {
|
||||
@@ -1410,13 +1473,31 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embeds_the_admin_frontend() {
|
||||
assert!(ADMIN_HTML.starts_with(b"<!doctype html>"));
|
||||
assert!(ADMIN_HTML
|
||||
.windows(b"/admin-assets/admin.js".len())
|
||||
.any(|value| value == b"/admin-assets/admin.js"));
|
||||
assert!(!ADMIN_SCRIPT.is_empty());
|
||||
assert!(!ADMIN_STYLE.is_empty());
|
||||
fn validates_public_asset_urls() {
|
||||
assert!(valid_public_asset_url(""));
|
||||
assert!(valid_public_asset_url("https://cdn.example.com/logo.svg"));
|
||||
assert!(valid_public_asset_url(
|
||||
"https://cdn.example.com/background.webp?size=large#hero"
|
||||
));
|
||||
assert!(!valid_public_asset_url("http://cdn.example.com/logo.svg"));
|
||||
assert!(!valid_public_asset_url("/logo.svg"));
|
||||
assert!(!valid_public_asset_url(
|
||||
"https://user:pass@example.com/logo.svg"
|
||||
));
|
||||
|
||||
assert!(valid_background_urls(""));
|
||||
assert!(valid_background_urls(
|
||||
"https://cdn.example.com/light.webp | https://cdn.example.com/dark.webp"
|
||||
));
|
||||
assert!(valid_background_urls(
|
||||
"https://cdn.example.com/light.webp |"
|
||||
));
|
||||
assert!(valid_background_urls("| https://cdn.example.com/dark.webp"));
|
||||
assert!(!valid_background_urls("|"));
|
||||
assert!(!valid_background_urls(
|
||||
"https://cdn.example.com/a.webp | https://cdn.example.com/b.webp | https://cdn.example.com/c.webp"
|
||||
));
|
||||
assert!(!valid_background_urls("javascript:alert(1)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+29
-6
@@ -1016,7 +1016,10 @@ impl DurableObject for LiveHub {
|
||||
reason: String,
|
||||
_was_clean: bool,
|
||||
) -> Result<()> {
|
||||
ws.close(Some(code as u16), Some(reason))
|
||||
match outbound_close_code(code) {
|
||||
Some(code) => ws.close(Some(code), Some(reason)),
|
||||
None => ws.close(None, None::<&str>),
|
||||
}
|
||||
}
|
||||
|
||||
async fn websocket_error(&self, ws: WebSocket, _error: Error) -> Result<()> {
|
||||
@@ -1024,6 +1027,11 @@ impl DurableObject for LiveHub {
|
||||
}
|
||||
}
|
||||
|
||||
fn outbound_close_code(code: usize) -> Option<u16> {
|
||||
let code = u16::try_from(code).ok()?;
|
||||
((1000..=4999).contains(&code) && !matches!(code, 1004..=1006 | 1015)).then_some(code)
|
||||
}
|
||||
|
||||
async fn post_live_action(env: &Env, path: &str, action: &str, body: &str) -> Result<Response> {
|
||||
let namespace = env.durable_object("LIVE_HUB")?;
|
||||
let stub = namespace.id_from_name("dashboard")?.get_stub()?;
|
||||
@@ -1126,6 +1134,7 @@ pub async fn disconnect_agents(env: &Env, server_ids: &[String]) -> Result<()> {
|
||||
pub async fn upgrade(req: Request, env: &Env) -> Result<Response> {
|
||||
let namespace = env.durable_object("LIVE_HUB")?;
|
||||
let stub = namespace.id_from_name("dashboard")?.get_stub()?;
|
||||
let req = req.clone_mut()?;
|
||||
req.headers().delete("X-Live-Agent-ID")?;
|
||||
req.headers().delete("X-Live-Agent-Hidden")?;
|
||||
for header in [
|
||||
@@ -1157,6 +1166,7 @@ pub async fn upgrade_agent(
|
||||
) -> Result<Response> {
|
||||
let namespace = env.durable_object("LIVE_HUB")?;
|
||||
let stub = namespace.id_from_name("dashboard")?.get_stub()?;
|
||||
let req = req.clone_mut()?;
|
||||
req.headers().set("X-Live-Agent-ID", server_id)?;
|
||||
req.headers()
|
||||
.set("X-Live-Agent-Hidden", if hidden { "1" } else { "0" })?;
|
||||
@@ -1204,11 +1214,11 @@ pub async fn upgrade_agent(
|
||||
mod tests {
|
||||
use super::{
|
||||
agent_wss_interval_ms, batch_update_parts_at, begin_d1_flush, cached_update_value,
|
||||
finish_d1_flush, live_ack_payload, next_d1_write_ms, trim_cached_live_samples,
|
||||
trim_socket_attachment, update_alert_window, AlertEvaluationServer, AlertWindowSample,
|
||||
CachedLiveSample, SocketAttachment, TrafficAttachment, ALERT_WINDOW_SECONDS,
|
||||
CACHED_LIVE_TTL_SECONDS, MAX_CACHED_LIVE_BYTES, MAX_CACHED_LIVE_SAMPLES,
|
||||
MAX_SERIALIZED_ATTACHMENT_BYTES,
|
||||
finish_d1_flush, live_ack_payload, next_d1_write_ms, outbound_close_code,
|
||||
trim_cached_live_samples, trim_socket_attachment, update_alert_window,
|
||||
AlertEvaluationServer, AlertWindowSample, CachedLiveSample, SocketAttachment,
|
||||
TrafficAttachment, ALERT_WINDOW_SECONDS, CACHED_LIVE_TTL_SECONDS, MAX_CACHED_LIVE_BYTES,
|
||||
MAX_CACHED_LIVE_SAMPLES, MAX_SERIALIZED_ATTACHMENT_BYTES,
|
||||
};
|
||||
use crate::db::HistoryMetricAggregate;
|
||||
use crate::latency::LatencyMetricAggregates;
|
||||
@@ -1228,6 +1238,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_reserved_and_invalid_websocket_close_codes() {
|
||||
assert_eq!(outbound_close_code(1000), Some(1000));
|
||||
assert_eq!(outbound_close_code(1011), Some(1011));
|
||||
assert_eq!(outbound_close_code(4000), Some(4000));
|
||||
assert_eq!(outbound_close_code(999), None);
|
||||
assert_eq!(outbound_close_code(1004), None);
|
||||
assert_eq!(outbound_close_code(1005), None);
|
||||
assert_eq!(outbound_close_code(1006), None);
|
||||
assert_eq!(outbound_close_code(1015), None);
|
||||
assert_eq!(outbound_close_code(5000), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_payload_contains_cycle_adjusted_traffic_and_latency() {
|
||||
let report = AgentReport {
|
||||
|
||||
@@ -54,6 +54,7 @@ pub struct SettingsInput {
|
||||
pub site_name: Option<String>,
|
||||
pub site_description: Option<String>,
|
||||
pub site_announcement: Option<String>,
|
||||
pub logo_url: Option<String>,
|
||||
pub favicon_url: Option<String>,
|
||||
pub locale: Option<String>,
|
||||
pub public_dashboard: Option<bool>,
|
||||
@@ -366,4 +367,6 @@ pub struct AlertRuleView {
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ApiError<'a> {
|
||||
pub error: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub request_id: Option<&'a str>,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{future::select, pin_mut};
|
||||
use worker::{AbortController, Delay, Fetch, Request, Response, Result};
|
||||
|
||||
pub async fn fetch_with_timeout(request: Request, timeout: Duration) -> Result<Option<Response>> {
|
||||
let controller = AbortController::default();
|
||||
let signal = controller.signal();
|
||||
let fetch_request = Fetch::Request(request);
|
||||
let fetch = fetch_request.send_with_signal(&signal);
|
||||
let delay = Delay::from(timeout);
|
||||
pin_mut!(fetch, delay);
|
||||
|
||||
match select(fetch, delay).await {
|
||||
futures_util::future::Either::Left((response, _)) => response.map(Some),
|
||||
futures_util::future::Either::Right(((), _)) => {
|
||||
controller.abort();
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-23
@@ -1,6 +1,6 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use worker::{console_error, console_warn, Method, Request, Response, Result, Url};
|
||||
use worker::{console_error, console_warn, Method, Request, Response, Result};
|
||||
|
||||
use crate::auth::{
|
||||
create_admin_jwt, create_theme_preview_proof, hash_password, random_salt, sha256_hex,
|
||||
@@ -11,9 +11,10 @@ use crate::models::{
|
||||
};
|
||||
use crate::routes::{RouteContext, RouteOutcome};
|
||||
use crate::{
|
||||
cloudflare, db, error, exchange, json, latency, live, no_content, notify, now, request_json,
|
||||
server_id, settings_for_admin_response, submitted_secret, theme, valid_cloudflare_account_id,
|
||||
valid_cloudflare_api_token, valid_password_derived, validate_alert_rule, validate_latency_task,
|
||||
cloudflare, db, env_secret_text, error, exchange, json, latency, live, no_content, notify, now,
|
||||
request_json, server_id, settings_for_admin_response, submitted_secret, theme,
|
||||
valid_background_urls, valid_cloudflare_account_id, valid_cloudflare_api_token,
|
||||
valid_password_derived, valid_public_asset_url, validate_alert_rule, validate_latency_task,
|
||||
validate_server, validate_theme, API_JSON_MAX_BYTES,
|
||||
};
|
||||
|
||||
@@ -555,20 +556,12 @@ async fn exchange_rates_refresh(ctx: &RouteContext) -> Result<Response> {
|
||||
|
||||
async fn cloudflare_usage(ctx: &RouteContext) -> Result<Response> {
|
||||
let account_id = if ctx.settings.cloudflare_account_id.trim().is_empty() {
|
||||
ctx.env
|
||||
.secret("CF_USAGE_ACCOUNT_ID")
|
||||
.or_else(|_| ctx.env.var("CF_USAGE_ACCOUNT_ID"))
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_default()
|
||||
env_secret_text(&ctx.env, "CF_USAGE_ACCOUNT_ID")
|
||||
} else {
|
||||
ctx.settings.cloudflare_account_id.clone()
|
||||
};
|
||||
let token = if ctx.settings.cloudflare_api_token.trim().is_empty() {
|
||||
ctx.env
|
||||
.secret("CF_USAGE_API_TOKEN")
|
||||
.or_else(|_| ctx.env.var("CF_USAGE_API_TOKEN"))
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_default()
|
||||
env_secret_text(&ctx.env, "CF_USAGE_API_TOKEN")
|
||||
} else {
|
||||
ctx.settings.cloudflare_api_token.clone()
|
||||
};
|
||||
@@ -624,6 +617,13 @@ async fn settings_patch(req: &mut Request, ctx: &RouteContext) -> Result<Respons
|
||||
{
|
||||
return error("站点公告不能超过 1000 个字符", 400);
|
||||
}
|
||||
if input
|
||||
.logo_url
|
||||
.as_deref()
|
||||
.is_some_and(|value| !valid_public_asset_url(value))
|
||||
{
|
||||
return error("站点 Logo 必须使用 HTTPS 地址", 400);
|
||||
}
|
||||
if input
|
||||
.default_theme
|
||||
.as_deref()
|
||||
@@ -639,10 +639,13 @@ async fn settings_patch(req: &mut Request, ctx: &RouteContext) -> Result<Respons
|
||||
}
|
||||
if input
|
||||
.background_url
|
||||
.as_ref()
|
||||
.is_some_and(|value| value.chars().count() > 1000)
|
||||
.as_deref()
|
||||
.is_some_and(|value| !valid_background_urls(value))
|
||||
{
|
||||
return error("背景地址过长", 400);
|
||||
return error(
|
||||
"背景地址无效:请使用 HTTPS 地址,浅色和深色可用 | 分隔",
|
||||
400,
|
||||
);
|
||||
}
|
||||
if input.theme_options.as_ref().is_some_and(|value| {
|
||||
let Some(options) = value.as_object() else {
|
||||
@@ -751,12 +754,11 @@ async fn settings_patch(req: &mut Request, ctx: &RouteContext) -> Result<Respons
|
||||
{
|
||||
return error("界面语言仅支持简体中文或 English", 400);
|
||||
}
|
||||
if input.favicon_url.as_deref().is_some_and(|value| {
|
||||
!value.trim().is_empty()
|
||||
&& Url::parse(value.trim())
|
||||
.ok()
|
||||
.is_none_or(|url| url.scheme() != "https")
|
||||
}) {
|
||||
if input
|
||||
.favicon_url
|
||||
.as_deref()
|
||||
.is_some_and(|value| !valid_public_asset_url(value))
|
||||
{
|
||||
return error("站点图标必须使用 HTTPS 地址", 400);
|
||||
}
|
||||
let password_hash = if input
|
||||
|
||||
@@ -15,6 +15,13 @@ use crate::{
|
||||
set_admin_session_cookie, store_history_response, API_JSON_MAX_BYTES,
|
||||
};
|
||||
|
||||
fn request_hostname(req: &Request) -> Option<String> {
|
||||
req.url()
|
||||
.ok()?
|
||||
.host_str()
|
||||
.map(|hostname| hostname.to_string())
|
||||
}
|
||||
|
||||
pub(crate) async fn route(mut req: Request, ctx: &RouteContext) -> Result<RouteOutcome> {
|
||||
let method = req.method();
|
||||
let path = req.path();
|
||||
@@ -76,6 +83,7 @@ fn config_value(ctx: &RouteContext) -> serde_json::Value {
|
||||
"site_name": settings.site_name,
|
||||
"site_description": settings.site_description,
|
||||
"site_announcement": settings.site_announcement,
|
||||
"logo_url": settings.logo_url,
|
||||
"favicon_url": settings.favicon_url,
|
||||
"locale": settings.locale,
|
||||
"public_dashboard": settings.public_dashboard,
|
||||
@@ -181,10 +189,15 @@ async fn admin_login(req: &mut Request, ctx: &RouteContext) -> Result<Response>
|
||||
}
|
||||
let login_turnstile_enabled = ctx.public_turnstile_enabled || ctx.login_protection_enabled;
|
||||
if login_turnstile_enabled {
|
||||
let Some(hostname) = request_hostname(req) else {
|
||||
return error("请求主机名无效", 400);
|
||||
};
|
||||
let verified = turnstile::verify(
|
||||
&input.turnstile_token,
|
||||
&ctx.turnstile_secret_key,
|
||||
client_ip(req).as_deref(),
|
||||
&hostname,
|
||||
turnstile::ADMIN_LOGIN_ACTION,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
@@ -229,10 +242,15 @@ async fn turnstile_verify(req: &mut Request, ctx: &RouteContext) -> Result<Respo
|
||||
Ok(value) => value,
|
||||
Err(_) => return error("验证请求格式无效", 400),
|
||||
};
|
||||
let Some(hostname) = request_hostname(req) else {
|
||||
return error("请求主机名无效", 400);
|
||||
};
|
||||
let verified = turnstile::verify(
|
||||
&input.token,
|
||||
&ctx.turnstile_secret_key,
|
||||
client_ip(req).as_deref(),
|
||||
&hostname,
|
||||
turnstile::PUBLIC_DASHBOARD_ACTION,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
+1
-12
@@ -368,18 +368,7 @@ pub async fn remote_theme_version(base: &str) -> Option<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
asset_url, builtin_settings_schema, normalize_url, resolve_url, validate_settings_schema,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn exposes_builtin_glass_theme_settings() {
|
||||
let schema = builtin_settings_schema();
|
||||
let settings = schema["settings"].as_array().expect("settings");
|
||||
assert!(settings.iter().any(|field| field["key"] == "assetCurrency"));
|
||||
assert!(settings.iter().any(|field| field["key"] == "enableBlur"));
|
||||
assert_eq!(schema["source"], "builtin");
|
||||
}
|
||||
use super::{asset_url, normalize_url, resolve_url, validate_settings_schema};
|
||||
|
||||
#[test]
|
||||
fn validates_remote_theme_settings() {
|
||||
|
||||
+88
-6
@@ -1,7 +1,14 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use worker::{wasm_bindgen::JsValue, Fetch, Method, Request, RequestInit, Result};
|
||||
use worker::{wasm_bindgen::JsValue, Method, Request, RequestInit, Result};
|
||||
|
||||
use crate::outbound::fetch_with_timeout;
|
||||
|
||||
const SITEVERIFY_URL: &str = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
||||
const VERIFY_TIMEOUT_SECONDS: u64 = 8;
|
||||
pub const ADMIN_LOGIN_ACTION: &str = "admin-login";
|
||||
pub const PUBLIC_DASHBOARD_ACTION: &str = "public-dashboard";
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct VerifyRequest<'a> {
|
||||
@@ -11,13 +18,39 @@ struct VerifyRequest<'a> {
|
||||
remoteip: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct VerifyResponse {
|
||||
success: bool,
|
||||
hostname: Option<String>,
|
||||
action: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn verify(token: &str, secret: &str, remote_ip: Option<&str>) -> Result<bool> {
|
||||
if token.is_empty() || token.len() > 2048 || secret.is_empty() {
|
||||
fn matches_request(
|
||||
response: &VerifyResponse,
|
||||
expected_hostname: &str,
|
||||
expected_action: &str,
|
||||
) -> bool {
|
||||
response.success
|
||||
&& response
|
||||
.hostname
|
||||
.as_deref()
|
||||
.is_some_and(|hostname| hostname.eq_ignore_ascii_case(expected_hostname))
|
||||
&& response.action.as_deref() == Some(expected_action)
|
||||
}
|
||||
|
||||
pub async fn verify(
|
||||
token: &str,
|
||||
secret: &str,
|
||||
remote_ip: Option<&str>,
|
||||
expected_hostname: &str,
|
||||
expected_action: &str,
|
||||
) -> Result<bool> {
|
||||
if token.is_empty()
|
||||
|| token.len() > 2048
|
||||
|| secret.is_empty()
|
||||
|| expected_hostname.is_empty()
|
||||
|| expected_action.is_empty()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
@@ -32,10 +65,59 @@ pub async fn verify(token: &str, secret: &str, remote_ip: Option<&str>) -> Resul
|
||||
let request = Request::new_with_init(SITEVERIFY_URL, &init)?;
|
||||
request.headers().set("Content-Type", "application/json")?;
|
||||
|
||||
let mut response = Fetch::Request(request).send().await?;
|
||||
let Some(mut response) =
|
||||
fetch_with_timeout(request, Duration::from_secs(VERIFY_TIMEOUT_SECONDS)).await?
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !(200..300).contains(&response.status_code()) {
|
||||
return Ok(false);
|
||||
}
|
||||
let result: VerifyResponse = response.json().await?;
|
||||
Ok(result.success)
|
||||
Ok(matches_request(&result, expected_hostname, expected_action))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{matches_request, VerifyResponse};
|
||||
|
||||
fn response(hostname: &str, action: &str) -> VerifyResponse {
|
||||
VerifyResponse {
|
||||
success: true,
|
||||
hostname: Some(hostname.to_string()),
|
||||
action: Some(action.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_matching_hostname_and_action() {
|
||||
assert!(matches_request(
|
||||
&response("status.example.com", "admin-login"),
|
||||
"STATUS.EXAMPLE.COM",
|
||||
"admin-login",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_mismatched_or_incomplete_context() {
|
||||
assert!(!matches_request(
|
||||
&response("other.example.com", "admin-login"),
|
||||
"status.example.com",
|
||||
"admin-login",
|
||||
));
|
||||
assert!(!matches_request(
|
||||
&response("status.example.com", "public-dashboard"),
|
||||
"status.example.com",
|
||||
"admin-login",
|
||||
));
|
||||
assert!(!matches_request(
|
||||
&VerifyResponse {
|
||||
success: true,
|
||||
hostname: None,
|
||||
action: None,
|
||||
},
|
||||
"status.example.com",
|
||||
"admin-login",
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user