Skip to content

Commit 06c64fa

Browse files
v2.9: two-tier storage with per-round downsampling, canvas smoke layer, 300-day window
Storage: day files older than hot_days (30) are rewritten in place with each round collapsed to [min, median, p90, max]; files past retention (300) are deleted. The round itself survives — same timestamp, sent/recv counts, burst flag and z-score — so cold data flows through the same Round struct, the same /api/series, and the same render code as hot data. No second data structure, no second render path: the tiering is invisible above the storage layer, which is what kept breaking before. Rendering: the smoke scatter moved off ECharts onto a plain canvas overlay written directly into an ImageData buffer. ECharts still owns the axes, median line, loss bars and tooltip; the canvas only paints samples, which needs no per-point hit testing. This is what makes long windows drawable at all — ECharts kept per-point state and died in the hundreds of thousands; a pixel buffer does not care. Verified end to end in a browser: 6h -> 24h -> 7d -> 30d -> 70d -> 300d -> 1h -> 30d -> 6h, canvas fingerprint changes on every switch, returning to a window reproduces its earlier fingerprint, zero JS errors. Unit tests cover downsampleRound (metadata preserved, idempotent) and Tier (old days downsampled, recent days untouched, round count unchanged). Race detector clean.
1 parent a3152b8 commit 06c64fa

6 files changed

Lines changed: 252 additions & 13 deletions

File tree

config.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ type Config struct {
1919
TargetsDir string
2020
Targets []TargetCfg
2121
Probe ProbeCfg
22-
RetentionDays int
22+
HotDays int // full samples kept this long
23+
RetentionDays int // downsampled data kept this long
2324
}
2425

2526
func defaultConfig() *Config {
@@ -28,7 +29,8 @@ func defaultConfig() *Config {
2829
DataDir: "./data",
2930
TargetsDir: "./targets",
3031
Probe: ProbeCfg{IntervalSec: 60, Packets: 20, GapMs: 50, TimeoutMs: 1000},
31-
RetentionDays: 70,
32+
HotDays: 30,
33+
RetentionDays: 300,
3234
}
3335
}
3436

main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ func housekeeping(cfg *Config, store *Store, stop chan struct{}) {
222222
day := now.Format("2006-01-02")
223223
if now.Hour() == 0 && now.Minute() == 5 && last != day {
224224
last = day
225-
store.Retention(cfg.RetentionDays)
225+
store.Tier(cfg.HotDays, cfg.RetentionDays)
226226
}
227227
}
228228
}

pingping_test.go

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
package main
22

3-
import "testing"
3+
import (
4+
"encoding/json"
5+
"os"
6+
"testing"
7+
"time"
8+
)
49

510
func TestParseListLine(t *testing.T) {
611
tt, err := parseListLine("59.43.247.1 HK CN2 pace=fast", "icmp")
@@ -95,3 +100,61 @@ func FuzzRobustZ(f *testing.F) {
95100
}
96101
})
97102
}
103+
104+
func TestDownsampleRound(t *testing.T) {
105+
ms := make([]float64, 20)
106+
for i := range ms {
107+
ms[i] = float64(i + 1) // 1..20
108+
}
109+
in := Round{T: 1234567890, S: 20, R: 18, MS: ms, B: true, Z: 3.42}
110+
out := downsampleRound(in)
111+
112+
if len(out.MS) != 4 {
113+
t.Fatalf("want 4 samples, got %d", len(out.MS))
114+
}
115+
// min / median / p90 / max preserved in order
116+
if out.MS[0] != 1 || out.MS[3] != 20 {
117+
t.Fatalf("min/max wrong: %v", out.MS)
118+
}
119+
if out.MS[1] < out.MS[0] || out.MS[2] < out.MS[1] || out.MS[3] < out.MS[2] {
120+
t.Fatalf("not sorted: %v", out.MS)
121+
}
122+
// everything else must survive untouched — this is what keeps one code path
123+
if out.T != in.T || out.S != in.S || out.R != in.R || out.B != in.B || out.Z != in.Z {
124+
t.Fatalf("metadata lost: %+v", out)
125+
}
126+
// idempotent: downsampling twice changes nothing
127+
if again := downsampleRound(out); len(again.MS) != 4 || again.MS[0] != out.MS[0] {
128+
t.Fatalf("not idempotent: %v", again.MS)
129+
}
130+
}
131+
132+
func TestTierDownsamplesOldDays(t *testing.T) {
133+
dir := t.TempDir()
134+
s, err := NewStore(dir, []TargetCfg{{Name: "X", Type: "icmp", Host: "1.1.1.1", dir: "X"}})
135+
if err != nil { t.Fatal(err) }
136+
old := time.Now().AddDate(0, 0, -40).Format("2006-01-02")
137+
recent := time.Now().AddDate(0, 0, -5).Format("2006-01-02")
138+
ms := make([]float64, 20)
139+
for i := range ms { ms[i] = float64(i + 30) }
140+
for _, day := range []string{old, recent} {
141+
f, _ := os.Create(dir + "/X/" + day + ".jsonl")
142+
for i := 0; i < 10; i++ {
143+
line, _ := json.Marshal(Round{T: time.Now().Unix() - int64(i*60), S: 20, R: 20, MS: ms})
144+
f.Write(append(line, byte(10)))
145+
}
146+
f.Close()
147+
}
148+
s.Tier(30, 300)
149+
oldRounds, _ := s.readDay("X", old)
150+
newRounds, _ := s.readDay("X", recent)
151+
if len(oldRounds) == 0 || len(oldRounds[0].MS) != 4 {
152+
t.Fatalf("old day should be downsampled to 4 samples, got %d", len(oldRounds[0].MS))
153+
}
154+
if len(newRounds) == 0 || len(newRounds[0].MS) != 20 {
155+
t.Fatalf("recent day must keep all samples, got %d", len(newRounds[0].MS))
156+
}
157+
if len(oldRounds) != 10 {
158+
t.Fatalf("round count must survive downsampling: %d", len(oldRounds))
159+
}
160+
}

