优化数据库

This commit is contained in:
imengying committed 2026-09-01 00:33:47 +08:00
1 parent 155c2e30e1
commit 0ecd3128c1
10 files changed
+73 -48

No files matched your search

+4 -9
View File
@@ -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<HTMLInputElement>) => {
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 ? <div className="submodal-backdrop" role="presentation" onMouseDown={serverDialog.onBackdropMouseDown}><form ref={serverDialog.dialogRef} className="editor-modal glass-panel" role="dialog" aria-modal="true" aria-labelledby="server-editor-title" tabIndex={-1} onSubmit={saveServer}>
<header><div><span className="eyebrow">节点配置</span><h3 id="server-editor-title">{editing === "new" ? "添加节点" : `编辑 · ${editing.name}`}</h3></div></header>
<div className="form-grid"><label><span>名称</span><input autoFocus required value={form.name} onChange={(event) => updateForm("name", event.target.value)} /></label><label><span>地区代码</span><input maxLength={16} placeholder="CN / JP / DE" value={form.region} onChange={(event) => updateForm("region", event.target.value.toUpperCase())} /></label><label><span>分组</span><input value={form.group_name} onChange={(event) => updateForm("group_name", event.target.value)} /></label><label><span>标签</span><input placeholder="主力, 线路:BGP" value={form.tags} onChange={(event) => updateForm("tags", event.target.value)} /></label></div>
<div className="form-grid three"><label><span>流量限额(-1 不限)</span><input placeholder="如 100 G,不带单位按 GB" {...sizeInputProps(trafficLimitText, setTrafficLimitText, (bytes) => updateForm("traffic_limit", bytes), form.traffic_limit, parseTrafficLimit)} /></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 不限)</span><input placeholder="如 100 G,不带单位按 GB;0 不限" {...sizeInputProps(trafficLimitText, setTrafficLimitText, (bytes) => updateForm("traffic_limit", bytes), form.traffic_limit)} /></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 inputMode="decimal" value={priceText} onChange={(event) => updatePriceText(event.target.value)} onBlur={() => setPriceText(String(form.price))} /></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><select value={String(form.billing_cycle)} onChange={(event) => updateForm("billing_cycle", Number(event.target.value))}>{BILLING_CYCLES.map((cycle) => <option key={cycle.days} value={cycle.days}>{cycle.label}</option>)}{BILLING_CYCLES.every((cycle) => cycle.days !== form.billing_cycle) ? <option value={form.billing_cycle}>{form.billing_cycle} 天</option> : null}</select></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>下行流量当前值</span><input placeholder="如 500 G" {...sizeInputProps(rxCurrentText, setRxCurrentText, setRxCurrentBytes, rxCurrentBytes)} /></label><label><span>上行流量当前值</span><input placeholder="如 500 G" {...sizeInputProps(txCurrentText, setTxCurrentText, setTxCurrentBytes, txCurrentBytes)} /></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="settings-toggles editor-toggles"><Toggle label={form.billing_cycle <= 0 ? "自动续费(一次性不适用)" : "自动续费"} 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={15} />保存节点</button></div>
</form></div> : null}
+3 -1
View File
@@ -129,7 +129,9 @@ export function NodeCard({ server, config, onOpen }: { server: Server; config: C
<div className="node-body">
<div className="node-chips">
{config.show_uptime ? <span>{ui(locale, `在线 ${Math.floor(number(server.uptime) / 86400)} 天`, `Online ${Math.floor(number(server.uptime) / 86400)} days`)}</span> : null}
{/* 离线时不显示:uptime 是最后一次上报的存量值,遮罩只有 20% 底色且
data-blur=off 时不模糊,「在线 N 天」会透过遮罩和上面的「离线」打脸。 */}
{config.show_uptime && online ? <span>{ui(locale, `在线 ${Math.floor(number(server.uptime) / 86400)} 天`, `Online ${Math.floor(number(server.uptime) / 86400)} days`)}</span> : null}
{config.show_price && price ? <span title={price}>{price}</span> : null}
</div>
+11
View File
@@ -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("");
+4 -3
View File
@@ -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");
});
});
+1 -2
View File
@@ -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) {
-4
View File
@@ -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;
}
+23 -8
View File
@@ -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<Usage
durable_objects_hibernation_wakeups: durable_objects.hibernation_wakeups,
durable_objects_inbound_websocket_messages: durable_objects.inbound_websocket_messages,
durable_objects_outbound_websocket_messages: durable_objects.outbound_websocket_messages,
durable_objects_raw_requests: durable_objects.raw_requests,
durable_objects_requests_estimated: true,
durable_objects_request_billing_ratio: DURABLE_OBJECTS_WEBSOCKET_MESSAGE_BILLING_RATIO,
durable_objects_duration: durable_objects.duration,
})
@@ -404,12 +403,28 @@ mod tests {
);
assert_eq!(summary.http_requests, 7);
assert_eq!(summary.hibernation_wakeups, 3);
assert_eq!(summary.raw_requests, 10);
assert_eq!(summary.billable_requests, 8);
assert_eq!(summary.inbound_websocket_messages, 23);
assert_eq!(summary.billable_requests, 9);
assert_eq!(summary.outbound_websocket_messages, 11);
assert_eq!(summary.duration, 2.5);
assert_eq!(ceil_div_nonnegative(0, 20), 0);
assert_eq!(ceil_div_nonnegative(20, 20), 1);
assert_eq!(ceil_div_nonnegative(21, 20), 2);
}
#[test]
fn bills_hibernating_websocket_messages_at_twenty_to_one() {
let summary = summarize_durable_objects(
vec![DurableObjectsInvocationGroup {
sum: DurableObjectsInvocationSum { requests: 21 },
dimensions: DurableObjectsInvocationDimensions {
invocation_type: "webSocketMessage".to_string(),
},
}],
Vec::new(),
);
assert_eq!(summary.hibernation_wakeups, 21);
assert_eq!(summary.inbound_websocket_messages, 21);
assert_eq!(summary.billable_requests, 2);
}
}
+8 -19
View File
@@ -420,8 +420,6 @@ pub struct DatabaseStats {
pub server_count: i64,
pub online_count: i64,
pub history_rows: i64,
pub oldest_history: Option<i64>,
pub newest_history: Option<i64>,
}
#[derive(Debug, Clone, Deserialize)]
@@ -1781,28 +1779,19 @@ pub async fn database_stats(db: &D1Database, offline_threshold: i64) -> Result<D
db.prepare(
r#"SELECT
(SELECT COUNT(*) FROM servers) AS server_count,
-- 在线判定读 server_latest_state:它和 metric_history 在同一个 batch 里
-- 写同一个 latest_timestamp,且只随 servers 的外键级联删除——轮换 DROP
-- 掉分钟表不影响它,所以它是各来源里的最大值,主键又正好是 server_id。
-- 原来那版按服务器逐行 MAX(latest_timestamp) over (分钟表 UNION 上一代),
-- 每台一次全表扫;这版是主键点查。
(SELECT COUNT(*) FROM servers s WHERE COALESCE(
(SELECT MAX(latest_timestamp) FROM (
SELECT server_id, latest_timestamp FROM metric_history UNION ALL
SELECT server_id, latest_timestamp FROM metric_history_old
) h WHERE h.server_id = s.id),
(SELECT latest_timestamp FROM metric_history_hourly h
WHERE h.server_id = s.id ORDER BY timestamp DESC LIMIT 1),
(SELECT l.latest_timestamp FROM server_latest_state l
WHERE l.server_id = s.id),
0
) >= ?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)
+2 -2
View File
@@ -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(),
+17
View File
@@ -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<i64> {
// 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));
}
}