diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 2596506..f64aafa 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -128,11 +128,6 @@ const BILLING_CYCLES: Array<{ days: number; label: string }> = [ { days: 0, label: "一次性" }, ]; -// 流量限额输入:"-1" 表示不限,其余按字节单位解析。 -function parseTrafficLimit(raw: string): number | null { - return raw.trim() === "-1" ? -1 : parseByteSize(raw); -} - // last_ip 来自 CF-Connecting-IP,是裸 IP;v6 带冒号,v4 是四段数字。 function ipFamily(ip: string): "v4" | "v6" | null { const value = ip.trim(); @@ -314,14 +309,14 @@ export function AdminPanel({ } // 带单位输入(如 100 G / 0.5 T):解析不了的中间态留在框里,失焦回显最后一次有效值。 - function sizeInputProps(text: string, setText: (value: string) => void, commit: (bytes: number) => void, current: number, parse: (raw: string) => number | null = parseByteSize) { + function sizeInputProps(text: string, setText: (value: string) => void, commit: (bytes: number) => void, current: number) { return { value: text, inputMode: "decimal" as const, onChange: (event: ChangeEvent) => { const raw = event.target.value; setText(raw); - const parsed = parse(raw); + const parsed = parseByteSize(raw); if (parsed !== null) commit(parsed); }, onBlur: () => setText(formatByteSize(current)), @@ -766,11 +761,11 @@ export function AdminPanel({ {editing ?
节点配置

{editing === "new" ? "添加节点" : `编辑 · ${editing.name}`}