static/index.html

Lines changed: 83 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@
9797
</div>
9898

9999
<div class="scope">
100-
<div class="crt"><div id="chart"></div></div>
100+
<div class="crt"><div id="chart"></div><canvas id="smokeCv"></canvas></div>
101101
<div class="readouts">
102102
<div class="ro"><div class="v" id="p50"></div><div class="l">P50 MS</div></div>
103103
<div class="ro"><div class="v" id="p90"></div><div class="l">P90 MS</div></div>
@@ -114,6 +114,7 @@
114114
<button data-m="10080">7d</button>
115115
<button data-m="43200">30d</button>
116116
<button data-m="100800">70d</button>
117+
<button data-m="432000">300d</button>
117118
</div>
118119
<div class="rng custom">
119120
<input type="datetime-local" id="customFrom" step="60">
@@ -166,6 +167,83 @@
166167

167168
const chart = echarts.init($('chart'));
168169

170+
171+
// 自绘烟雾层:ECharts 的散点图在几十万点就吃不消,因为它给每个点维护状态。
172+
// 烟雾不需要单点命中(tooltip 按轮显示),所以直接写像素缓冲 —— 百万点也是一次遍历。
173+
function drawSmoke(rounds) {
174+
const cv = $('smokeCv'), box = $('chart');
175+
const W = box.clientWidth, H = box.clientHeight;
176+
const dpr = window.devicePixelRatio || 1;
177+
cv.width = W * dpr; cv.height = H * dpr;
178+
cv.style.width = W + 'px'; cv.style.height = H + 'px';
179+
const ctx = cv.getContext('2d');
180+
ctx.clearRect(0, 0, cv.width, cv.height);
181+
if (!rounds.length) return;
182+
183+
// 与 ECharts 网格对齐(grid: left 52, right 46, top 18, bottom 42)
184+
const gl = 52 * dpr, gr = 46 * dpr, gt = 18 * dpr, gb = 42 * dpr;
185+
const plotW = cv.width - gl - gr, plotH = cv.height - gt - gb;
186+
if (plotW <= 0 || plotH <= 0) return;
187+
188+
// 用 ECharts 当前的坐标范围,保证两层严丝合缝
189+
const opt = chart.getOption();
190+
const yAxis = opt.yAxis && opt.yAxis[0];
191+
const t0 = rounds[0].t * 1000, t1 = rounds[rounds.length - 1].t * 1000;
192+
const yMin = yAxis && yAxis.min != null ? yAxis.min : 0;
193+
let yMax = yAxis && yAxis.max != null ? yAxis.max : 0;
194+
if (!yMax) { // 回退:自己算上界
195+
for (const r of rounds) for (const v of (r.ms || [])) if (v > yMax) yMax = v;
196+
yMax *= 1.05;
197+
}
198+
const tSpan = Math.max(1, t1 - t0), ySpan = Math.max(1e-6, yMax - yMin);
199+
200+
// 逐点写 ImageData:比 fillRect 快一个数量级
201+
const img = ctx.createImageData(cv.width, cv.height);
202+
const buf = img.data;
203+
const hot = hexToRgb(cssv('--smoke-hot')), mid = hexToRgb(cssv('--smoke-mid')), cool = hexToRgb(cssv('--smoke-cool'));
204+
205+
for (const r of rounds) {
206+
const ms = r.ms;
207+
if (!ms || !ms.length) continue;
208+
const x = gl + (r.t * 1000 - t0) / tSpan * plotW;
209+
if (x < 0 || x >= cv.width) continue;
210+
// 该轮中位数用于着色:贴线的亮,散开的冷
211+
const srt = ms.length > 2 ? [...ms].sort((a, b) => a - b) : ms;
212+
const med = srt[Math.floor(srt.length / 2)];
213+
for (const v of ms) {
214+
const y = gt + plotH - (v - yMin) / ySpan * plotH;
215+
if (y < 0 || y >= cv.height) continue;
216+
const dev = med ? Math.min(1, Math.abs(v - med) / med / 0.5) : 0;
217+
const c = dev < 0.5 ? lerp(hot, mid, dev * 2) : lerp(mid, cool, (dev - 0.5) * 2);
218+
const a = Math.round((0.5 - dev * 0.4) * 255);
219+
const xi = x | 0, yi = y | 0;
220+
for (let dx = 0; dx < 2; dx++) for (let dy = 0; dy < 2; dy++) { // 2x2 让点可见
221+
const px = xi + dx, py = yi + dy;
222+
if (px >= cv.width || py >= cv.height) continue;
223+
const idx = (py * cv.width + px) * 4;
224+
// 叠加混合:重叠越多越亮,这正是烟雾的质感
225+
buf[idx] = Math.min(255, buf[idx] + c[0] * a / 255);
226+
buf[idx + 1] = Math.min(255, buf[idx + 1] + c[1] * a / 255);
227+
buf[idx + 2] = Math.min(255, buf[idx + 2] + c[2] * a / 255);
228+
buf[idx + 3] = Math.min(255, buf[idx + 3] + a);
229+
}
230+
}
231+
}
232+
ctx.putImageData(img, 0, 0);
233+
}
234+
235+
function hexToRgb(h) {
236+
h = (h || '#7ce38b').trim();
237+
if (h[0] === '#') h = h.slice(1);
238+
if (h.length === 3) h = h.split('').map(c => c + c).join('');
239+
const n = parseInt(h, 16);
240+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
241+
}
242+
function lerp(a, b, t) {
243+
t = Math.max(0, Math.min(1, t));
244+
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
245+
}
246+
169247
async function loadTargets() {
170248
targets = await (await fetch('/api/targets')).json() || [];
171249
const kinds = new Set(targets.map(t => t.type));
@@ -183,6 +261,7 @@
183261
}
184262

