优化项目逻辑
This commit is contained in:
1 parent
97e4b3a5f1
commit
ceda386010
29 files changed
+3306
-1253
No files matched your search
@@ -44,6 +44,8 @@ jobs:
|
||||
needs: metadata
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
NODEFLARE_VERSION: ${{ needs.metadata.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -73,6 +75,42 @@ jobs:
|
||||
run: cargo test --locked --manifest-path agent/Cargo.toml
|
||||
- name: Build Worker
|
||||
run: NODEFLARE_ADMIN_READY=1 sh scripts/build-worker.sh
|
||||
- name: Smoke test Worker and D1
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
state_dir="$RUNNER_TEMP/nodeflare-state"
|
||||
log_file="$RUNNER_TEMP/nodeflare-dev.log"
|
||||
export XDG_CONFIG_HOME="$RUNNER_TEMP/wrangler-config"
|
||||
mkdir -p "$XDG_CONFIG_HOME"
|
||||
|
||||
bunx wrangler d1 migrations apply DB --local --persist-to "$state_dir"
|
||||
NODEFLARE_ADMIN_READY=1 NODEFLARE_WORKER_READY=1 \
|
||||
bunx wrangler dev --local --persist-to "$state_dir" --port 8787 \
|
||||
--var ADMIN_USERNAME:smoke --var ADMIN_PASSWORD:SmokePassword123 \
|
||||
>"$log_file" 2>&1 &
|
||||
dev_pid=$!
|
||||
trap 'kill "$dev_pid" 2>/dev/null || true' EXIT
|
||||
|
||||
ready=0
|
||||
for _ in $(seq 1 30); do
|
||||
if curl --fail --silent http://127.0.0.1:8787/api/bootstrap >/dev/null; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$ready" != 1 ]; then
|
||||
cat "$log_file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! MONITOR_BASE_URL=http://127.0.0.1:8787 \
|
||||
MONITOR_ADMIN_USERNAME=smoke MONITOR_ADMIN_PASSWORD=SmokePassword123 \
|
||||
sh scripts/smoke-test.sh; then
|
||||
cat "$log_file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
agent:
|
||||
name: Agent (${{ matrix.platform }})
|
||||
@@ -85,7 +123,7 @@ jobs:
|
||||
id-token: write
|
||||
env:
|
||||
CARGO_TARGET_DIR: target/agent
|
||||
NODEFLARE_AGENT_VERSION: ${{ needs.metadata.outputs.version }}
|
||||
NODEFLARE_VERSION: ${{ needs.metadata.outputs.version }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -197,6 +235,9 @@ jobs:
|
||||
attestations: write
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
NODEFLARE_VERSION: ${{ needs.metadata.outputs.version }}
|
||||
VITE_NODEFLARE_VERSION: ${{ needs.metadata.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[build.env]
|
||||
passthrough = ["NODEFLARE_AGENT_VERSION"]
|
||||
passthrough = ["NODEFLARE_VERSION"]
|
||||
|
||||
[target.aarch64-unknown-linux-musl]
|
||||
runner = "qemu-user"
|
||||
@@ -41,10 +41,17 @@
|
||||
| `CF_USAGE_ACCOUNT_ID` | 可选 | Cloudflare 用量查询的账户 ID,不设置则不启用用量查询 |
|
||||
| `CF_USAGE_API_TOKEN` | 可选 | 用量查询 Token,勾选“加密”;需要 Account Analytics: Read,并授权对应账户 |
|
||||
|
||||
## Agent 卸载
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/imengying/NodeFlare/main/agent/agent.sh | sudo sh -s -- --uninstall
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/bootstrap` | 前台配置、节点、汇率和访问状态 |
|
||||
| `GET` | `/api/config` | 公开站点配置 |
|
||||
| `GET` | `/api/exchange-rates` | D1 中的 CNY 基准汇率快照 |
|
||||
| `GET` | `/api/servers` | 公开节点和最新指标 |
|
||||
@@ -52,6 +59,7 @@
|
||||
| `GET` | `/api/latency/:id?hours=24` | 节点延迟任务历史 |
|
||||
| `GET` | `/api/ws` | 实时 WebSocket |
|
||||
| `GET` | `/api/agent/live` | Agent 实时指标 WebSocket |
|
||||
| `GET` | `/api/agent/config` | Agent 配置同步,节点 Bearer Token |
|
||||
| `POST` | `/api/agent/report` | Agent 上报,节点 Bearer Token |
|
||||
| `POST` | `/api/admin/login` | 管理登录 |
|
||||
| `POST` | `/api/admin/logout` | 退出登录(清除会话 Cookie) |
|
||||
|
||||
+597
-266
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,7 @@ fn main() {
|
||||
println!("cargo:rerun-if-changed=frontend/admin-dist/admin.html");
|
||||
println!("cargo:rerun-if-changed=frontend/admin-dist/admin.js");
|
||||
println!("cargo:rerun-if-changed=frontend/admin-dist/admin.css");
|
||||
println!("cargo:rerun-if-env-changed=NODEFLARE_VERSION");
|
||||
|
||||
let files = [
|
||||
(
|
||||
|
||||
+129
-37
@@ -12,10 +12,12 @@ import { derivePassword } from "./password";
|
||||
|
||||
const defaultConfig: Config = { ...demoConfig, site_description: "", site_name: "" };
|
||||
const demoMode = import.meta.env.DEV && new URLSearchParams(window.location.search).has("demo");
|
||||
const MAX_PLAYBACK_SAMPLES_PER_SERVER = 600;
|
||||
const NodeDetails = lazy(() => import("./components/NodeDetails").then((module) => ({ default: module.NodeDetails })));
|
||||
|
||||
interface LiveMetrics {
|
||||
timestamp: number;
|
||||
displayTimestamp: number;
|
||||
metrics: Partial<Server>;
|
||||
latencyResults?: LiveLatencyResult[];
|
||||
}
|
||||
@@ -69,6 +71,7 @@ export default function App() {
|
||||
const [configReady, setConfigReady] = useState(demoMode);
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [liveMetrics, setLiveMetrics] = useState<Record<string, LiveMetrics>>({});
|
||||
const [clockNow, setClockNow] = useState(() => Date.now());
|
||||
const [exchangeRates, setExchangeRates] = useState<ExchangeRates | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
@@ -90,18 +93,28 @@ export default function App() {
|
||||
const [systemDark, setSystemDark] = useState(() => matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const wsRef = useRef<WebSocket[]>([]);
|
||||
const liveConnectedRef = useRef(false);
|
||||
const playbackRef = useRef<Map<string, LiveSample[]>>(new Map());
|
||||
const serversRef = useRef<Server[]>([]);
|
||||
const dark = appearance ? appearance === "dark" : config.default_theme === "system" ? systemDark : config.default_theme === "dark";
|
||||
const background = resolveBackground(config.background_url, dark);
|
||||
const blur = themeToggle(config, "enableBlur");
|
||||
const liveServers = useMemo(() => servers.map((server) => {
|
||||
const live = liveMetrics[server.id];
|
||||
if (!live) return server;
|
||||
const latency = live.latencyResults?.length ? mergeLiveLatency(server, live.latencyResults) : server.latency;
|
||||
if (live.timestamp <= (server.timestamp ?? 0)) {
|
||||
return latency === server.latency ? server : { ...server, latency };
|
||||
}
|
||||
return { ...server, ...live.metrics, latency, timestamp: live.timestamp };
|
||||
}), [liveMetrics, servers]);
|
||||
const merged = !live ? server : (() => {
|
||||
const latency = live.latencyResults?.length ? mergeLiveLatency(server, live.latencyResults) : server.latency;
|
||||
if (live.timestamp <= (server.timestamp ?? 0)) {
|
||||
return latency === server.latency ? server : { ...server, latency };
|
||||
}
|
||||
return { ...server, ...live.metrics, latency, timestamp: live.timestamp };
|
||||
})();
|
||||
// Keep the card clock moving between Agent samples, while preserving the
|
||||
// actual sample timestamp for freshness/online checks.
|
||||
if (!merged.timestamp || !merged.uptime || !Number.isFinite(merged.uptime)) return merged;
|
||||
const elapsed = Math.max(0, Math.floor(clockNow / 1000 - merged.timestamp));
|
||||
return elapsed > 0 && elapsed <= config.offline_threshold_seconds
|
||||
? { ...merged, uptime: merged.uptime + elapsed }
|
||||
: merged;
|
||||
}), [clockNow, config.offline_threshold_seconds, liveMetrics, servers]);
|
||||
|
||||
const load = useCallback(async (quiet = false) => {
|
||||
if (!quiet) setLoading(true);
|
||||
@@ -114,29 +127,32 @@ export default function App() {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let dashboardPublic = true;
|
||||
try {
|
||||
const nextConfig = await api.config();
|
||||
dashboardPublic = nextConfig.public_dashboard;
|
||||
setConfig(nextConfig);
|
||||
const result = await api.bootstrap();
|
||||
setConfig(result.config);
|
||||
setConfigReady(true);
|
||||
const [serverResult, ratesResult] = await Promise.all([api.servers(), api.exchangeRates()]);
|
||||
setServers(serverResult.servers);
|
||||
setExchangeRates(ratesResult);
|
||||
setNeedsVerification(false);
|
||||
setNeedsLogin(false);
|
||||
setServers(result.servers);
|
||||
setExchangeRates(result.exchange_rates);
|
||||
setNeedsVerification(result.access === "turnstile");
|
||||
setNeedsLogin(result.access === "login");
|
||||
if (result.access !== "ok") {
|
||||
playbackRef.current.clear();
|
||||
setLiveMetrics({});
|
||||
}
|
||||
setError("");
|
||||
} catch (reason) {
|
||||
if (reason instanceof ApiError && (reason.status === 401 || (!dashboardPublic && reason.status === 403))) {
|
||||
if (reason instanceof ApiError && reason.status === 401) {
|
||||
setNeedsLogin(true);
|
||||
setNeedsVerification(false);
|
||||
setServers([]);
|
||||
playbackRef.current.clear();
|
||||
setLiveMetrics({});
|
||||
setError("");
|
||||
} else if (reason instanceof ApiError && reason.status === 403) {
|
||||
setNeedsVerification(true);
|
||||
setNeedsLogin(false);
|
||||
setServers([]);
|
||||
playbackRef.current.clear();
|
||||
setLiveMetrics({});
|
||||
setError(reason.message);
|
||||
} else {
|
||||
@@ -189,14 +205,65 @@ export default function App() {
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (needsLogin) return;
|
||||
serversRef.current = servers;
|
||||
for (const server of servers) {
|
||||
const samples = playbackRef.current.get(server.id);
|
||||
if (!samples || !server.timestamp) continue;
|
||||
const fresh = samples.filter((sample) => sample.ts > server.timestamp!);
|
||||
if (fresh.length) playbackRef.current.set(server.id, fresh);
|
||||
else playbackRef.current.delete(server.id);
|
||||
}
|
||||
}, [servers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (needsLogin || needsVerification) return;
|
||||
let ticks = 0;
|
||||
const timer = window.setInterval(() => {
|
||||
ticks += 1;
|
||||
if (!liveConnectedRef.current || ticks % 4 === 0) void load(true);
|
||||
if (!liveConnectedRef.current || ticks % 20 === 0) void load(true);
|
||||
}, 15_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [load, needsLogin]);
|
||||
}, [load, needsLogin, needsVerification]);
|
||||
|
||||
useEffect(() => {
|
||||
let previous = Date.now();
|
||||
const timer = window.setInterval(() => {
|
||||
const current = Date.now();
|
||||
const elapsed = Math.max(0, Math.min(5_000, current - previous));
|
||||
previous = current;
|
||||
setClockNow(current);
|
||||
if (elapsed === 0) return;
|
||||
setLiveMetrics((currentMetrics) => {
|
||||
if (!playbackRef.current.size) return currentMetrics;
|
||||
const next = { ...currentMetrics };
|
||||
for (const [serverId, samples] of playbackRef.current) {
|
||||
const state = next[serverId];
|
||||
if (!state || !samples.length) continue;
|
||||
const displayTimestamp = state.displayTimestamp + elapsed / 1000;
|
||||
let selected: LiveSample | undefined;
|
||||
while (samples.length && samples[0].ts <= displayTimestamp) selected = samples.shift();
|
||||
if (selected) {
|
||||
const metrics = { ...selected.data };
|
||||
const latencyResults = Array.isArray(metrics.latency_results) ? metrics.latency_results : [];
|
||||
delete metrics.latency_results;
|
||||
next[serverId] = {
|
||||
timestamp: selected.ts,
|
||||
displayTimestamp,
|
||||
metrics,
|
||||
latencyResults: latencyResults.length
|
||||
? mergeLiveResults(state.latencyResults, latencyResults)
|
||||
: state.latencyResults,
|
||||
};
|
||||
} else {
|
||||
next[serverId] = { ...state, displayTimestamp };
|
||||
}
|
||||
if (!samples.length) playbackRef.current.delete(serverId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, 1_000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
@@ -249,7 +316,6 @@ export default function App() {
|
||||
wsRef.current.push(socket);
|
||||
socket.onopen = () => {
|
||||
liveConnectedRef.current = true;
|
||||
void load(true);
|
||||
};
|
||||
socket.onclose = () => {
|
||||
wsRef.current = wsRef.current.filter((current) => current !== socket);
|
||||
@@ -270,29 +336,55 @@ export default function App() {
|
||||
return;
|
||||
}
|
||||
if (message.type === "batchUpdate" && Array.isArray(message.updates)) {
|
||||
const replayCached = message.cached === true;
|
||||
setLiveMetrics((current) => {
|
||||
const next = { ...current };
|
||||
for (const update of message.updates as Array<{ serverId?: string; samples?: LiveSample[] }>) {
|
||||
for (const update of message.updates as Array<{
|
||||
serverId?: string;
|
||||
samples?: LiveSample[];
|
||||
reportAgeMs?: number;
|
||||
}>) {
|
||||
if (!update.serverId || !Array.isArray(update.samples) || !update.samples.length) continue;
|
||||
const samples = update.samples
|
||||
.filter((sample) => Number.isFinite(sample.ts) && sample.data && typeof sample.data === "object")
|
||||
.sort((left, right) => left.ts - right.ts);
|
||||
const latest = samples.at(-1);
|
||||
if (!latest) continue;
|
||||
const previous = next[update.serverId];
|
||||
if (previous && previous.timestamp >= latest.ts) continue;
|
||||
const results = samples.flatMap((sample) => Array.isArray(sample.data.latency_results)
|
||||
? sample.data.latency_results
|
||||
: []);
|
||||
const metrics = { ...latest.data };
|
||||
delete metrics.latency_results;
|
||||
next[update.serverId] = {
|
||||
timestamp: latest.ts,
|
||||
metrics,
|
||||
latencyResults: Array.isArray(results) && results.length
|
||||
? mergeLiveResults(previous?.latencyResults, results)
|
||||
: previous?.latencyResults,
|
||||
};
|
||||
const pending = playbackRef.current.get(update.serverId) ?? [];
|
||||
const persistedTimestamp = serversRef.current.find((server) => server.id === update.serverId)?.timestamp ?? 0;
|
||||
const appliedTimestamp = Math.max(previous?.timestamp ?? 0, persistedTimestamp);
|
||||
const seen = new Set<number>([
|
||||
...pending.map((sample) => sample.ts),
|
||||
]);
|
||||
const incoming = samples.filter((sample) => sample.ts > appliedTimestamp && !seen.has(sample.ts));
|
||||
if (!incoming.length) continue;
|
||||
const reportAgeSeconds = replayCached && Number.isFinite(update.reportAgeMs)
|
||||
? Math.max(0, update.reportAgeMs! / 1000)
|
||||
: 0;
|
||||
const cursor = replayCached
|
||||
? Math.max(previous?.displayTimestamp ?? 0, incoming[incoming.length - 1].ts + reportAgeSeconds)
|
||||
: previous?.displayTimestamp ?? incoming[0].ts;
|
||||
const all = [...pending, ...incoming]
|
||||
.sort((left, right) => left.ts - right.ts)
|
||||
.slice(-MAX_PLAYBACK_SAMPLES_PER_SERVER);
|
||||
let selected: LiveSample | undefined;
|
||||
while (all.length && all[0].ts <= cursor) selected = all.shift();
|
||||
if (selected) {
|
||||
const metrics = { ...selected.data };
|
||||
const latencyResults = Array.isArray(metrics.latency_results) ? metrics.latency_results : [];
|
||||
delete metrics.latency_results;
|
||||
next[update.serverId] = {
|
||||
timestamp: selected.ts,
|
||||
displayTimestamp: cursor,
|
||||
metrics,
|
||||
latencyResults: latencyResults.length
|
||||
? mergeLiveResults(previous?.latencyResults, latencyResults)
|
||||
: previous?.latencyResults,
|
||||
};
|
||||
} else if (!previous) {
|
||||
continue;
|
||||
}
|
||||
if (all.length) playbackRef.current.set(update.serverId, all);
|
||||
else playbackRef.current.delete(update.serverId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
import type { AdminServer, AlertRule, AlertRuleInput, CloudflareUsage, Config, DatabaseStats, ExchangeRates, HistoryPoint, LatencySample, LatencyTask, LatencyTaskInput, LatencyTestPoint, Server, ServerInput, Settings, Theme, ThemeSettingsSchema } from "./types";
|
||||
import type { AdminServer, AlertRule, AlertRuleInput, Bootstrap, CloudflareUsage, Config, DatabaseStats, ExchangeRates, HistoryPoint, LatencySample, LatencyTask, LatencyTaskInput, LatencyTestPoint, Server, ServerInput, Settings, Theme, ThemeSettingsSchema } from "./types";
|
||||
|
||||
const TOKEN_KEY = "nodeflare-admin-token";
|
||||
export const ADMIN_UNAUTHORIZED_EVENT = "nodeflare:admin-unauthorized";
|
||||
@@ -38,6 +38,7 @@ async function request<T>(path: string, init: RequestInit = {}, admin = false):
|
||||
}
|
||||
|
||||
export const api = {
|
||||
bootstrap: () => request<Bootstrap>("/api/bootstrap"),
|
||||
config: () => request<Config>("/api/config"),
|
||||
exchangeRates: () => request<ExchangeRates>("/api/exchange-rates"),
|
||||
refreshExchangeRates: () => request<ExchangeRates>("/api/admin/exchange-rates/refresh", { method: "POST" }, true),
|
||||
|
||||
@@ -42,7 +42,7 @@ import { AlertRuleManager } from "./AlertRuleManager";
|
||||
import { Flag } from "./Flag";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
const { version: VERSION } = pkg;
|
||||
const VERSION = import.meta.env.VITE_NODEFLARE_VERSION || pkg.version;
|
||||
|
||||
type AdminTab = "servers" | "latency" | "appearance" | "themes" | "themeSettings" | "alerts" | "security" | "data" | "about";
|
||||
type AgentPlatform = "linux" | "windows" | "macos";
|
||||
@@ -81,7 +81,7 @@ const emptyServer: ServerInput = {
|
||||
network_interface: "",
|
||||
reset_day: 1,
|
||||
report_interval: 60,
|
||||
collect_interval: 5,
|
||||
collect_interval: 1,
|
||||
rx_correction: 0,
|
||||
tx_correction: 0,
|
||||
agent_mirror: "",
|
||||
@@ -679,7 +679,7 @@ export function AdminPanel({
|
||||
<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>
|
||||
<div className="form-grid three"><label><span>到期日期</span><input type="date" value={formatDate(form.expires_at)} onChange={(event) => updateForm("expires_at", event.target.value ? Math.floor(new Date(`${event.target.value}T00:00:00Z`).getTime() / 1000) : null)} /></label><label><span>Agent 上报间隔(秒)</span><input min="15" max="3600" type="number" value={form.report_interval} onChange={(event) => updateForm("report_interval", Number(event.target.value))} /></label><label><span>指标采样间隔(秒)</span><input min="2" max="60" type="number" value={form.collect_interval} onChange={(event) => updateForm("collect_interval", Number(event.target.value))} /></label></div>
|
||||
<div className="form-grid three"><label><span>到期日期</span><input type="date" value={formatDate(form.expires_at)} onChange={(event) => updateForm("expires_at", event.target.value ? Math.floor(new Date(`${event.target.value}T00:00:00Z`).getTime() / 1000) : null)} /></label><label><span>Agent 上报间隔(秒)</span><input min="15" max="3600" type="number" value={form.report_interval} onChange={(event) => updateForm("report_interval", Number(event.target.value))} /></label><label><span>指标采样间隔(秒)</span><input min="1" max="60" type="number" value={form.collect_interval} onChange={(event) => updateForm("collect_interval", Number(event.target.value))} /></label></div>
|
||||
<div className="form-grid"><label><span>统计网卡(逗号分隔,留空自动)</span><input value={form.network_interface} onChange={(event) => updateForm("network_interface", event.target.value)} placeholder="eth0,ens3" /></label><label><span>下行流量当前值(GB)</span><input min="0" step="0.1" type="number" value={rxCurrentGb} onChange={(event) => setRxCurrentGb(event.target.value)} /></label><label><span>上行流量当前值(GB)</span><input min="0" step="0.1" type="number" value={txCurrentGb} onChange={(event) => setTxCurrentGb(event.target.value)} /></label><label><span>Agent 下载加速(可选)</span><input value={form.agent_mirror} onChange={(event) => updateForm("agent_mirror", event.target.value.trim())} placeholder="https://ghproxy.net" /></label></div>
|
||||
<div className="settings-toggles editor-toggles"><Toggle label="自动续费" checked={form.auto_renewal} onChange={(value) => updateForm("auto_renewal", value)} /><Toggle label="Agent 自动更新" checked={form.auto_update} onChange={(value) => updateForm("auto_update", value)} /><Toggle label="隐藏节点" checked={form.hidden} onChange={(value) => updateForm("hidden", value)} /><Toggle label="关闭离线告警" checked={form.offline_notify_disabled} onChange={(value) => updateForm("offline_notify_disabled", value)} /></div>
|
||||
<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>
|
||||
|
||||
@@ -76,6 +76,13 @@ export interface ExchangeRates {
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
export interface Bootstrap {
|
||||
config: Config;
|
||||
access: "ok" | "login" | "turnstile";
|
||||
servers: Server[];
|
||||
exchange_rates: ExchangeRates | null;
|
||||
}
|
||||
|
||||
interface DiskMetric {
|
||||
name: string;
|
||||
mount_point: string;
|
||||
|
||||
+59
-120
@@ -1,8 +1,8 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
id INTEGER PRIMARY KEY CHECK(id = 1),
|
||||
value TEXT NOT NULL CHECK(json_valid(value)),
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ CREATE TABLE servers (
|
||||
network_interface TEXT NOT NULL DEFAULT '',
|
||||
reset_day INTEGER NOT NULL DEFAULT 1,
|
||||
report_interval INTEGER NOT NULL DEFAULT 60,
|
||||
collect_interval INTEGER NOT NULL DEFAULT 5,
|
||||
collect_interval INTEGER NOT NULL DEFAULT 1,
|
||||
rx_correction INTEGER NOT NULL DEFAULT 0,
|
||||
tx_correction INTEGER NOT NULL DEFAULT 0,
|
||||
agent_mirror TEXT NOT NULL DEFAULT '',
|
||||
@@ -36,57 +36,6 @@ CREATE TABLE servers (
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE latest_metrics (
|
||||
server_id TEXT PRIMARY KEY REFERENCES servers(id) ON DELETE CASCADE,
|
||||
timestamp INTEGER NOT NULL,
|
||||
cpu REAL NOT NULL DEFAULT 0,
|
||||
load1 REAL NOT NULL DEFAULT 0,
|
||||
load5 REAL NOT NULL DEFAULT 0,
|
||||
load15 REAL NOT NULL DEFAULT 0,
|
||||
mem_used INTEGER NOT NULL DEFAULT 0,
|
||||
mem_total INTEGER NOT NULL DEFAULT 0,
|
||||
swap_used INTEGER NOT NULL DEFAULT 0,
|
||||
swap_total INTEGER NOT NULL DEFAULT 0,
|
||||
disk_used INTEGER NOT NULL DEFAULT 0,
|
||||
disk_total INTEGER NOT NULL DEFAULT 0,
|
||||
net_in REAL NOT NULL DEFAULT 0,
|
||||
net_out REAL NOT NULL DEFAULT 0,
|
||||
net_rx_total INTEGER NOT NULL DEFAULT 0,
|
||||
net_tx_total INTEGER NOT NULL DEFAULT 0,
|
||||
uptime INTEGER NOT NULL DEFAULT 0,
|
||||
processes INTEGER NOT NULL DEFAULT 0,
|
||||
tcp_connections INTEGER NOT NULL DEFAULT 0,
|
||||
udp_connections INTEGER NOT NULL DEFAULT 0,
|
||||
cpu_cores INTEGER NOT NULL DEFAULT 0,
|
||||
cpu_model TEXT NOT NULL DEFAULT '',
|
||||
os TEXT NOT NULL DEFAULT '',
|
||||
kernel TEXT NOT NULL DEFAULT '',
|
||||
arch TEXT NOT NULL DEFAULT '',
|
||||
virtualization TEXT NOT NULL DEFAULT '',
|
||||
gpu_usage REAL NOT NULL DEFAULT 0,
|
||||
gpu_model TEXT NOT NULL DEFAULT '',
|
||||
agent_version TEXT NOT NULL DEFAULT '',
|
||||
disk_read_bps REAL NOT NULL DEFAULT 0,
|
||||
disk_write_bps REAL NOT NULL DEFAULT 0,
|
||||
disk_read_iops REAL NOT NULL DEFAULT 0,
|
||||
disk_write_iops REAL NOT NULL DEFAULT 0,
|
||||
disk_await_ms REAL NOT NULL DEFAULT 0,
|
||||
disk_utilization REAL NOT NULL DEFAULT 0,
|
||||
disk_info TEXT NOT NULL DEFAULT '[]',
|
||||
gpu_info TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
|
||||
CREATE TABLE traffic_cycles (
|
||||
server_id TEXT PRIMARY KEY REFERENCES servers(id) ON DELETE CASCADE,
|
||||
cycle_key INTEGER NOT NULL,
|
||||
reset_day INTEGER NOT NULL DEFAULT 1,
|
||||
timestamp INTEGER NOT NULL,
|
||||
raw_rx INTEGER NOT NULL DEFAULT 0,
|
||||
raw_tx INTEGER NOT NULL DEFAULT 0,
|
||||
used_rx INTEGER NOT NULL DEFAULT 0,
|
||||
used_tx INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE metric_history (
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
timestamp INTEGER NOT NULL,
|
||||
@@ -114,8 +63,11 @@ CREATE TABLE metric_history (
|
||||
disk_write_iops REAL NOT NULL DEFAULT 0,
|
||||
disk_await_ms REAL NOT NULL DEFAULT 0,
|
||||
disk_utilization REAL NOT NULL DEFAULT 0,
|
||||
latest_timestamp INTEGER NOT NULL,
|
||||
latest_json TEXT NOT NULL CHECK(json_valid(latest_json)),
|
||||
latency_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(latency_json)),
|
||||
PRIMARY KEY(server_id, timestamp)
|
||||
);
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE metric_history_hourly (
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
@@ -144,51 +96,57 @@ CREATE TABLE metric_history_hourly (
|
||||
disk_write_iops REAL NOT NULL DEFAULT 0,
|
||||
disk_await_ms REAL NOT NULL DEFAULT 0,
|
||||
disk_utilization REAL NOT NULL DEFAULT 0,
|
||||
latest_timestamp INTEGER NOT NULL,
|
||||
latest_json TEXT NOT NULL CHECK(json_valid(latest_json)),
|
||||
latency_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(latency_json)),
|
||||
PRIMARY KEY(server_id, timestamp)
|
||||
);
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX idx_history_server_time
|
||||
ON metric_history(server_id, timestamp DESC);
|
||||
CREATE INDEX idx_history_hourly_server_time
|
||||
ON metric_history_hourly(server_id, timestamp DESC);
|
||||
CREATE INDEX idx_servers_sort
|
||||
ON servers(sort_order, created_at);
|
||||
|
||||
INSERT INTO settings(key, value, updated_at) VALUES
|
||||
('site_description', '轻量、实时的服务器运行状态', unixepoch()),
|
||||
('site_announcement', '', unixepoch()),
|
||||
('favicon_url', '', unixepoch()),
|
||||
('locale', 'zh-CN', unixepoch()),
|
||||
('public_dashboard', 'true', unixepoch()),
|
||||
('history_cache_version', '0', unixepoch()),
|
||||
('default_theme', 'system', unixepoch()),
|
||||
('active_theme_id', 'builtin-nodeflare-glass', unixepoch()),
|
||||
('background_url', '', unixepoch()),
|
||||
('theme_options', '{}', unixepoch()),
|
||||
('show_search', 'true', unixepoch()),
|
||||
('show_groups', 'true', unixepoch()),
|
||||
('show_stats', 'true', unixepoch()),
|
||||
('show_assets', 'true', unixepoch()),
|
||||
('show_traffic', 'true', unixepoch()),
|
||||
('show_speed', 'true', unixepoch()),
|
||||
('show_price', 'true', unixepoch()),
|
||||
('show_expiry', 'true', unixepoch()),
|
||||
('show_latency', 'true', unixepoch()),
|
||||
('show_uptime', 'true', unixepoch()),
|
||||
('admin_username', '', unixepoch()),
|
||||
('admin_password_hash', '', unixepoch()),
|
||||
('password_client_salt', lower(hex(randomblob(16))), unixepoch()),
|
||||
('turnstile_enabled', 'false', unixepoch()),
|
||||
('turnstile_login_enabled', 'true', unixepoch()),
|
||||
('turnstile_site_key', '', unixepoch()),
|
||||
('turnstile_secret_key', '', unixepoch()),
|
||||
('notification_enabled', 'false', unixepoch()),
|
||||
('notification_endpoint', '', unixepoch()),
|
||||
('notification_target', '', unixepoch()),
|
||||
('offline_alert_minutes', '5', unixepoch()),
|
||||
('expiry_alert_days', '7', unixepoch()),
|
||||
('cloudflare_account_id', '', unixepoch()),
|
||||
('cloudflare_api_token', '', unixepoch());
|
||||
INSERT INTO settings(id, value, updated_at) VALUES (
|
||||
1,
|
||||
json_patch(
|
||||
'{
|
||||
"site_description": "轻量、实时的服务器运行状态",
|
||||
"site_announcement": "",
|
||||
"favicon_url": "",
|
||||
"locale": "zh-CN",
|
||||
"public_dashboard": "true",
|
||||
"history_cache_version": "0",
|
||||
"default_theme": "system",
|
||||
"active_theme_id": "builtin-nodeflare-glass",
|
||||
"background_url": "",
|
||||
"theme_options": "{}",
|
||||
"show_search": "true",
|
||||
"show_groups": "true",
|
||||
"show_stats": "true",
|
||||
"show_assets": "true",
|
||||
"show_traffic": "true",
|
||||
"show_speed": "true",
|
||||
"show_price": "true",
|
||||
"show_expiry": "true",
|
||||
"show_latency": "true",
|
||||
"show_uptime": "true",
|
||||
"admin_username": "",
|
||||
"admin_password_hash": "",
|
||||
"turnstile_enabled": "false",
|
||||
"turnstile_login_enabled": "true",
|
||||
"turnstile_site_key": "",
|
||||
"turnstile_secret_key": "",
|
||||
"notification_enabled": "false",
|
||||
"notification_endpoint": "",
|
||||
"notification_target": "",
|
||||
"offline_alert_minutes": "5",
|
||||
"expiry_alert_days": "7",
|
||||
"cloudflare_account_id": "",
|
||||
"cloudflare_api_token": ""
|
||||
}',
|
||||
json_object('password_client_salt', lower(hex(randomblob(16))))
|
||||
),
|
||||
unixepoch()
|
||||
);
|
||||
|
||||
CREATE TABLE exchange_rate_snapshots (
|
||||
base_currency TEXT PRIMARY KEY,
|
||||
@@ -236,33 +194,12 @@ CREATE TABLE latency_tasks (
|
||||
CREATE TABLE latency_task_servers (
|
||||
task_id TEXT NOT NULL REFERENCES latency_tasks(id) ON DELETE CASCADE,
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
assigned_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(task_id, server_id)
|
||||
);
|
||||
|
||||
CREATE TABLE latency_latest (
|
||||
task_id TEXT NOT NULL REFERENCES latency_tasks(id) ON DELETE CASCADE,
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
timestamp INTEGER NOT NULL,
|
||||
latency_ms REAL NOT NULL,
|
||||
packet_loss REAL NOT NULL,
|
||||
PRIMARY KEY(task_id, server_id)
|
||||
);
|
||||
|
||||
CREATE TABLE latency_history (
|
||||
task_id TEXT NOT NULL REFERENCES latency_tasks(id) ON DELETE CASCADE,
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
timestamp INTEGER NOT NULL,
|
||||
latency_ms REAL NOT NULL,
|
||||
packet_loss REAL NOT NULL,
|
||||
PRIMARY KEY(task_id, server_id, timestamp)
|
||||
);
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX idx_latency_task_servers_server
|
||||
ON latency_task_servers(server_id, task_id);
|
||||
CREATE INDEX idx_latency_history_server_time
|
||||
ON latency_history(server_id, timestamp DESC);
|
||||
CREATE INDEX idx_latency_history_task_time
|
||||
ON latency_history(task_id, timestamp DESC);
|
||||
|
||||
CREATE TABLE alert_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -280,7 +217,7 @@ CREATE TABLE alert_rule_servers (
|
||||
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY(rule_id, server_id)
|
||||
);
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE alert_states (
|
||||
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
@@ -288,4 +225,6 @@ CREATE TABLE alert_states (
|
||||
active INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(rule_id, server_id)
|
||||
);
|
||||
) WITHOUT ROWID;
|
||||
|
||||
PRAGMA optimize;
|
||||
@@ -2,8 +2,11 @@
|
||||
set -eu
|
||||
|
||||
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
root_dir=$(CDPATH= cd -- "$script_dir/.." && pwd)
|
||||
NODEFLARE_VERSION=$(sh "$script_dir/resolve-version.sh")
|
||||
NODEFLARE_NATIVE_RUST_ONLY=1
|
||||
export NODEFLARE_NATIVE_RUST_ONLY
|
||||
export NODEFLARE_NATIVE_RUST_ONLY NODEFLARE_VERSION
|
||||
cd "$root_dir"
|
||||
. "$script_dir/ensure-rust.sh"
|
||||
|
||||
agent_target_dir=${CARGO_TARGET_DIR:-target/agent}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
#!/bin/sh
|
||||
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
|
||||
cd "$root_dir"
|
||||
|
||||
bun run --cwd frontend build
|
||||
|
||||
if build_key=$(sh scripts/build-key.sh frontend 2>/dev/null); then
|
||||
|
||||
@@ -3,10 +3,10 @@ set -eu
|
||||
|
||||
case "${1:-}" in
|
||||
frontend)
|
||||
paths="frontend package.json bun.lock scripts/build-frontend.sh scripts/build-key.sh"
|
||||
paths="frontend package.json bun.lock scripts/build-frontend.sh scripts/build-key.sh scripts/resolve-version.sh"
|
||||
;;
|
||||
worker)
|
||||
paths="src frontend Cargo.toml Cargo.lock build.rs scripts/build-worker.sh scripts/build-key.sh scripts/worker-shim.js"
|
||||
paths="src frontend Cargo.toml Cargo.lock build.rs scripts/build-worker.sh scripts/build-key.sh scripts/resolve-version.sh scripts/worker-shim.js"
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 frontend|worker" >&2
|
||||
@@ -15,6 +15,7 @@ case "${1:-}" in
|
||||
esac
|
||||
|
||||
{
|
||||
sh scripts/resolve-version.sh
|
||||
git rev-parse HEAD
|
||||
git diff --binary -- $paths
|
||||
git ls-files --others --exclude-standard -- $paths | while IFS= read -r file; do
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
#!/bin/sh
|
||||
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
|
||||
cd "$root_dir"
|
||||
|
||||
current_build_key=$(sh scripts/build-key.sh worker 2>/dev/null || true)
|
||||
if [ "${NODEFLARE_WORKER_READY:-0}" = "1" ] && [ -n "$current_build_key" ]; then
|
||||
built_build_key=$(sed -n '1p' build/.nodeflare-revision 2>/dev/null || true)
|
||||
@@ -12,7 +19,6 @@ if [ "${NODEFLARE_WORKER_READY:-0}" = "1" ] && [ -n "$current_build_key" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
. "$script_dir/ensure-rust.sh"
|
||||
|
||||
if [ "${NODEFLARE_ADMIN_READY:-0}" != "1" ]; then
|
||||
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
root_dir=$(CDPATH= cd -- "$script_dir/.." && pwd)
|
||||
|
||||
raw=${NODEFLARE_VERSION:-}
|
||||
if [ -z "$raw" ]; then
|
||||
raw=$(git -C "$root_dir" describe --tags --exact-match --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null || true)
|
||||
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
|
||||
raw=$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",*$/\1/p' "$root_dir/package.json" | sed -n '1p')
|
||||
fi
|
||||
|
||||
version=${raw#v}
|
||||
if ! printf '%s\n' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "invalid NodeFlare version: $raw" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$version"
|
||||
+58
-15
@@ -17,7 +17,9 @@ request() {
|
||||
monitor_curl --fail --location --silent --show-error "$@"
|
||||
}
|
||||
|
||||
config_json=$(request "$MONITOR_BASE_URL/api/config")
|
||||
bootstrap_json=$(request "$MONITOR_BASE_URL/api/bootstrap")
|
||||
printf '%s' "$bootstrap_json" | jq -e '.access == "ok" and (.servers | type == "array")' >/dev/null
|
||||
config_json=$(printf '%s' "$bootstrap_json" | jq -c '.config')
|
||||
printf '%s' "$config_json" | jq -e '.site_name | length > 0' >/dev/null
|
||||
password_client_salt=$(printf '%s' "$config_json" | jq -er '.password_client_salt | select(length > 0)')
|
||||
password_derived=$(NODEFLARE_PASSWORD="$MONITOR_ADMIN_PASSWORD" NODEFLARE_SALT="$password_client_salt" bun -e '
|
||||
@@ -27,7 +29,7 @@ const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", i
|
||||
console.log(Array.from(new Uint8Array(bits), byte => byte.toString(16).padStart(2, "0")).join(""));
|
||||
')
|
||||
cross_origin_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
-H 'Origin: https://not-nodeflare.invalid' "$MONITOR_BASE_URL/api/config")
|
||||
-H 'Origin: https://not-nodeflare.invalid' "$MONITOR_BASE_URL/api/bootstrap")
|
||||
[ "$cross_origin_status" = "403" ]
|
||||
|
||||
admin_html=$(request "$MONITOR_BASE_URL/admin")
|
||||
@@ -45,8 +47,8 @@ case "$admin_html" in
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
request "$MONITOR_BASE_URL/admin-assets/admin.js" >/dev/null
|
||||
request "$MONITOR_BASE_URL/admin-assets/admin.css" >/dev/null
|
||||
request "$MONITOR_BASE_URL/admin-assets/admin.js" | grep -q '管理面板'
|
||||
request "$MONITOR_BASE_URL/admin-assets/admin.css" | grep -q 'admin-shell'
|
||||
|
||||
login_json=$(request -H 'Content-Type: application/json' \
|
||||
--data "$(jq -nc --arg username "$MONITOR_ADMIN_USERNAME" --arg password "$MONITOR_ADMIN_PASSWORD" --arg password_derived "$password_derived" --arg turnstile_token "$MONITOR_TURNSTILE_TOKEN" '{username:$username,password:$password,password_derived:$password_derived,turnstile_token:$turnstile_token}')" \
|
||||
@@ -55,13 +57,16 @@ admin_token=$(printf '%s' "$login_json" | jq -er '.token')
|
||||
|
||||
# Login protection defaults to enabled, but without a complete Turnstile pair it
|
||||
# remains inactive so the first admin settings save must still work.
|
||||
settings_payload=$(request -H "Authorization: Bearer $admin_token" \
|
||||
"$MONITOR_BASE_URL/api/admin/settings" | \
|
||||
jq -c '.site_description = "Smoke settings" | del(.admin_password_configured)')
|
||||
request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' -X PATCH \
|
||||
--data '{"site_description":"Smoke settings"}' \
|
||||
--data "$settings_payload" \
|
||||
"$MONITOR_BASE_URL/api/admin/settings" | jq -e '.settings.site_description == "Smoke settings"' >/dev/null
|
||||
|
||||
request -H "Authorization: Bearer $admin_token" \
|
||||
"$MONITOR_BASE_URL/api/exchange-rates" | \
|
||||
jq -e '.base == "CNY" and .rates.CNY == 1 and .rates.USD > 0 and .rates.CAD > 0 and (has("cny") | not)' >/dev/null
|
||||
"$MONITOR_BASE_URL/api/bootstrap" | \
|
||||
jq -e '.config.site_description == "Smoke settings" and .exchange_rates.base == "CNY" and .exchange_rates.rates.CNY == 1 and .exchange_rates.rates.USD > 0 and .exchange_rates.rates.CAD > 0 and (.exchange_rates | has("cny") | not)' >/dev/null
|
||||
|
||||
request -H "Authorization: Bearer $admin_token" \
|
||||
"$MONITOR_BASE_URL/api/admin/themes" | \
|
||||
@@ -72,7 +77,11 @@ server_input='{"name":"Smoke Test Node","region":"JP","group_name":"Test","tags"
|
||||
server_id=
|
||||
latency_task_id=
|
||||
alert_rule_id=
|
||||
stress_report_file=
|
||||
cleanup() {
|
||||
if [ -n "$stress_report_file" ]; then
|
||||
rm -f "$stress_report_file"
|
||||
fi
|
||||
if [ -n "$alert_rule_id" ]; then
|
||||
monitor_curl --silent --show-error -H "Authorization: Bearer $admin_token" \
|
||||
-X DELETE "$MONITOR_BASE_URL/api/admin/alert-rules/$alert_rule_id" >/dev/null || true
|
||||
@@ -106,7 +115,7 @@ token_without_admin_status=$(monitor_curl --silent --output /dev/null --write-ou
|
||||
request -H "Authorization: Bearer $admin_token" \
|
||||
"$MONITOR_BASE_URL/api/admin/servers/$server_id/token" | \
|
||||
jq -e --arg token "$agent_token" '.agent_token == $token' >/dev/null
|
||||
agent_version=$(sed -n 's/^version = "\([^"]*\)"/\1/p' agent/Cargo.toml | head -1)
|
||||
agent_version=$(sh scripts/resolve-version.sh)
|
||||
|
||||
sample=$(jq -nc --arg agent_version "$agent_version" '{
|
||||
timestamp:(now|floor),cpu:18.5,load1:0.42,load5:0.36,load15:0.31,
|
||||
@@ -131,6 +140,10 @@ report_response=$(request -H "Authorization: Bearer $agent_token" -H 'Content-Ty
|
||||
printf '%s' "$report_response" | jq -e \
|
||||
--arg task_id "$latency_task_id" \
|
||||
'.collect_interval == 5 and .agent_mirror == "https://mirror.example.com" and .auto_update == true and (.latency_tasks | any(.id == $task_id and .task_type == "tcp" and .target == "example.com" and .port == 443))' >/dev/null
|
||||
request -H "Authorization: Bearer $agent_token" \
|
||||
"$MONITOR_BASE_URL/api/agent/config" | jq -e \
|
||||
--arg task_id "$latency_task_id" \
|
||||
'.collect_interval == 5 and .agent_mirror == "https://mirror.example.com" and (.latency_tasks | any(.id == $task_id))' >/dev/null
|
||||
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/admin/servers" | \
|
||||
jq -e --arg id "$server_id" '.servers | any(.id == $id and .last_ip == "8.8.8.8")' >/dev/null
|
||||
|
||||
@@ -138,16 +151,46 @@ latency_report=$(printf '%s' "$report" | jq --arg task_id "$latency_task_id" '.s
|
||||
request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
|
||||
--data "$latency_report" "$MONITOR_BASE_URL/api/agent/report" | jq -e '.collect_interval == 5' >/dev/null
|
||||
|
||||
# Traffic counters may reset when a host reboots. Cycle usage must retain the
|
||||
# bytes already observed before the reset and continue from the new counter.
|
||||
traffic_report=$(printf '%s' "$report" | jq '.samples[0].timestamp += 2 | .samples[0].net_rx_total=2147483648 | .samples[0].net_tx_total=1073741824')
|
||||
# A whole Agent batch must collapse into one traffic-cycle write while preserving
|
||||
# counter resets. Replaying the same batch must not count any bytes twice.
|
||||
traffic_report=$(printf '%s' "$report" | jq '.samples = [
|
||||
(.samples[0] | .timestamp += 2 | .net_rx_total=2147483648 | .net_tx_total=1073741824),
|
||||
(.samples[0] | .timestamp += 3 | .net_rx_total=268435456 | .net_tx_total=134217728),
|
||||
(.samples[0] | .timestamp += 4 | .net_rx_total=536870912 | .net_tx_total=268435456)
|
||||
]')
|
||||
request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
|
||||
--data "$traffic_report" "$MONITOR_BASE_URL/api/agent/report" | jq -e '.collect_interval == 5' >/dev/null
|
||||
reset_report=$(printf '%s' "$report" | jq '.samples[0].timestamp += 3 | .samples[0].net_rx_total=268435456 | .samples[0].net_tx_total=134217728')
|
||||
request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
|
||||
--data "$reset_report" "$MONITOR_BASE_URL/api/agent/report" | jq -e '.collect_interval == 5' >/dev/null
|
||||
--data "$traffic_report" "$MONITOR_BASE_URL/api/agent/report" | jq -e '.collect_interval == 5' >/dev/null
|
||||
|
||||
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/servers" | jq -e --arg id "$server_id" --arg task_id "$latency_task_id" '.servers | any(.id == $id and .cpu == 18.5 and .gpu_usage == 32.5 and .disk_await_ms == 1.4 and (.gpus | length) == 1 and (.disks | length) == 1 and .disk_used == 21474836480 and .traffic_limit == 107374182400 and .net_rx_total == 2415919104 and .net_tx_total == 1207959552 and .price == 9.9 and (has("last_ip") | not) and (.latency | any(.task_id == $task_id and .latency_ms == 28.4 and .packet_loss == 25)))' >/dev/null
|
||||
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/bootstrap" | jq -e --arg id "$server_id" --arg task_id "$latency_task_id" '.servers | any(.id == $id and .cpu == 18.5 and .gpu_usage == 32.5 and .disk_await_ms == 1.4 and (.gpus | length) == 1 and (.disks | length) == 1 and .disk_used == 21474836480 and .traffic_limit == 107374182400 and .net_rx_total == 2684354560 and .net_tx_total == 1342177280 and .price == 9.9 and (has("last_ip") | not) and (.latency | any(.task_id == $task_id and .latency_ms == 28.4 and .packet_loss == 25)))' >/dev/null
|
||||
|
||||
# Changing the reset day starts a new traffic cycle. Only growth after the first
|
||||
# sample in that cycle counts toward usage.
|
||||
request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' -X PATCH \
|
||||
--data "$(printf '%s' "$server_input" | jq '.reset_day=2')" \
|
||||
"$MONITOR_BASE_URL/api/admin/servers/$server_id" >/dev/null
|
||||
cycle_report=$(printf '%s' "$report" | jq '.samples = [
|
||||
(.samples[0] | .timestamp += 5 | .net_rx_total=805306368 | .net_tx_total=536870912),
|
||||
(.samples[0] | .timestamp += 6 | .net_rx_total=1073741824 | .net_tx_total=805306368)
|
||||
]')
|
||||
request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
|
||||
--data "$cycle_report" "$MONITOR_BASE_URL/api/agent/report" | jq -e '.collect_interval == 5' >/dev/null
|
||||
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/bootstrap" | \
|
||||
jq -e --arg id "$server_id" '.servers | any(.id == $id and .net_rx_total == 268435456 and .net_tx_total == 268435456)' >/dev/null
|
||||
|
||||
# Exercise the protocol maximum so batch aggregation and request-size limits
|
||||
# stay covered when an Agent flushes a full pending queue.
|
||||
stress_report_file=$(mktemp "${TMPDIR:-/tmp}/nodeflare-stress.XXXXXX")
|
||||
printf '%s' "$report" | jq '(now | floor) as $start | .samples = [
|
||||
range(0; 720) as $index |
|
||||
(.samples[0] |
|
||||
.timestamp=($start + $index + 10) |
|
||||
.net_rx_total=(1073741824 + (($index + 1) * 4096)) |
|
||||
.net_tx_total=(805306368 + (($index + 1) * 2048)))
|
||||
]' >"$stress_report_file"
|
||||
request -H "Authorization: Bearer $agent_token" -H 'Content-Type: application/json' \
|
||||
--data-binary "@$stress_report_file" "$MONITOR_BASE_URL/api/agent/report" | jq -e '.collect_interval == 5' >/dev/null
|
||||
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/history/$server_id?hours=1" | jq -e '.points | length >= 1 and any(.gpu_usage == 32.5)' >/dev/null
|
||||
history_cache_header=$(monitor_curl --silent --dump-header - --output /dev/null \
|
||||
-H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/history/$server_id?hours=1" | \
|
||||
@@ -174,7 +217,7 @@ request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/admin/ale
|
||||
request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' -X PATCH \
|
||||
--data "$(printf '%s' "$server_input" | jq '.hidden=true')" \
|
||||
"$MONITOR_BASE_URL/api/admin/servers/$server_id" >/dev/null
|
||||
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/servers" | \
|
||||
request -H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/bootstrap" | \
|
||||
jq -e --arg id "$server_id" '.servers | all(.id != $id)' >/dev/null
|
||||
hidden_history_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
-H "Authorization: Bearer $admin_token" \
|
||||
|
||||
@@ -330,12 +330,6 @@ mod tests {
|
||||
issued_at + ADMIN_SESSION_SECONDS + 1
|
||||
));
|
||||
assert!(!verify_admin_jwt(&token, "different-secret", issued_at));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_tampered_admin_jwt() {
|
||||
let issued_at = 1_800_000_000;
|
||||
let token = create_admin_jwt_with_secret("d1-password-hash", issued_at).expect("jwt");
|
||||
let tampered = format!("{}x", token);
|
||||
assert!(!verify_admin_jwt(&tampered, "d1-password-hash", issued_at));
|
||||
}
|
||||
|
||||
+1
-5
@@ -368,14 +368,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_durable_objects_hibernation_invocations() {
|
||||
fn calculates_durable_objects_billable_requests() {
|
||||
assert!(is_hibernation_invocation_type("webSocketMessage"));
|
||||
assert!(is_hibernation_invocation_type("hibernationWakeup"));
|
||||
assert!(!is_hibernation_invocation_type("fetch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculates_durable_objects_billable_requests() {
|
||||
let summary = summarize_durable_objects(
|
||||
vec![
|
||||
DurableObjectsInvocationGroup {
|
||||
|
||||
+176
-189
@@ -1,4 +1,4 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use worker::{wasm_bindgen::JsValue, D1Database, Date, Result};
|
||||
@@ -6,7 +6,6 @@ use worker::{wasm_bindgen::JsValue, D1Database, Date, Result};
|
||||
use crate::models::{AgentLatencyResult, AgentLatencyTask, LatencyTaskInput};
|
||||
|
||||
pub const MAX_LATENCY_TASKS: usize = 128;
|
||||
const MAX_HISTORY_ROWS_PER_REPORT: usize = 4096;
|
||||
const MAX_HISTORY_RESPONSE_ROWS: i64 = 4000;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -26,12 +25,96 @@ struct AssignmentRow {
|
||||
server_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
struct StoredLatencyResult {
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub(crate) struct StoredLatencyResult {
|
||||
task_id: String,
|
||||
timestamp: i64,
|
||||
latency_ms: f64,
|
||||
packet_loss: f64,
|
||||
latest_timestamp: i64,
|
||||
latest_latency_ms: f64,
|
||||
latest_packet_loss: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
// Tuple layout keeps Durable Object WebSocket attachments below Cloudflare's
|
||||
// 16 KiB serialized-attachment limit even with all 128 latency tasks enabled.
|
||||
struct LatencyMetricAggregate(u64, u64, f64, f64, i64, f64, f64);
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub struct LatencyMetricAggregates {
|
||||
#[serde(rename = "v")]
|
||||
values: BTreeMap<String, LatencyMetricAggregate>,
|
||||
}
|
||||
|
||||
impl LatencyMetricAggregates {
|
||||
pub fn extend(&mut self, results: &[AgentLatencyResult], received_at: i64) {
|
||||
for result in results {
|
||||
if !self.values.contains_key(&result.task_id) && self.values.len() >= MAX_LATENCY_TASKS
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let timestamp = if (result.timestamp - received_at).abs() <= 86_400 {
|
||||
result.timestamp
|
||||
} else {
|
||||
received_at
|
||||
};
|
||||
let aggregate = self.values.entry(result.task_id.clone()).or_default();
|
||||
aggregate.0 = aggregate.0.saturating_add(1);
|
||||
aggregate.3 += result.packet_loss;
|
||||
if result.latency_ms >= 0.0 {
|
||||
aggregate.1 = aggregate.1.saturating_add(1);
|
||||
aggregate.2 += result.latency_ms;
|
||||
}
|
||||
if timestamp >= aggregate.4 {
|
||||
aggregate.4 = timestamp;
|
||||
aggregate.5 = result.latency_ms;
|
||||
aggregate.6 = result.packet_loss;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn merge(&mut self, other: Self) {
|
||||
for (task_id, other) in other.values {
|
||||
if !self.values.contains_key(&task_id) && self.values.len() >= MAX_LATENCY_TASKS {
|
||||
continue;
|
||||
}
|
||||
let aggregate = self.values.entry(task_id).or_default();
|
||||
aggregate.0 = aggregate.0.saturating_add(other.0);
|
||||
aggregate.1 = aggregate.1.saturating_add(other.1);
|
||||
aggregate.2 += other.2;
|
||||
aggregate.3 += other.3;
|
||||
if other.4 > aggregate.4 {
|
||||
aggregate.4 = other.4;
|
||||
aggregate.5 = other.5;
|
||||
aggregate.6 = other.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rows_matching(&self, mut include: impl FnMut(&String) -> bool) -> Vec<StoredLatencyResult> {
|
||||
let mut history = Vec::new();
|
||||
for (task_id, aggregate) in self.values.iter().filter(|(task_id, _)| include(task_id)) {
|
||||
history.push(StoredLatencyResult {
|
||||
task_id: task_id.clone(),
|
||||
timestamp: aggregate.4 / 60 * 60,
|
||||
latency_ms: if aggregate.1 == 0 {
|
||||
-1.0
|
||||
} else {
|
||||
aggregate.2 / aggregate.1 as f64
|
||||
},
|
||||
packet_loss: aggregate.3 / aggregate.0.max(1) as f64,
|
||||
latest_timestamp: aggregate.4,
|
||||
latest_latency_ms: aggregate.5,
|
||||
latest_packet_loss: aggregate.6,
|
||||
});
|
||||
}
|
||||
history
|
||||
}
|
||||
|
||||
pub fn stored_json(&self) -> Result<String> {
|
||||
Ok(serde_json::to_string(&self.rows_matching(|_| true))?)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -153,10 +236,10 @@ pub async fn create_task(
|
||||
number(timestamp),
|
||||
])?,
|
||||
db.prepare(
|
||||
"INSERT INTO latency_task_servers(task_id, server_id) \
|
||||
SELECT ?1, CAST(value AS TEXT) FROM json_each(?2)",
|
||||
"INSERT INTO latency_task_servers(task_id, server_id, assigned_at) \
|
||||
SELECT ?1, CAST(value AS TEXT), ?3 FROM json_each(?2)",
|
||||
)
|
||||
.bind(&[text(id), text(&server_ids)])?,
|
||||
.bind(&[text(id), text(&server_ids), number(timestamp)])?,
|
||||
];
|
||||
db.batch(statements).await?;
|
||||
Ok(())
|
||||
@@ -198,43 +281,22 @@ pub async fn update_task(
|
||||
JsValue::from_bool(input.default_enabled),
|
||||
number(timestamp),
|
||||
])?,
|
||||
db.prepare("DELETE FROM latency_task_servers WHERE task_id = ?1")
|
||||
.bind(&[text(id)])?,
|
||||
db.prepare(
|
||||
"INSERT INTO latency_task_servers(task_id, server_id) \
|
||||
SELECT ?1, CAST(value AS TEXT) FROM json_each(?2)",
|
||||
"DELETE FROM latency_task_servers WHERE task_id = ?1 AND server_id NOT IN ( \
|
||||
SELECT CAST(value AS TEXT) FROM json_each(?2) \
|
||||
)",
|
||||
)
|
||||
.bind(&[text(id), text(&server_ids)])?,
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO latency_task_servers(task_id, server_id, assigned_at) \
|
||||
SELECT ?1, CAST(value AS TEXT), ?3 FROM json_each(?2)",
|
||||
)
|
||||
.bind(&[text(id), text(&server_ids), number(timestamp)])?,
|
||||
];
|
||||
if reset_history {
|
||||
statements.push(
|
||||
db.prepare("DELETE FROM latency_latest WHERE task_id = ?1")
|
||||
.bind(&[text(id)])?,
|
||||
);
|
||||
statements.push(
|
||||
db.prepare("DELETE FROM latency_history WHERE task_id = ?1")
|
||||
.bind(&[text(id)])?,
|
||||
);
|
||||
} else {
|
||||
statements.push(
|
||||
db.prepare(
|
||||
"DELETE FROM latency_latest WHERE task_id = ?1 AND NOT EXISTS ( \
|
||||
SELECT 1 FROM latency_task_servers a \
|
||||
WHERE a.task_id = latency_latest.task_id \
|
||||
AND a.server_id = latency_latest.server_id \
|
||||
)",
|
||||
)
|
||||
.bind(&[text(id)])?,
|
||||
);
|
||||
statements.push(
|
||||
db.prepare(
|
||||
"DELETE FROM latency_history WHERE task_id = ?1 AND NOT EXISTS ( \
|
||||
SELECT 1 FROM latency_task_servers a \
|
||||
WHERE a.task_id = latency_history.task_id \
|
||||
AND a.server_id = latency_history.server_id \
|
||||
)",
|
||||
)
|
||||
.bind(&[text(id)])?,
|
||||
db.prepare("UPDATE latency_task_servers SET assigned_at = ?2 WHERE task_id = ?1")
|
||||
.bind(&[text(id), number(timestamp)])?,
|
||||
);
|
||||
}
|
||||
db.batch(statements).await?;
|
||||
@@ -252,150 +314,65 @@ pub async fn delete_task(db: &D1Database, id: &str) -> Result<bool> {
|
||||
|
||||
pub async fn assign_defaults(db: &D1Database, server_id: &str) -> Result<()> {
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO latency_task_servers(task_id, server_id) \
|
||||
SELECT id, ?1 FROM latency_tasks WHERE default_enabled = 1",
|
||||
"INSERT OR IGNORE INTO latency_task_servers(task_id, server_id, assigned_at) \
|
||||
SELECT id, ?1, ?2 FROM latency_tasks WHERE default_enabled = 1",
|
||||
)
|
||||
.bind(&[text(server_id)])?
|
||||
.bind(&[text(server_id), number(crate::now())])?
|
||||
.run()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn save_results(
|
||||
db: &D1Database,
|
||||
server_id: &str,
|
||||
results: &[AgentLatencyResult],
|
||||
received_at: i64,
|
||||
) -> Result<()> {
|
||||
if results.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let assigned: HashSet<String> = tasks_for_server(db, server_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|task| task.id)
|
||||
.collect();
|
||||
let (latest, history) = compact_results(results, &assigned, received_at);
|
||||
if latest.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let latest_json = serde_json::to_string(&latest)?;
|
||||
let history_json = serde_json::to_string(&history)?;
|
||||
let latest_statement = db
|
||||
.prepare(
|
||||
"INSERT INTO latency_latest( \
|
||||
task_id, server_id, timestamp, latency_ms, packet_loss \
|
||||
) SELECT \
|
||||
json_extract(value, '$.task_id'), ?1, \
|
||||
CAST(json_extract(value, '$.timestamp') AS INTEGER), \
|
||||
CAST(json_extract(value, '$.latency_ms') AS REAL), \
|
||||
CAST(json_extract(value, '$.packet_loss') AS REAL) \
|
||||
FROM json_each(?2) WHERE true \
|
||||
ON CONFLICT(task_id, server_id) DO UPDATE SET \
|
||||
timestamp=excluded.timestamp, latency_ms=excluded.latency_ms, \
|
||||
packet_loss=excluded.packet_loss \
|
||||
WHERE excluded.timestamp >= latency_latest.timestamp",
|
||||
)
|
||||
.bind(&[text(server_id), text(&latest_json)])?;
|
||||
let history_statement = db
|
||||
.prepare(
|
||||
"INSERT INTO latency_history( \
|
||||
task_id, server_id, timestamp, latency_ms, packet_loss \
|
||||
) SELECT \
|
||||
json_extract(value, '$.task_id'), ?1, \
|
||||
CAST(json_extract(value, '$.timestamp') AS INTEGER), \
|
||||
CAST(json_extract(value, '$.latency_ms') AS REAL), \
|
||||
CAST(json_extract(value, '$.packet_loss') AS REAL) \
|
||||
FROM json_each(?2) WHERE true \
|
||||
ON CONFLICT(task_id, server_id, timestamp) DO UPDATE SET \
|
||||
latency_ms=excluded.latency_ms, packet_loss=excluded.packet_loss",
|
||||
)
|
||||
.bind(&[text(server_id), text(&history_json)])?;
|
||||
db.batch(vec![latest_statement, history_statement]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn compact_results(
|
||||
results: &[AgentLatencyResult],
|
||||
assigned: &HashSet<String>,
|
||||
assigned: &std::collections::HashSet<String>,
|
||||
received_at: i64,
|
||||
) -> (Vec<StoredLatencyResult>, Vec<StoredLatencyResult>) {
|
||||
let mut latest: HashMap<String, StoredLatencyResult> = HashMap::new();
|
||||
let mut history = std::collections::BTreeMap::new();
|
||||
for result in results
|
||||
.iter()
|
||||
.filter(|result| assigned.contains(&result.task_id))
|
||||
{
|
||||
let timestamp = if (result.timestamp - received_at).abs() <= 86_400 {
|
||||
result.timestamp
|
||||
} else {
|
||||
received_at
|
||||
};
|
||||
let stored = StoredLatencyResult {
|
||||
task_id: result.task_id.clone(),
|
||||
timestamp,
|
||||
latency_ms: result.latency_ms,
|
||||
packet_loss: result.packet_loss,
|
||||
};
|
||||
if latest
|
||||
.get(&result.task_id)
|
||||
.is_none_or(|current| timestamp >= current.timestamp)
|
||||
{
|
||||
latest.insert(result.task_id.clone(), stored.clone());
|
||||
}
|
||||
let minute = timestamp / 60 * 60;
|
||||
history.insert(
|
||||
(minute, result.task_id.clone()),
|
||||
StoredLatencyResult {
|
||||
timestamp: minute,
|
||||
..stored
|
||||
},
|
||||
);
|
||||
}
|
||||
let mut latest = latest.into_values().collect::<Vec<_>>();
|
||||
latest.sort_by(|left, right| left.task_id.cmp(&right.task_id));
|
||||
let skip = history.len().saturating_sub(MAX_HISTORY_ROWS_PER_REPORT);
|
||||
let history = history
|
||||
.into_values()
|
||||
.skip(skip)
|
||||
.collect::<Vec<StoredLatencyResult>>();
|
||||
(latest, history)
|
||||
) -> Vec<StoredLatencyResult> {
|
||||
let mut aggregates = LatencyMetricAggregates::default();
|
||||
aggregates.extend(results, received_at);
|
||||
aggregates.rows_matching(|task_id| assigned.contains(task_id))
|
||||
}
|
||||
|
||||
pub async fn latest_all(db: &D1Database) -> Result<Vec<LatencySample>> {
|
||||
db.prepare(
|
||||
"SELECT a.task_id, a.server_id, t.name, t.task_type, t.target, t.port, \
|
||||
COALESCE(l.timestamp, 0) AS timestamp, \
|
||||
COALESCE(l.latency_ms, -1) AS latency_ms, \
|
||||
COALESCE(l.packet_loss, -1) AS packet_loss \
|
||||
FROM latency_task_servers a \
|
||||
INNER JOIN latency_tasks t ON t.id = a.task_id \
|
||||
LEFT JOIN latency_latest l \
|
||||
ON l.task_id = a.task_id AND l.server_id = a.server_id \
|
||||
ORDER BY t.sort_order ASC, t.created_at ASC",
|
||||
)
|
||||
.all()
|
||||
.await?
|
||||
.results()
|
||||
db.prepare(latest_query(None)).all().await?.results()
|
||||
}
|
||||
|
||||
pub async fn latest_for_server(db: &D1Database, server_id: &str) -> Result<Vec<LatencySample>> {
|
||||
db.prepare(
|
||||
"SELECT a.task_id, a.server_id, t.name, t.task_type, t.target, t.port, \
|
||||
COALESCE(l.timestamp, 0) AS timestamp, \
|
||||
COALESCE(l.latency_ms, -1) AS latency_ms, \
|
||||
COALESCE(l.packet_loss, -1) AS packet_loss \
|
||||
FROM latency_task_servers a \
|
||||
INNER JOIN latency_tasks t ON t.id = a.task_id \
|
||||
LEFT JOIN latency_latest l \
|
||||
ON l.task_id = a.task_id AND l.server_id = a.server_id \
|
||||
WHERE a.server_id = ?1 \
|
||||
ORDER BY t.sort_order ASC, t.created_at ASC",
|
||||
db.prepare(latest_query(Some("a.server_id = ?1")))
|
||||
.bind(&[text(server_id)])?
|
||||
.all()
|
||||
.await?
|
||||
.results()
|
||||
}
|
||||
|
||||
fn latest_query(filter: Option<&str>) -> String {
|
||||
let filter = filter.map_or(String::new(), |value| format!("WHERE {value}"));
|
||||
format!(
|
||||
"WITH assignments AS ( \
|
||||
SELECT a.task_id, a.server_id, a.assigned_at, t.name, t.task_type, \
|
||||
t.target, t.port, t.sort_order, t.created_at \
|
||||
FROM latency_task_servers a \
|
||||
INNER JOIN latency_tasks t ON t.id = a.task_id {filter} \
|
||||
), latest AS ( \
|
||||
SELECT assignments.*, COALESCE( \
|
||||
(SELECT j.value FROM metric_history h, json_each(h.latency_json) j \
|
||||
WHERE h.server_id = assignments.server_id \
|
||||
AND json_extract(j.value, '$.task_id') = assignments.task_id \
|
||||
AND CAST(json_extract(j.value, '$.latest_timestamp') AS INTEGER) >= assignments.assigned_at \
|
||||
ORDER BY h.timestamp DESC LIMIT 1), \
|
||||
(SELECT j.value FROM metric_history_hourly h, json_each(h.latency_json) j \
|
||||
WHERE h.server_id = assignments.server_id \
|
||||
AND json_extract(j.value, '$.task_id') = assignments.task_id \
|
||||
AND CAST(json_extract(j.value, '$.latest_timestamp') AS INTEGER) >= assignments.assigned_at \
|
||||
ORDER BY h.timestamp DESC LIMIT 1) \
|
||||
) AS result FROM assignments \
|
||||
) SELECT task_id, server_id, name, task_type, target, port, \
|
||||
COALESCE(CAST(json_extract(result, '$.latest_timestamp') AS INTEGER), 0) AS timestamp, \
|
||||
COALESCE(CAST(json_extract(result, '$.latest_latency_ms') AS REAL), -1) AS latency_ms, \
|
||||
COALESCE(CAST(json_extract(result, '$.latest_packet_loss') AS REAL), -1) AS packet_loss \
|
||||
FROM latest ORDER BY sort_order ASC, created_at ASC"
|
||||
)
|
||||
.bind(&[text(server_id)])?
|
||||
.all()
|
||||
.await?
|
||||
.results()
|
||||
}
|
||||
|
||||
pub async fn history(db: &D1Database, server_id: &str, hours: i64) -> Result<Vec<LatencySample>> {
|
||||
@@ -411,16 +388,37 @@ pub async fn history(db: &D1Database, server_id: &str, hours: i64) -> Result<Vec
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let bucket = history_bucket_seconds(hours, task_count);
|
||||
let source = if hours <= 24 {
|
||||
"SELECT h.server_id, h.timestamp, json_extract(j.value, '$.task_id') AS task_id, \
|
||||
CAST(json_extract(j.value, '$.latency_ms') AS REAL) AS latency_ms, \
|
||||
CAST(json_extract(j.value, '$.packet_loss') AS REAL) AS packet_loss, \
|
||||
CAST(json_extract(j.value, '$.latest_timestamp') AS INTEGER) AS latest_timestamp \
|
||||
FROM metric_history h, json_each(h.latency_json) j"
|
||||
.to_string()
|
||||
} else {
|
||||
"SELECT h.server_id, h.timestamp, json_extract(j.value, '$.task_id') AS task_id, \
|
||||
CAST(json_extract(j.value, '$.latency_ms') AS REAL) AS latency_ms, \
|
||||
CAST(json_extract(j.value, '$.packet_loss') AS REAL) AS packet_loss, \
|
||||
CAST(json_extract(j.value, '$.latest_timestamp') AS INTEGER) AS latest_timestamp \
|
||||
FROM metric_history h, json_each(h.latency_json) j \
|
||||
UNION ALL \
|
||||
SELECT h.server_id, h.timestamp, json_extract(j.value, '$.task_id') AS task_id, \
|
||||
CAST(json_extract(j.value, '$.latency_ms') AS REAL) AS latency_ms, \
|
||||
CAST(json_extract(j.value, '$.packet_loss') AS REAL) AS packet_loss, \
|
||||
CAST(json_extract(j.value, '$.latest_timestamp') AS INTEGER) AS latest_timestamp \
|
||||
FROM metric_history_hourly h, json_each(h.latency_json) j"
|
||||
.to_string()
|
||||
};
|
||||
let query = format!(
|
||||
"SELECT h.task_id, h.server_id, t.name, t.task_type, t.target, t.port, \
|
||||
(h.timestamp / {bucket}) * {bucket} AS timestamp, \
|
||||
CASE WHEN SUM(CASE WHEN h.latency_ms >= 0 THEN 1 ELSE 0 END) > 0 \
|
||||
THEN AVG(CASE WHEN h.latency_ms >= 0 THEN h.latency_ms END) ELSE -1 END AS latency_ms, \
|
||||
AVG(h.packet_loss) AS packet_loss \
|
||||
FROM latency_history h INNER JOIN latency_tasks t ON t.id = h.task_id \
|
||||
FROM ({source}) h INNER JOIN latency_tasks t ON t.id = h.task_id \
|
||||
INNER JOIN latency_task_servers a \
|
||||
ON a.task_id = h.task_id AND a.server_id = h.server_id \
|
||||
WHERE h.server_id = ?1 AND h.timestamp >= ?2 \
|
||||
WHERE h.server_id = ?1 AND h.timestamp >= ?2 AND h.latest_timestamp >= a.assigned_at \
|
||||
GROUP BY h.task_id, h.server_id, t.name, t.task_type, t.target, t.port, h.timestamp / {bucket} \
|
||||
ORDER BY timestamp ASC, t.sort_order ASC LIMIT {MAX_HISTORY_RESPONSE_ROWS}"
|
||||
);
|
||||
@@ -447,22 +445,9 @@ fn history_bucket_seconds(hours: i64, task_count: i64) -> i64 {
|
||||
base.max(bounded)
|
||||
}
|
||||
|
||||
pub async fn cleanup_history(db: &D1Database, cutoff: i64) -> Result<()> {
|
||||
db.prepare("DELETE FROM latency_history WHERE timestamp < ?1")
|
||||
.bind(&[number(cutoff)])?
|
||||
.run()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_history(db: &D1Database) -> Result<()> {
|
||||
db.prepare("DELETE FROM latency_history").run().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{compact_results, history_bucket_seconds, MAX_HISTORY_ROWS_PER_REPORT};
|
||||
use super::{compact_results, history_bucket_seconds, MAX_LATENCY_TASKS};
|
||||
use crate::models::AgentLatencyResult;
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -489,16 +474,18 @@ mod tests {
|
||||
packet_loss: 0.0,
|
||||
},
|
||||
];
|
||||
let (latest, history) = compact_results(&results, &assigned, 150);
|
||||
assert_eq!(latest.len(), 1);
|
||||
assert_eq!(latest[0].timestamp, 149);
|
||||
let history = compact_results(&results, &assigned, 150);
|
||||
assert_eq!(history.len(), 1);
|
||||
assert_eq!(history[0].timestamp, 120);
|
||||
assert_eq!(history[0].latency_ms, 20.0);
|
||||
assert_eq!(history[0].latency_ms, 15.0);
|
||||
assert_eq!(history[0].packet_loss, 12.5);
|
||||
assert_eq!(history[0].latest_timestamp, 149);
|
||||
assert_eq!(history[0].latest_latency_ms, 20.0);
|
||||
assert_eq!(history[0].latest_packet_loss, 25.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_compacted_latency_history() {
|
||||
fn bounds_compacted_latency_history_by_task() {
|
||||
let assigned = (0..128)
|
||||
.map(|index| format!("task-{index}"))
|
||||
.collect::<HashSet<_>>();
|
||||
@@ -512,8 +499,8 @@ mod tests {
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let (_, history) = compact_results(&results, &assigned, results.last().unwrap().timestamp);
|
||||
assert_eq!(history.len(), MAX_HISTORY_ROWS_PER_REPORT);
|
||||
let history = compact_results(&results, &assigned, results.last().unwrap().timestamp);
|
||||
assert_eq!(history.len(), MAX_LATENCY_TASKS);
|
||||
assert_eq!(
|
||||
history.last().unwrap().timestamp,
|
||||
results.last().unwrap().timestamp / 60 * 60
|
||||
|
||||
+99
-17
@@ -28,11 +28,14 @@ use crate::models::{
|
||||
pub(crate) const ADMIN_HTML: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/admin.html"));
|
||||
pub(crate) const ADMIN_SCRIPT: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/admin.js"));
|
||||
pub(crate) const ADMIN_STYLE: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/admin.css"));
|
||||
const HISTORY_CACHE_SECONDS: i64 = 30;
|
||||
pub(crate) const API_JSON_MAX_BYTES: usize = 1024 * 1024;
|
||||
pub(crate) const AGENT_JSON_MAX_BYTES: usize = 16 * 1024 * 1024;
|
||||
pub(crate) const MAX_AGENT_SAMPLES: usize = 720;
|
||||
pub(crate) const MAX_AGENT_LATENCY_RESULTS: usize = 4096;
|
||||
pub(crate) const VERSION: &str = match option_env!("NODEFLARE_VERSION") {
|
||||
Some(version) => version,
|
||||
None => env!("CARGO_PKG_VERSION"),
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PublicServer {
|
||||
@@ -286,6 +289,11 @@ fn remote_theme_failure(message: &str) -> Result<Response> {
|
||||
secure_public_response(response)
|
||||
}
|
||||
|
||||
fn rewrite_remote_theme_index(html: &str, asset_prefix: &str) -> String {
|
||||
html.replace("\"/assets/", &format!("\"{asset_prefix}"))
|
||||
.replace("'/assets/", &format!("'{asset_prefix}"))
|
||||
}
|
||||
|
||||
async fn remote_theme_response(path: &str, base: &str) -> Result<Option<Response>> {
|
||||
let (remote_url, relative) =
|
||||
if path == "/" || path == "/index.html" || path.starts_with("/instance/") {
|
||||
@@ -300,7 +308,8 @@ async fn remote_theme_response(path: &str, base: &str) -> Result<Option<Response
|
||||
};
|
||||
let is_index = relative == "index.html";
|
||||
let cache = Cache::default();
|
||||
if let Ok(Some(response)) = cache.get(remote_url.as_str(), false).await {
|
||||
let cache_key = format!("{remote_url}?nodeflare-theme-cache=2");
|
||||
if let Ok(Some(response)) = cache.get(&cache_key, false).await {
|
||||
return Ok(Some(secure_public_response(mutable_response(response)?)?));
|
||||
}
|
||||
let request = Request::new(&remote_url, Method::Get)?;
|
||||
@@ -339,6 +348,13 @@ async fn remote_theme_response(path: &str, base: &str) -> Result<Option<Response
|
||||
Ok(Some(remote_theme_failure("远程主题资源大小无效")?))
|
||||
};
|
||||
};
|
||||
let body = if is_index {
|
||||
let html = String::from_utf8(body)
|
||||
.map_err(|_| Error::RustError("主题 index.html 不是 UTF-8 文本".to_string()))?;
|
||||
rewrite_remote_theme_index(&html, "/__theme-active/assets/").into_bytes()
|
||||
} else {
|
||||
body
|
||||
};
|
||||
let mut response = Response::from_bytes(body)?;
|
||||
let headers = response.headers_mut();
|
||||
headers.set("Cache-Control", "public, max-age=300")?;
|
||||
@@ -347,7 +363,7 @@ async fn remote_theme_response(path: &str, base: &str) -> Result<Option<Response
|
||||
}
|
||||
let mut response = secure_public_response(response)?;
|
||||
if let Ok(cached) = response.cloned() {
|
||||
let _ = cache.put(remote_url.as_str(), cached).await;
|
||||
let _ = cache.put(&cache_key, cached).await;
|
||||
}
|
||||
Ok(Some(response))
|
||||
}
|
||||
@@ -381,9 +397,7 @@ async fn remote_theme_preview_response(
|
||||
let html = String::from_utf8(body)
|
||||
.map_err(|_| Error::RustError("主题 index.html 不是 UTF-8 文本".to_string()))?;
|
||||
let asset_prefix = format!("{preview_prefix}/assets/");
|
||||
let html = html
|
||||
.replace("\"/assets/", &format!("\"{asset_prefix}"))
|
||||
.replace("'/assets/", &format!("'{asset_prefix}"));
|
||||
let html = rewrite_remote_theme_index(&html, &asset_prefix);
|
||||
let mut response = Response::from_html(html)?;
|
||||
response.headers_mut().set("Cache-Control", "no-store")?;
|
||||
secure_public_response(response)
|
||||
@@ -616,9 +630,14 @@ pub(crate) fn validate_server(input: &ServerInput) -> Option<&'static str> {
|
||||
if !(15..=3600).contains(&input.report_interval) {
|
||||
return Some("上报间隔应为 15 至 3600 秒");
|
||||
}
|
||||
if !(2..=60).contains(&input.collect_interval) || input.collect_interval > input.report_interval
|
||||
if !(1..=60).contains(&input.collect_interval) || input.collect_interval > input.report_interval
|
||||
{
|
||||
return Some("采样间隔应为 2 至 60 秒且不能大于上报间隔");
|
||||
return Some("采样间隔应为 1 至 60 秒且不能大于上报间隔");
|
||||
}
|
||||
if (input.report_interval + input.collect_interval - 1) / input.collect_interval
|
||||
> MAX_AGENT_SAMPLES as i64
|
||||
{
|
||||
return Some("每个上报周期最多采集 720 个样本");
|
||||
}
|
||||
if input.network_interface.chars().count() > 160 {
|
||||
return Some("统计网卡配置字段过长");
|
||||
@@ -853,7 +872,7 @@ fn rate_limit_binding(method: Method, path: &str) -> Option<&'static str> {
|
||||
if method == Method::Post && matches!(path, "/api/admin/login" | "/api/turnstile/verify") {
|
||||
Some("AUTH_RATE_LIMITER")
|
||||
} else if (method == Method::Post && path == "/api/agent/report")
|
||||
|| (method == Method::Get && path == "/api/agent/live")
|
||||
|| (method == Method::Get && matches!(path, "/api/agent/live" | "/api/agent/config"))
|
||||
{
|
||||
Some("AGENT_RATE_LIMITER")
|
||||
} else if path.starts_with("/api/") {
|
||||
@@ -927,6 +946,15 @@ pub(crate) fn history_cache_key(
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn history_cache_seconds(hours: i64) -> i64 {
|
||||
match hours {
|
||||
120.. => 10 * 60,
|
||||
60.. => 5 * 60,
|
||||
30.. => 3 * 60,
|
||||
_ => 60,
|
||||
}
|
||||
}
|
||||
|
||||
async fn cached_history_response(key: &str) -> Option<Response> {
|
||||
match Cache::default().get(key, false).await {
|
||||
Ok(Some(response)) => {
|
||||
@@ -946,7 +974,7 @@ async fn cached_history_response(key: &str) -> Option<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn store_history_response(key: &str, response: &mut Response) {
|
||||
async fn store_history_response(key: &str, response: &mut Response, ttl: i64) {
|
||||
if response.headers_mut().set("X-Cache", "MISS").is_err() {
|
||||
return;
|
||||
}
|
||||
@@ -957,7 +985,7 @@ async fn store_history_response(key: &str, response: &mut Response) {
|
||||
.headers_mut()
|
||||
.set(
|
||||
"Cache-Control",
|
||||
&format!("public, max-age={HISTORY_CACHE_SECONDS}"),
|
||||
&format!("public, max-age={}", ttl.clamp(60, 10 * 60)),
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
@@ -996,6 +1024,9 @@ async fn handle(req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||
if method == Method::Post && path == "/api/agent/report" {
|
||||
return routes::agent::report(req, env, ctx, &database).await;
|
||||
}
|
||||
if method == Method::Get && path == "/api/agent/config" {
|
||||
return routes::agent::config(&req, &database).await;
|
||||
}
|
||||
if method == Method::Get && path == "/api/agent/live" {
|
||||
return routes::agent::live_websocket(req, &env, &database).await;
|
||||
}
|
||||
@@ -1156,7 +1187,7 @@ async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
|
||||
console_error!("server renewal failed: {err}");
|
||||
}
|
||||
if let Ok(settings) = settings {
|
||||
if let Err(err) = notify::check_alerts(&database, &settings).await {
|
||||
if let Err(err) = notify::check_alerts(&database, &env, &settings).await {
|
||||
console_error!("alert check failed: {err}");
|
||||
}
|
||||
}
|
||||
@@ -1165,12 +1196,13 @@ async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
allow_on_rate_limit_failure, history_cache_key, rate_limit_binding, 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, ADMIN_HTML, ADMIN_SCRIPT, ADMIN_STYLE,
|
||||
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,
|
||||
};
|
||||
use crate::{db::SECRET_MASK, models::LatencyTaskInput};
|
||||
use crate::{db::SECRET_MASK, models::LatencyTaskInput, models::ServerInput};
|
||||
use worker::{Method, Url};
|
||||
|
||||
#[test]
|
||||
@@ -1187,6 +1219,10 @@ mod tests {
|
||||
rate_limit_binding(Method::Get, "/api/agent/live"),
|
||||
Some("AGENT_RATE_LIMITER")
|
||||
);
|
||||
assert_eq!(
|
||||
rate_limit_binding(Method::Get, "/api/agent/config"),
|
||||
Some("AGENT_RATE_LIMITER")
|
||||
);
|
||||
assert_eq!(
|
||||
rate_limit_binding(Method::Get, "/api/history/node-a"),
|
||||
Some("API_RATE_LIMITER")
|
||||
@@ -1206,6 +1242,10 @@ mod tests {
|
||||
);
|
||||
assert!(same_origin("https://status.example", &request_url));
|
||||
assert!(!same_origin("https://other.example", &request_url));
|
||||
assert_eq!(history_cache_seconds(1), 60);
|
||||
assert_eq!(history_cache_seconds(30), 180);
|
||||
assert_eq!(history_cache_seconds(60), 300);
|
||||
assert_eq!(history_cache_seconds(120), 600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1217,6 +1257,39 @@ mod tests {
|
||||
assert!(!valid_server_price(f64::NAN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_samples_buffered_between_reports() {
|
||||
let mut server = ServerInput {
|
||||
name: "node-a".to_string(),
|
||||
region: String::new(),
|
||||
group_name: String::new(),
|
||||
tags: String::new(),
|
||||
hidden: false,
|
||||
expires_at: None,
|
||||
traffic_limit: 0,
|
||||
traffic_limit_type: "sum".to_string(),
|
||||
price: 0.0,
|
||||
billing_cycle: 30,
|
||||
currency: "CNY".to_string(),
|
||||
auto_renewal: false,
|
||||
network_interface: String::new(),
|
||||
reset_day: 1,
|
||||
report_interval: 3600,
|
||||
collect_interval: 5,
|
||||
rx_correction: 0,
|
||||
tx_correction: 0,
|
||||
agent_mirror: String::new(),
|
||||
offline_notify_disabled: false,
|
||||
auto_update: true,
|
||||
};
|
||||
assert_eq!(validate_server(&server), None);
|
||||
server.collect_interval = 4;
|
||||
assert_eq!(
|
||||
validate_server(&server),
|
||||
Some("每个上报周期最多采集 720 个样本")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_agent_mirrors() {
|
||||
for mirror in [
|
||||
@@ -1345,4 +1418,13 @@ mod tests {
|
||||
assert!(!ADMIN_SCRIPT.is_empty());
|
||||
assert!(!ADMIN_STYLE.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespaces_remote_theme_assets() {
|
||||
let html = r#"<script src="/assets/app.js"></script><link href='/assets/app.css'>"#;
|
||||
assert_eq!(
|
||||
rewrite_remote_theme_index(html, "/__theme-active/assets/"),
|
||||
r#"<script src="/__theme-active/assets/app.js"></script><link href='/__theme-active/assets/app.css'>"#
|
||||
);
|
||||
}
|
||||
}
|
||||
+1058
-110
File diff suppressed because it is too large.
Load diff
+3
-3
@@ -181,7 +181,7 @@ pub struct AgentReportBatch {
|
||||
pub samples: Vec<AgentReport>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AgentReport {
|
||||
pub timestamp: i64,
|
||||
@@ -294,7 +294,7 @@ pub struct ServerView {
|
||||
pub gpu_info: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub struct HistoryPoint {
|
||||
pub timestamp: i64,
|
||||
pub cpu: f64,
|
||||
@@ -351,7 +351,7 @@ pub struct AlertRuleInput {
|
||||
pub server_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct AlertRuleView {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
|
||||
+27
-9
@@ -1,9 +1,13 @@
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use worker::{wasm_bindgen::JsValue, D1Database, Fetch, Method, Request, RequestInit, Result};
|
||||
use worker::{
|
||||
console_warn, wasm_bindgen::JsValue, D1Database, Env, Fetch, Method, Request, RequestInit,
|
||||
Result,
|
||||
};
|
||||
|
||||
use crate::db::{self, SettingsView};
|
||||
use crate::live;
|
||||
|
||||
#[derive(Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
struct AlertState {
|
||||
@@ -84,7 +88,7 @@ pub async fn renew_servers(db_conn: &D1Database) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_alerts(db_conn: &D1Database, settings: &SettingsView) -> Result<()> {
|
||||
pub async fn check_alerts(db_conn: &D1Database, env: &Env, settings: &SettingsView) -> Result<()> {
|
||||
if !settings.notification_enabled || settings.notification_endpoint.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -136,7 +140,22 @@ pub async fn check_alerts(db_conn: &D1Database, settings: &SettingsView) -> Resu
|
||||
let mut current_resources = HashSet::new();
|
||||
let mut evaluated_resources = HashSet::new();
|
||||
let mut eligible_resources = HashSet::new();
|
||||
for rule in db::list_alert_rules(db_conn).await? {
|
||||
let rules = db::list_alert_rules(db_conn).await?;
|
||||
let mut values_by_rule = HashMap::new();
|
||||
if rules.iter().any(|rule| rule.enabled) {
|
||||
match live::evaluate_resource_alerts(env, &rules, &servers, current_time).await {
|
||||
Ok(values) => {
|
||||
for value in values {
|
||||
values_by_rule
|
||||
.entry(value.rule_id)
|
||||
.or_insert_with(Vec::new)
|
||||
.push(value.row);
|
||||
}
|
||||
}
|
||||
Err(error) => console_warn!("resource alert evaluation failed: {error:?}"),
|
||||
}
|
||||
}
|
||||
for rule in rules {
|
||||
if !rule.enabled {
|
||||
continue;
|
||||
}
|
||||
@@ -167,7 +186,10 @@ pub async fn check_alerts(db_conn: &D1Database, settings: &SettingsView) -> Resu
|
||||
} else {
|
||||
"%"
|
||||
};
|
||||
for value in db::alert_metric_values(db_conn, &rule, current_time).await? {
|
||||
for value in values_by_rule.remove(&rule.id).unwrap_or_default() {
|
||||
if !db::alert_window_covered(&value, rule.duration_minutes, current_time) {
|
||||
continue;
|
||||
}
|
||||
let key = format!("{}:{}", rule.id, value.server_id);
|
||||
evaluated_resources.insert(key.clone());
|
||||
if value.value >= rule.threshold {
|
||||
@@ -221,10 +243,6 @@ mod tests {
|
||||
assert_eq!(renewed_expiry(80 * day, 30, 100 * day), Some(110 * day));
|
||||
assert_eq!(renewed_expiry(101 * day, 30, 100 * day), Some(131 * day));
|
||||
assert_eq!(renewed_expiry(102 * day, 30, 100 * day), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saturates_pathological_expiry_values() {
|
||||
assert_eq!(renewed_expiry(i64::MIN, 1, i64::MAX), Some(i64::MAX));
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -135,6 +135,7 @@ pub(crate) async fn route(mut req: Request, ctx: &RouteContext) -> Result<RouteO
|
||||
}
|
||||
if method == Method::Delete && path == "/api/admin/history" {
|
||||
db::clear_history(&ctx.database).await?;
|
||||
live::clear_alert_windows(&ctx.env).await?;
|
||||
db::increment_setting(&ctx.database, "history_cache_version").await?;
|
||||
return handled(no_content());
|
||||
}
|
||||
@@ -303,6 +304,9 @@ async fn servers_batch_delete(req: &mut Request, ctx: &RouteContext) -> Result<R
|
||||
if let Err(error) = live::disconnect_agents(&ctx.env, &input.ids).await {
|
||||
console_error!("failed to disconnect deleted Agents: {error}");
|
||||
}
|
||||
if let Err(error) = live::remove_alert_samples(&ctx.env, &input.ids).await {
|
||||
console_error!("failed to remove deleted Agent alert samples: {error}");
|
||||
}
|
||||
no_content()
|
||||
}
|
||||
|
||||
@@ -372,6 +376,9 @@ async fn server_patch_delete(
|
||||
if let Err(error) = live::disconnect_agents(&ctx.env, std::slice::from_ref(&id)).await {
|
||||
console_error!("failed to disconnect deleted Agent: {error}");
|
||||
}
|
||||
if let Err(error) = live::remove_alert_samples(&ctx.env, std::slice::from_ref(&id)).await {
|
||||
console_error!("failed to remove deleted Agent alert samples: {error}");
|
||||
}
|
||||
return no_content();
|
||||
}
|
||||
let input: ServerInput = match request_json(req, API_JSON_MAX_BYTES).await {
|
||||
@@ -396,7 +403,7 @@ async fn themes_get(ctx: &RouteContext) -> Result<Response> {
|
||||
name: theme::BUILTIN_THEME_NAME.to_string(),
|
||||
description: "默认主题".to_string(),
|
||||
url: String::new(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
version: crate::VERSION.to_string(),
|
||||
builtin: true,
|
||||
active: ctx.settings.active_theme_id == theme::BUILTIN_THEME_ID,
|
||||
}];
|
||||
|
||||
+57
-32
@@ -13,6 +13,33 @@ use crate::{
|
||||
AGENT_JSON_MAX_BYTES, MAX_AGENT_LATENCY_RESULTS, MAX_AGENT_SAMPLES,
|
||||
};
|
||||
|
||||
async fn config_response(
|
||||
database: &D1Database,
|
||||
server_id: &str,
|
||||
agent_config_hash: &str,
|
||||
received_at: i64,
|
||||
changed_status: u16,
|
||||
) -> Result<Response> {
|
||||
let Some(config) = db::agent_config(database, server_id).await? else {
|
||||
return error("节点不存在", 404);
|
||||
};
|
||||
let config_json = serde_json::to_string(&config)?;
|
||||
let config_hash = sha256_hex(&config_json);
|
||||
let mut response = if agent_config_hash == config_hash {
|
||||
no_content()?
|
||||
} else {
|
||||
crate::json(&config, changed_status)?
|
||||
};
|
||||
response
|
||||
.headers_mut()
|
||||
.set("X-Agent-Config-Sha256", &config_hash)?;
|
||||
response
|
||||
.headers_mut()
|
||||
.set("X-NodeFlare-Server-Time", &received_at.to_string())?;
|
||||
response.headers_mut().set("Cache-Control", "no-store")?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub(crate) async fn report(
|
||||
mut req: Request,
|
||||
env: Env,
|
||||
@@ -50,22 +77,25 @@ pub(crate) async fn report(
|
||||
if let Some(ip) = client_ip(&req).and_then(|value| value.parse::<IpAddr>().ok()) {
|
||||
db::update_last_ip(database, &server_id, &ip.to_string()).await?;
|
||||
}
|
||||
let latency_results = batch
|
||||
let latency_result_count = batch
|
||||
.samples
|
||||
.iter()
|
||||
.flat_map(|report| report.latency_results.iter())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if latency_results.len() > MAX_AGENT_LATENCY_RESULTS {
|
||||
.count();
|
||||
if latency_result_count > MAX_AGENT_LATENCY_RESULTS {
|
||||
return error("每批最多包含 4096 条延迟结果", 400);
|
||||
}
|
||||
let mut history_aggregate = db::HistoryMetricAggregate::default();
|
||||
history_aggregate.extend(&batch.samples);
|
||||
let alert_point = history_aggregate.point();
|
||||
db::save_reports(database, &server_id, &batch.samples).await?;
|
||||
latency::save_results(database, &server_id, &latency_results, received_at).await?;
|
||||
if let Some(point) = alert_point.as_ref() {
|
||||
if let Err(error) = live::record_alert_sample(&env, &server_id, point).await {
|
||||
worker::console_warn!("resource alert sample write failed: {error:?}");
|
||||
}
|
||||
}
|
||||
|
||||
let public_dashboard = db::get_setting(database, "public_dashboard")
|
||||
.await?
|
||||
.is_none_or(|value| value == "true");
|
||||
if identity.hidden == 0 && public_dashboard {
|
||||
if identity.hidden == 0 {
|
||||
let server = db::get_server(database, &server_id, false).await?;
|
||||
let samples = latency::latest_for_server(database, &server_id).await?;
|
||||
let payload = server
|
||||
@@ -84,30 +114,22 @@ pub(crate) async fn report(
|
||||
});
|
||||
}
|
||||
}
|
||||
let Some(config) = db::agent_config(database, &server_id).await? else {
|
||||
return error("节点不存在", 404);
|
||||
config_response(database, &server_id, &agent_config_hash, received_at, 202).await
|
||||
}
|
||||
|
||||
pub(crate) async fn config(req: &Request, database: &D1Database) -> Result<Response> {
|
||||
let agent_config_hash = req
|
||||
.headers()
|
||||
.get("X-Agent-Config-Sha256")?
|
||||
.unwrap_or_default();
|
||||
let token = match bearer_token(req) {
|
||||
Some(value) => value,
|
||||
None => return error("缺少 Agent Token", 401),
|
||||
};
|
||||
let config_json = serde_json::to_string(&config)?;
|
||||
let config_hash = sha256_hex(&config_json);
|
||||
if agent_config_hash == config_hash {
|
||||
let mut response = no_content()?;
|
||||
response
|
||||
.headers_mut()
|
||||
.set("X-Agent-Config-Sha256", &config_hash)?;
|
||||
response
|
||||
.headers_mut()
|
||||
.set("X-NodeFlare-Server-Time", &received_at.to_string())?;
|
||||
response.headers_mut().set("Cache-Control", "no-store")?;
|
||||
return Ok(response);
|
||||
}
|
||||
let mut response = crate::json(&config, 202)?;
|
||||
response
|
||||
.headers_mut()
|
||||
.set("X-Agent-Config-Sha256", &config_hash)?;
|
||||
response
|
||||
.headers_mut()
|
||||
.set("X-NodeFlare-Server-Time", &received_at.to_string())?;
|
||||
Ok(response)
|
||||
let Some(identity) = db::get_agent_identity(database, &token).await? else {
|
||||
return error("Agent Token 无效", 401);
|
||||
};
|
||||
config_response(database, &identity.id, &agent_config_hash, now(), 200).await
|
||||
}
|
||||
|
||||
pub(crate) async fn live_websocket(
|
||||
@@ -122,6 +144,9 @@ pub(crate) async fn live_websocket(
|
||||
let Some(identity) = db::get_agent_identity(database, &token).await? else {
|
||||
return error("Agent Token 无效", 401);
|
||||
};
|
||||
if let Some(ip) = client_ip(&req).and_then(|value| value.parse::<IpAddr>().ok()) {
|
||||
db::update_last_ip(database, &identity.id, &ip.to_string()).await?;
|
||||
}
|
||||
let Some(context) = db::agent_live_context(database, &identity.id).await? else {
|
||||
return error("节点不存在", 404);
|
||||
};
|
||||
|
||||
+93
-52
@@ -10,15 +10,18 @@ use crate::models::LoginRequest;
|
||||
use crate::routes::{RouteContext, RouteOutcome};
|
||||
use crate::turnstile;
|
||||
use crate::{
|
||||
cached_history_response, db, error, history_cache_key, json, latency, live, no_content, now,
|
||||
public_server, request_json, requested_hours, server_id, set_admin_session_cookie,
|
||||
store_history_response, API_JSON_MAX_BYTES,
|
||||
cached_history_response, db, error, history_cache_key, history_cache_seconds, json, latency,
|
||||
live, no_content, now, public_server, request_json, requested_hours, server_id,
|
||||
set_admin_session_cookie, store_history_response, API_JSON_MAX_BYTES,
|
||||
};
|
||||
|
||||
pub(crate) async fn route(mut req: Request, ctx: &RouteContext) -> Result<RouteOutcome> {
|
||||
let method = req.method();
|
||||
let path = req.path();
|
||||
|
||||
if method == Method::Get && path == "/api/bootstrap" {
|
||||
return Ok(RouteOutcome::Handled(bootstrap(ctx).await?));
|
||||
}
|
||||
if method == Method::Get && path == "/api/config" {
|
||||
return Ok(RouteOutcome::Handled(config(ctx)?));
|
||||
}
|
||||
@@ -67,45 +70,97 @@ fn public_access_denied(ctx: &RouteContext) -> Option<Result<Response>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn config(ctx: &RouteContext) -> Result<Response> {
|
||||
fn config_value(ctx: &RouteContext) -> serde_json::Value {
|
||||
let settings = &ctx.settings;
|
||||
let mut response = json(
|
||||
&serde_json::json!({
|
||||
"site_name": settings.site_name,
|
||||
"site_description": settings.site_description,
|
||||
"site_announcement": settings.site_announcement,
|
||||
"favicon_url": settings.favicon_url,
|
||||
"locale": settings.locale,
|
||||
"public_dashboard": settings.public_dashboard,
|
||||
"offline_threshold_seconds": settings.offline_threshold_seconds,
|
||||
"history_retention_days": settings.history_retention_days,
|
||||
"default_theme": settings.default_theme,
|
||||
"active_theme_id": settings.active_theme_id,
|
||||
"background_url": settings.background_url,
|
||||
"theme_options": settings.theme_options,
|
||||
"show_search": settings.show_search,
|
||||
"show_groups": settings.show_groups,
|
||||
"show_stats": settings.show_stats,
|
||||
"show_assets": settings.show_assets,
|
||||
"show_traffic": settings.show_traffic,
|
||||
"show_speed": settings.show_speed,
|
||||
"show_price": settings.show_price,
|
||||
"show_expiry": settings.show_expiry,
|
||||
"show_latency": settings.show_latency,
|
||||
"show_uptime": settings.show_uptime,
|
||||
"turnstile_enabled": ctx.public_turnstile_enabled,
|
||||
"turnstile_login_enabled": ctx.login_protection_enabled || ctx.public_turnstile_enabled,
|
||||
"turnstile_site_key": ctx.turnstile_site_key,
|
||||
"password_client_salt": settings.password_client_salt
|
||||
}),
|
||||
200,
|
||||
)?;
|
||||
serde_json::json!({
|
||||
"site_name": settings.site_name,
|
||||
"site_description": settings.site_description,
|
||||
"site_announcement": settings.site_announcement,
|
||||
"favicon_url": settings.favicon_url,
|
||||
"locale": settings.locale,
|
||||
"public_dashboard": settings.public_dashboard,
|
||||
"offline_threshold_seconds": settings.offline_threshold_seconds,
|
||||
"history_retention_days": settings.history_retention_days,
|
||||
"default_theme": settings.default_theme,
|
||||
"active_theme_id": settings.active_theme_id,
|
||||
"background_url": settings.background_url,
|
||||
"theme_options": settings.theme_options,
|
||||
"show_search": settings.show_search,
|
||||
"show_groups": settings.show_groups,
|
||||
"show_stats": settings.show_stats,
|
||||
"show_assets": settings.show_assets,
|
||||
"show_traffic": settings.show_traffic,
|
||||
"show_speed": settings.show_speed,
|
||||
"show_price": settings.show_price,
|
||||
"show_expiry": settings.show_expiry,
|
||||
"show_latency": settings.show_latency,
|
||||
"show_uptime": settings.show_uptime,
|
||||
"turnstile_enabled": ctx.public_turnstile_enabled,
|
||||
"turnstile_login_enabled": ctx.login_protection_enabled || ctx.public_turnstile_enabled,
|
||||
"turnstile_site_key": ctx.turnstile_site_key,
|
||||
"password_client_salt": settings.password_client_salt
|
||||
})
|
||||
}
|
||||
|
||||
fn config(ctx: &RouteContext) -> Result<Response> {
|
||||
let mut response = json(&config_value(ctx), 200)?;
|
||||
response
|
||||
.headers_mut()
|
||||
.set("X-NodeFlare-Server-Time", &now().to_string())?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn server_values(ctx: &RouteContext) -> Result<Vec<serde_json::Value>> {
|
||||
let raw_servers = db::list_servers(&ctx.database, false).await?;
|
||||
let mut latency_by_server: HashMap<String, Vec<latency::LatencySample>> = HashMap::new();
|
||||
for sample in latency::latest_all(&ctx.database).await? {
|
||||
latency_by_server
|
||||
.entry(sample.server_id.clone())
|
||||
.or_default()
|
||||
.push(sample);
|
||||
}
|
||||
raw_servers
|
||||
.into_iter()
|
||||
.map(|server| {
|
||||
let samples = latency_by_server.remove(&server.id).unwrap_or_default();
|
||||
serde_json::to_value(public_server(server, samples)).map_err(Into::into)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn bootstrap(ctx: &RouteContext) -> Result<Response> {
|
||||
let access = if !ctx.turnstile_verified {
|
||||
"turnstile"
|
||||
} else if !ctx.settings.public_dashboard && !ctx.admin {
|
||||
"login"
|
||||
} else {
|
||||
"ok"
|
||||
};
|
||||
let config = config_value(ctx);
|
||||
if access != "ok" {
|
||||
return json(
|
||||
&serde_json::json!({
|
||||
"config": config,
|
||||
"access": access,
|
||||
"servers": [],
|
||||
"exchange_rates": null
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
let servers = server_values(ctx).await?;
|
||||
let exchange_rates = crate::exchange::current(&ctx.database, now()).await?;
|
||||
json(
|
||||
&serde_json::json!({
|
||||
"config": config,
|
||||
"access": access,
|
||||
"servers": servers,
|
||||
"exchange_rates": exchange_rates
|
||||
}),
|
||||
200,
|
||||
)
|
||||
}
|
||||
|
||||
async fn admin_login(req: &mut Request, ctx: &RouteContext) -> Result<Response> {
|
||||
let settings = &ctx.settings;
|
||||
if settings.admin_username.trim().is_empty() {
|
||||
@@ -217,21 +272,7 @@ async fn servers(ctx: &RouteContext) -> Result<Response> {
|
||||
if let Some(denied) = public_access_denied(ctx) {
|
||||
return denied;
|
||||
}
|
||||
let raw_servers = db::list_servers(&ctx.database, false).await?;
|
||||
let mut latency_by_server: HashMap<String, Vec<latency::LatencySample>> = HashMap::new();
|
||||
for sample in latency::latest_all(&ctx.database).await? {
|
||||
latency_by_server
|
||||
.entry(sample.server_id.clone())
|
||||
.or_default()
|
||||
.push(sample);
|
||||
}
|
||||
let servers: Vec<_> = raw_servers
|
||||
.into_iter()
|
||||
.map(|server| {
|
||||
let samples = latency_by_server.remove(&server.id).unwrap_or_default();
|
||||
public_server(server, samples)
|
||||
})
|
||||
.collect();
|
||||
let servers = server_values(ctx).await?;
|
||||
json(&serde_json::json!({ "servers": servers }), 200)
|
||||
}
|
||||
|
||||
@@ -266,7 +307,7 @@ async fn history(req: &Request, ctx: &RouteContext) -> Result<Response> {
|
||||
let points = db::history(&ctx.database, &id, hours).await?;
|
||||
let mut response = json(&serde_json::json!({ "points": points }), 200)?;
|
||||
if let Some(cache_key) = cache_key.as_deref() {
|
||||
store_history_response(cache_key, &mut response).await;
|
||||
store_history_response(cache_key, &mut response, history_cache_seconds(hours)).await;
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
@@ -306,7 +347,7 @@ async fn latency(req: &Request, ctx: &RouteContext) -> Result<Response> {
|
||||
200,
|
||||
)?;
|
||||
if let Some(cache_key) = cache_key.as_deref() {
|
||||
store_history_response(cache_key, &mut response).await;
|
||||
store_history_response(cache_key, &mut response, history_cache_seconds(hours)).await;
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
@@ -51,6 +51,24 @@ pub(crate) async fn route(req: Request, ctx: &RouteContext) -> Result<RouteOutco
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(relative) = path.strip_prefix("/__theme-active/") {
|
||||
if ctx.settings.active_theme_id == theme::BUILTIN_THEME_ID {
|
||||
return Ok(RouteOutcome::Handled(Response::error(
|
||||
"远程主题未启用",
|
||||
404,
|
||||
)?));
|
||||
}
|
||||
let Some(url) = db::theme_url(&ctx.database, &ctx.settings.active_theme_id).await? else {
|
||||
return Ok(RouteOutcome::Handled(Response::error("主题不存在", 404)?));
|
||||
};
|
||||
let remote_path = format!("/{relative}");
|
||||
return Ok(RouteOutcome::Handled(
|
||||
remote_theme_response(&remote_path, &url)
|
||||
.await?
|
||||
.unwrap_or(Response::error("主题资源不存在", 404)?),
|
||||
));
|
||||
}
|
||||
|
||||
if ctx.settings.active_theme_id != theme::BUILTIN_THEME_ID {
|
||||
if let Some(url) = db::theme_url(&ctx.database, &ctx.settings.active_theme_id).await? {
|
||||
if let Some(response) = remote_theme_response(&path, &url).await? {
|
||||
|
||||
+11
-1
@@ -13,7 +13,17 @@
|
||||
"directory": "./frontend/dist",
|
||||
"binding": "ASSETS",
|
||||
"not_found_handling": "single-page-application",
|
||||
"run_worker_first": true
|
||||
"run_worker_first": [
|
||||
"/",
|
||||
"/index.html",
|
||||
"/instance/*",
|
||||
"/api/*",
|
||||
"/admin",
|
||||
"/admin/*",
|
||||
"/admin-assets/*",
|
||||
"/__theme-active/*",
|
||||
"/__theme-preview/*"
|
||||
]
|
||||
},
|
||||
"d1_databases": [
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user