优化项目逻辑

This commit is contained in:
imengying committed 2026-08-18 00:44:00 +08:00
1 parent 97e4b3a5f1
commit ceda386010
29 files changed
+3306 -1253

No files matched your search

+42 -1
View File
@@ -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
View File
@@ -1,5 +1,5 @@
[build.env]
passthrough = ["NODEFLARE_AGENT_VERSION"]
passthrough = ["NODEFLARE_VERSION"]
[target.aarch64-unknown-linux-musl]
runner = "qemu-user"
+8
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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),
+3 -3
View File
@@ -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>
+7
View File
@@ -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
View File
@@ -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;
+4 -1
View File
@@ -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}
+7
View File
@@ -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 -2
View File
@@ -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
+7 -1
View File
@@ -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
+23
View File
@@ -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
View File
@@ -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" \
-6
View File
@@ -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
View File
@@ -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 {
+804 -380
View File
File diff suppressed because it is too large. Load diff
+176 -189
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+18
View File
@@ -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
View File
@@ -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": [
{