185263
let sel = null; // {from,to} 自定义查询区间(unix 秒)
264+
let lastRounds = [];
186265
let renderSeq = 0;
187266
let rendering = false; // 一次渲染(含慢速 band 查询)是否仍在进行
188267
async function render() {
@@ -291,13 +370,7 @@
291370
if (i.b) html += ` <span style="color:${cssv('--alert')}">· ◆ burst z=${i.z ? i.z.toFixed(2) : '—'}</span>`;
292371
return html;
293372
} },
294-
// 荧光束渐变:贴线的样本 = 炽亮核心,散开的样本渐冷渐暗 —— 渐变即信息
295-
visualMap: { show: false, type: 'continuous', dimension: 2, min: 0, max: 0.5,
296-
seriesIndex: 0,
297-
inRange: { color: [cssv('--smoke-hot'), cssv('--smoke-mid'), cssv('--smoke-cool')],
298-
opacity: [0.5, 0.10] } },
299373
series: [
300-
{ type: 'scatter', data: smoke, symbolSize: 2.2, silent: true, large: true },
301374
{ type: 'line', data: median, showSymbol: false, silent: true, z: 4,
302375
lineStyle: { color: phos, width: 7, opacity: 0.10 } },
303376
{ type: 'line', data: median, showSymbol: false,
@@ -310,6 +383,8 @@
310383
tooltip: { formatter: p => `◆ z-score ${p.value[2] ? p.value[2].toFixed(2) : '—'} · loss ${p.value[1].toFixed(0)}% (${p.value[3]}/${p.value[4]})` } }
311384
]
312385
}, true);
386+
drawSmoke(rounds); // ECharts 画完坐标轴后叠加烟雾层
387+
lastRounds = rounds;
313388
}
314389

315390

@@ -379,7 +454,7 @@
379454
applyTheme();
380455
render();
381456
};
382-
window.addEventListener('resize', () => chart && chart.resize());
457+
window.addEventListener('resize', () => { if (chart) { chart.resize(); drawSmoke(lastRounds); } });
383458
</script>
384459
</body>
385460
</html>

