From 29d0b0421b8528e1cee5c5b3fe167f43e1d13c01 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:49:23 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=8E=86=E5=8F=B2=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E8=BF=94=E5=9B=9E=20step=EF=BC=8C=E5=8D=B3=E6=AF=8F?= =?UTF-8?q?=E4=B8=AA=E7=82=B9=E8=A6=86=E7=9B=96=E7=9A=84=E7=A7=92=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/api/nodes/{id}/metrics` 的响应加上 `step`。`minutes` 的满额是 `step / 60`,主题此前只能从相邻时间戳推算,窗口里只有一个点时推不出来。新增字段,旧主题不受影响。 --- src/api.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/api.rs b/src/api.rs index d88606b2..4dc449a4 100644 --- a/src/api.rs +++ b/src/api.rs @@ -410,7 +410,12 @@ pub async fn metrics( // gone by the time the rows are built. Additive, so a theme unaware of it // continues to work. let (ping, loss) = if want_ping { app.db.ping_records(id, span)? } else { (vec![], json!({})) }; - anyhow::Ok(json!({"metrics": metrics, "ping": ping, "probes": probes, "loss": loss})) + // `step` is the seconds each point spans, which a row's `minutes` is read + // against. Inferred from the stamps instead, it is missing for a window + // holding a single point. + anyhow::Ok(json!({ + "metrics": metrics, "ping": ping, "probes": probes, "loss": loss, "step": span.step, + })) }) .await; match built.map_err(|e| anyhow::anyhow!(e)).and_then(|r| r) { @@ -3430,6 +3435,7 @@ mod tests { }; let rows = |body: &[u8]| serde_json::from_slice::(body).unwrap()["metrics"].as_array().unwrap().len(); + let step = |body: &[u8]| serde_json::from_slice::(body).unwrap()["step"].clone(); // Before the first rollup nothing is folded, and a window past the week // reads the newest week of minute rows rather than every one. @@ -3440,11 +3446,14 @@ mod tests { app.db.set("retention_days", "7").unwrap(); let week = ask(168).await; assert_eq!(rows(&week), 8, "a week reaches back seven days"); + assert_eq!(step(&week), 420, "in seven-minute points, a week of minutes at the 1,440-point budget"); assert_eq!(ask(2_160).await, week, "a window past the retention window is narrowed to it"); // Past the week, from the hourly tier and the minute rows after it. app.db.set("retention_days", "30").unwrap(); - assert_eq!(rows(&ask(2_160).await), 30, "a month reaches back thirty days"); + let month = ask(2_160).await; + assert_eq!(rows(&month), 30, "a month reaches back thirty days"); + assert_eq!(step(&month), 3_600, "in whole hours, as no hourly row may straddle two points"); } #[tokio::test] From 198fd7afb53e9b88fb1521e33cae03b8a1eaf808 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:33:13 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E5=8E=86=E5=8F=B2=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E4=BB=8E=E7=82=B9=E7=9A=84=E8=BE=B9=E7=95=8C=E5=BC=80=E5=A7=8B?= =?UTF-8?q?=EF=BC=8C=E9=A6=96=E4=B8=AA=E7=82=B9=E4=B8=8D=E5=86=8D=E5=8F=AA?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E4=B8=80=E9=83=A8=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 分桶按 `ts / step` 从 epoch 对齐,窗口起点改为向下对齐到 `step`,首个点覆盖完整的一段,满额 `minutes` 与 `step / 60` 一致 - 注明最后一个点是进行中的时段,`minutes` 只算到当前 --- src/api.rs | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/api.rs b/src/api.rs index 42f4054f..c78cd211 100644 --- a/src/api.rs +++ b/src/api.rs @@ -409,9 +409,10 @@ pub async fn metrics( // gone by the time the rows are built. Additive, so a theme unaware of it // continues to work. let (ping, loss) = if want_ping { app.db.ping_records(id, span)? } else { (vec![], json!({})) }; - // `step` is the seconds each point spans, which a row's `minutes` is read - // against. Inferred from the stamps instead, it is missing for a window - // holding a single point. + // `step` is the seconds each point spans, against which a row's `minutes` + // is read; inferred from the stamps instead, it would be missing for a + // window holding a single point. The last point is the bucket still in + // progress and holds only the minutes elapsed. anyhow::Ok(json!({ "metrics": metrics, "ping": ping, "probes": probes, "loss": loss, "step": span.step, })) @@ -423,7 +424,8 @@ pub async fn metrics( } } -/// The window a chart request is answered with, `hours` wide. +/// The window a chart request is answered with: `hours` back from `now`, +/// widened to the point boundary at or before that. /// /// The width is capped by the retention window alone, the same for every /// caller, because no width costs more than the week: up to `DETAIL_DAYS` a @@ -455,7 +457,10 @@ fn span(hours: i64, points: Option, now: i64) -> db::Span { // evenly would keep the finer step and exceed it. `i64::div_ceil` is still // unstable, and both operands are positive here. let step = unit * ((hours * 3_600 / unit + budget - 1) / budget).max(1); - db::Span { since: now - hours * 3_600, step, hourly } + // Rows are bucketed by `ts / step` from the epoch, and a window opening + // partway through a bucket would leave its first point short of the + // `step / 60` minutes a whole one holds. + db::Span { since: (now - hours * 3_600).div_euclid(step) * step, step, hourly } } /// Guards a per-node read: the panel sees everything, while the public page sees @@ -2453,11 +2458,15 @@ mod tests { ); // Thinned, but neither empty nor reaching outside the window. assert!(!metrics.is_empty() && !ping.is_empty(), "{hours}h returned nothing"); - // A bucket the window opens partway through begins before it. assert!( - metrics.iter().all(|m| m["ts"].as_i64().unwrap() >= span.since - span.step), + metrics.iter().all(|m| m["ts"].as_i64().unwrap() >= span.since), "{hours}h reached back too far" ); + // Begun on a point boundary, so the first point is a whole one where + // the history reaches past it: this node reported every minute. + if hours < 720 { + assert_eq!(metrics[0]["minutes"], span.step / 60, "{hours}h opened partway through a point"); + } } // A day returns every minute it holds: thinning exists only for what the