-
+
-
updateForm("auto_renewal", value)} /> updateForm("auto_update", value)} /> updateForm("hidden", value)} /> updateForm("offline_notify_disabled", value)} />
+
updateForm("auto_renewal", value)} /> updateForm("auto_update", value)} /> updateForm("hidden", value)} /> updateForm("offline_notify_disabled", value)} />
: null} diff --git a/frontend/src/components/NodeCard.tsx b/frontend/src/components/NodeCard.tsx index f53a04d..243f9ac 100644 --- a/frontend/src/components/NodeCard.tsx +++ b/frontend/src/components/NodeCard.tsx @@ -129,7 +129,9 @@ export function NodeCard({ server, config, onOpen }: { server: Server; config: C
- {config.show_uptime ? {ui(locale, `在线 ${Math.floor(number(server.uptime) / 86400)} 天`, `Online ${Math.floor(number(server.uptime) / 86400)} days`)} : null} + {/* 离线时不显示:uptime 是最后一次上报的存量值,遮罩只有 20% 底色且 + data-blur=off 时不模糊,「在线 N 天」会透过遮罩和上面的「离线」打脸。 */} + {config.show_uptime && online ? {ui(locale, `在线 ${Math.floor(number(server.uptime) / 86400)} 天`, `Online ${Math.floor(number(server.uptime) / 86400)} days`)} : null} {config.show_price && price ? {price} : null}
diff --git a/frontend/src/components/NodeDetails.tsx b/frontend/src/components/NodeDetails.tsx index d939ba7..52a3dd9 100644 --- a/frontend/src/components/NodeDetails.tsx +++ b/frontend/src/components/NodeDetails.tsx @@ -228,6 +228,17 @@ export function NodeDetails({ server, threshold, retentionDays, locale, demo = f return () => { active = false; }; }, [server.id, loadHours, demo, locale]); + // 「实时」档要跟着上报往前走,否则挂载之后这张图就冻住了。依赖只取 + // `server.timestamp`:`server` 每次渲染都是新对象,挂它会让 effect 空转。 + // 上面那个 fetch 落地时已经带了一个快照,这里只负责后续追加; + // `appendRealtimePoint` 自带去重、按 1 小时窗裁剪和 720 点封顶。 + const liveTimestamp = server.timestamp; + useEffect(() => { + if (demo || loadHours !== 0) return; + const point = historyPointFromServer(latestServerRef.current); + if (point) setPoints((current) => appendRealtimePoint(current, point)); + }, [demo, loadHours, liveTimestamp]); + useEffect(() => { setLatencyLoading(true); setLatencyError(""); diff --git a/frontend/src/format.test.ts b/frontend/src/format.test.ts index 1055091..e8623b3 100644 --- a/frontend/src/format.test.ts +++ b/frontend/src/format.test.ts @@ -15,7 +15,6 @@ describe("parseByteSize", () => { expect(parseByteSize("abc")).toBeNull(); expect(parseByteSize("")).toBeNull(); expect(parseByteSize("-5 G")).toBeNull(); - expect(parseByteSize("-1")).toBeNull(); }); }); @@ -26,8 +25,10 @@ describe("formatByteSize", () => { } }); - test("passes the unlimited sentinel through unchanged", () => { - expect(formatByteSize(-1)).toBe("-1"); + test("normalises negatives to zero", () => { + // 0 就是「不限」,没有单独的负数哨兵。 + expect(formatByteSize(0)).toBe("0"); + expect(formatByteSize(-1)).toBe("0"); }); }); diff --git a/frontend/src/format.ts b/frontend/src/format.ts index a8959b0..750fd71 100644 --- a/frontend/src/format.ts +++ b/frontend/src/format.ts @@ -40,9 +40,8 @@ export function parseByteSize(text: string): number | null { return factor === undefined ? null : Math.round(Number(match[1]) * factor); } -/** 字节数 → "100 G" 风格短文本,编辑框失焦时回显;-1 是「不限」哨兵,原样回显。 */ +/** 字节数 → "100 G" 风格短文本,编辑框失焦时回显。0 表示不限,负数归零。 */ export function formatByteSize(bytes: number): string { - if (bytes < 0) return "-1"; const size = Math.max(0, Math.round(number(bytes))); if (size === 0) return "0"; for (const [suffix, factor] of [["T", 1024 ** 4], ["G", 1024 ** 3], ["M", 1024 ** 2], ["K", 1024]] as const) { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 43439b8..0694793 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -313,8 +313,6 @@ export interface DatabaseStats { server_count: number; online_count: number; history_rows: number; - oldest_history: number | null; - newest_history: number | null; } interface CloudflareUsagePeriod { @@ -326,8 +324,6 @@ interface CloudflareUsagePeriod { durable_objects_hibernation_wakeups: number; durable_objects_inbound_websocket_messages: number; durable_objects_outbound_websocket_messages: number; - durable_objects_raw_requests: number; - durable_objects_requests_estimated: boolean; durable_objects_request_billing_ratio: number; durable_objects_duration: number; } diff --git a/src/cloudflare.rs b/src/cloudflare.rs index 03a0561..5fbb060 100644 --- a/src/cloudflare.rs +++ b/src/cloudflare.rs @@ -59,8 +59,6 @@ pub struct UsagePeriod { pub durable_objects_hibernation_wakeups: i64, pub durable_objects_inbound_websocket_messages: i64, pub durable_objects_outbound_websocket_messages: i64, - pub durable_objects_raw_requests: i64, - pub durable_objects_requests_estimated: bool, pub durable_objects_request_billing_ratio: i64, pub durable_objects_duration: f64, } @@ -186,7 +184,6 @@ struct DurableObjectsSummary { hibernation_wakeups: i64, inbound_websocket_messages: i64, outbound_websocket_messages: i64, - raw_requests: i64, billable_requests: i64, duration: f64, } @@ -216,9 +213,13 @@ fn summarize_durable_objects( let mut summary = DurableObjectsSummary::default(); for group in invocation_groups { let requests = group.sum.requests.max(0); - summary.raw_requests = summary.raw_requests.saturating_add(requests); if is_hibernation_invocation_type(&group.dimensions.invocation_type) { summary.hibernation_wakeups = summary.hibernation_wakeups.saturating_add(requests); + // Hibernating WebSocket messages are reported in the invocations + // dataset instead of periodic groups. Keep them in the total + // inbound count so the 20:1 billing ratio applies to both paths. + summary.inbound_websocket_messages = + summary.inbound_websocket_messages.saturating_add(requests); } else { summary.http_requests = summary.http_requests.saturating_add(requests); } @@ -331,8 +332,6 @@ async fn query_period(token: &str, account_id: &str, date: &str) -> Result, - pub newest_history: Option, } #[derive(Debug, Clone, Deserialize)] @@ -1781,28 +1779,19 @@ pub async fn database_stats(db: &D1Database, offline_threshold: i64) -> Result= ?1) AS online_count, ((SELECT COUNT(*) FROM metric_history) + (SELECT COUNT(*) FROM metric_history_old) + - (SELECT COUNT(*) FROM metric_history_hourly)) AS history_rows, - (SELECT MIN(timestamp) FROM ( - SELECT timestamp FROM metric_history UNION ALL - SELECT timestamp FROM metric_history_old UNION ALL - SELECT timestamp FROM metric_history_hourly - )) AS oldest_history, - (SELECT MAX(timestamp) FROM ( - SELECT timestamp FROM metric_history UNION ALL - SELECT timestamp FROM metric_history_old UNION ALL - SELECT timestamp FROM metric_history_hourly - )) AS newest_history"#, + (SELECT COUNT(*) FROM metric_history_hourly)) AS history_rows"#, ) .bind(&[number(cutoff)])? .first(None) diff --git a/src/lib.rs b/src/lib.rs index c511dcd..c1a72e2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -676,8 +676,8 @@ pub(crate) fn validate_server(input: &ServerInput) -> Option<&'static str> { if input.tags.chars().count() > 240 { return Some("标签字段过长"); } - if input.traffic_limit < -1 { - return Some("流量限额不能小于 -1(-1 表示不限)"); + if input.traffic_limit < 0 { + return Some("流量限额不能为负数"); } if !matches!( input.traffic_limit_type.as_str(), diff --git a/src/notify.rs b/src/notify.rs index f69928d..f9e3cf4 100644 --- a/src/notify.rs +++ b/src/notify.rs @@ -603,6 +603,11 @@ pub async fn renew_servers(db_conn: &D1Database) -> Result<()> { } fn renewed_expiry(expires_at: i64, billing_cycle_days: i64, current_time: i64) -> Option { + // billing_cycle = 0 是「一次性」,没有周期可续。不拦的话下面的 clamp(1, ..) + // 会把它当成 1 天,每跑一次 cron 就把到期日推一天,节点永远停在「剩 1 天」。 + if billing_cycle_days <= 0 { + return None; + } let renew_before = i128::from(current_time) + 86_400; let expires_at = i128::from(expires_at); if expires_at > renew_before { @@ -874,4 +879,16 @@ mod tests { assert_eq!(renewed_expiry(80 * day, 30, 100 * day), Some(110 * day)); assert_eq!(renewed_expiry(102 * day, 30, 100 * day), None); } + + #[test] + fn one_time_billing_cycle_never_renews() { + // 一次性(billing_cycle = 0)就算把自动续费开着也不该续:否则 clamp(1, ..) + // 把周期当成 1 天,每跑一次 cron 推一天,节点永远停在「剩 1 天」。 + let day = 86_400; + assert_eq!(renewed_expiry(80 * day, 0, 100 * day), None); + assert_eq!(renewed_expiry(102 * day, 0, 101 * day), None); + assert_eq!(renewed_expiry(80 * day, -5, 100 * day), None); + // 正常周期不受影响。 + assert_eq!(renewed_expiry(80 * day, 1, 100 * day), Some(102 * day)); + } }