store.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"os"
99
"path/filepath"
1010
"sort"
11+
"strings"
1112
"sync"
1213
"time"
1314
)
@@ -259,6 +260,104 @@ func pct(sorted []float64, p float64) float64 {
259260
}
260261

261262
// Retention:保留期就是 rm。按天分文件让清理不需要任何压缩整理逻辑。
263+
// downsampleRound collapses a round's samples to [min, median, P90, max].
264+
// The round itself is preserved — same timestamp, same sent/recv counts, same burst
265+
// flag and z-score — so cold data flows through the exact same code path as hot data.
266+
// Only the within-round redundancy is dropped: on a month-wide axis those 20-30
267+
// samples land in the same pixel column anyway.
268+
func downsampleRound(r Round) Round {
269+
if len(r.MS) <= 4 {
270+
return r // already small enough
271+
}
272+
sorted := append([]float64(nil), r.MS...)
273+
sort.Float64s(sorted)
274+
n := len(sorted)
275+
r.MS = []float64{
276+
round2(sorted[0]),
277+
round2(sorted[n/2]),
278+
round2(sorted[int(float64(n)*0.9)]),
279+
round2(sorted[n-1]),
280+
}
281+
return r
282+
}
283+
284+
// Downsample rewrites one day file in place with per-round downsampling. Files are
285+
// rewritten atomically via a temp file so a crash cannot leave a half-written day.
286+
func (s *Store) Downsample(name, day string) error {
287+
rounds, err := s.readDay(name, day)
288+
if err != nil || len(rounds) == 0 {
289+
return err
290+
}
291+
// already downsampled? cheap check on the first round
292+
if len(rounds[0].MS) <= 4 {
293+
return nil
294+
}
295+
path := s.dayFile(name, day)
296+
tmp := path + ".tmp"
297+
f, err := os.Create(tmp)
298+
if err != nil {
299+
return err
300+
}
301+
for _, r := range rounds {
302+
line, err := json.Marshal(downsampleRound(r))
303+
if err != nil {
304+
f.Close()
305+
os.Remove(tmp)
306+
return err
307+
}
308+
if _, err := f.Write(append(line, byte(10))); err != nil {
309+
f.Close()
310+
os.Remove(tmp)
311+
return err
312+
}
313+
}
314+
if err := f.Close(); err != nil {
315+
os.Remove(tmp)
316+
return err
317+
}
318+
return os.Rename(tmp, path)
319+
}
320+
321+
// Tier runs nightly: day files older than hotDays get downsampled in place, files
322+
// older than keepDays are deleted. One pass, no separate cold directory — the
323+
// tiering is invisible above the storage layer.
324+
func (s *Store) Tier(hotDays, keepDays int) {
325+
hotCut := time.Now().AddDate(0, 0, -hotDays).Format("2006-01-02")
326+
keepCut := time.Now().AddDate(0, 0, -keepDays).Format("2006-01-02")
327+
s.mu.RLock()
328+
names := make([]string, 0, len(s.dirs))
329+
for n := range s.dirs {
330+
names = append(names, n)
331+
}
332+
s.mu.RUnlock()
333+
334+
for _, name := range names {
335+
s.mu.RLock()
336+
dir := s.dirs[name]
337+
s.mu.RUnlock()
338+
entries, err := os.ReadDir(filepath.Join(s.dir, dir))
339+
if err != nil {
340+
continue
341+
}
342+
for _, e := range entries {
343+
fn := e.Name()
344+
if len(fn) < 10 || strings.HasSuffix(fn, ".tmp") {
345+
continue
346+
}
347+
day := fn[:10]
348+
switch {
349+
case day < keepCut:
350+
os.Remove(filepath.Join(s.dir, dir, fn))
351+
log.Printf("retention: removed %s/%s", dir, fn)
352+
case day < hotCut:
353+
if err := s.Downsample(name, day); err != nil {
354+
log.Printf("downsample %s/%s failed: %v", dir, day, err)
355+
}
356+
}
357+
}
358+
}
359+
}
360+
262361
func (s *Store) Retention(days int) {
263362
if days <= 0 {
264363
return

web.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ func serveWeb(cfg *Config, store *Store, users map[string]string) error {
164164
to, _ := strconv.ParseInt(q.Get("to"), 10, 64)
165165
if from == 0 || to == 0 {
166166
minutes, _ := strconv.Atoi(q.Get("minutes"))
167-
if minutes <= 0 || minutes > 100800 { // up to 70 days
167+
if minutes <= 0 || minutes > 432000 { // up to 300 days
168168
minutes = 360
169169
}
170170
to = time.Now().Unix()

0 commit comments

Comments
 (0)