Skip to content

Commit 213b341

Browse files
v2.7: one render path, one data path — 180d max, no aggregation, no thinning
Removed the entire band/aggregate branch that caused every recent bug: /api/band, BandSeries, BandRow, bucketRounds, renderBand, the dual-mode chart instance, and the ALL window. 90d and 180d now go through the same smoke path as 1h; every sample is returned as stored, no server-side thinning. 177 lines deleted, 16 added. Verified in a browser: canvas fingerprint changes on every window switch (6h -> 180d -> 1h -> 90d -> 24h), no JS errors, /api/band returns 404.
1 parent 4219fdb commit 213b341

3 files changed

Lines changed: 16 additions & 193 deletions

File tree

static/index.html

Lines changed: 12 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,8 @@
113113
<button data-m="1440">24h</button>
114114
<button data-m="10080">7d</button>
115115
<button data-m="43200">30d</button>
116-
<button data-d="90">3M</button>
117-
<button data-d="180">6M</button>
118-
<button data-d="300">ALL</button>
116+
<button data-m="129600">90d</button>
117+
<button data-m="259200">180d</button>
119118
</div>
120119
<div class="rng custom">
121120
<input type="datetime-local" id="customFrom" step="60">
@@ -158,25 +157,15 @@
158157
const el = $('themeName'); if (el) el.textContent = theme;
159158
}
160159
applyTheme();
161-
let targets = [], current = null, minutes = 360, days = 0, typeFilter = 'all';
160+
let targets = [], current = null, minutes = 360, typeFilter = 'all';
162161

163162
// Deep link: /#t=NAME&m=1440 — send "this link, this window" to a colleague
164163
const hp = new URLSearchParams(location.hash.slice(1));
165164
if (hp.get('m') && [60,360,720,1440].includes(+hp.get('m'))) minutes = +hp.get('m');
166165
if (hp.get('t')) current = hp.get('t');
167166
document.querySelectorAll('.rng button[data-m]').forEach(b => b.classList.toggle('on', +b.dataset.m === minutes));
168167

169-
let chart = echarts.init($('chart'));
170-
let chartMode = null; // 'smoke' | 'band' —— 视图类型切换时销毁重建实例,
171-
// 避免 visualMap 等组件在 notMerge 重建时状态错乱导致图不重绘。
172-
function useChart(mode) {
173-
if (chartMode !== mode) {
174-
chart.dispose();
175-
chart = echarts.init($('chart'));
176-
chartMode = mode;
177-
}
178-
return chart;
179-
}
168+
const chart = echarts.init($('chart'));
180169

181170
async function loadTargets() {
182171
targets = await (await fetch('/api/targets')).json() || [];
@@ -194,7 +183,7 @@
194183
if ((!current || !visible.some(t => t.name === current)) && visible.length) current = visible[0].name;
195184
}
196185

197-
let sel = null; // {from,to} 框选时间段(unix 秒)
186+
let sel = null; // {from,to} 自定义查询区间(unix 秒)
198187
let renderSeq = 0;
199188
let rendering = false; // 一次渲染(含慢速 band 查询)是否仍在进行
200189
async function render() {
@@ -211,8 +200,7 @@
211200
await loadTargets();
212201
const t = targets.find(x => x.name === current);
213202
if (!t) return;
214-
if (!sel && days > 0) { await renderBand(t, seq); return; }
215-
let url = `/api/series?target=${encodeURIComponent(current)}&maxpts=12000`;
203+
let url = `/api/series?target=${encodeURIComponent(current)}`;
216204
if (sel) url += `&from=${sel.from}&to=${sel.to}`;
217205
else url += `&minutes=${minutes}`;
218206
const rounds = await (await fetch(url)).json() || [];
@@ -265,7 +253,7 @@
265253
$('conclusion').textContent = (t.down ? '🔴 ' : '🟢 ') + `${t.name}: ${win} P99 ${pool.length ? q(.99).toFixed(1) : '–'} ms · loss ${lossPct.toFixed(2)}% · ${bursts} bursts`;
266254

267255
const phos = cssv('--phos');
268-
useChart('smoke').setOption({
256+
chart.setOption({
269257
animation: false,
270258
grid: { left: 52, right: 46, top: 18, bottom: 42 },
271259
xAxis: { type: 'time', axisLine: { lineStyle: { color: cssv('--grid-axis') } },
@@ -316,13 +304,12 @@
316304
}
317305

318306

319-
document.querySelectorAll('.rng button[data-m], .rng button[data-d]').forEach(b => b.onclick = () => {
307+
document.querySelectorAll('.rng button[data-m]').forEach(b => b.onclick = () => {
320308
sel = null; $('selReset').style.display = 'none';
321309
$('customFrom').value = ''; $('customTo').value = '';
322-
document.querySelectorAll('.rng button[data-m], .rng button[data-d]').forEach(x => x.classList.remove('on'));
310+
document.querySelectorAll('.rng button[data-m]').forEach(x => x.classList.remove('on'));
323311
b.classList.add('on');
324-
if (b.dataset.d) { days = +b.dataset.d; minutes = 0; }
325-
else { minutes = +b.dataset.m; days = 0; }
312+
minutes = +b.dataset.m;
326313
render();
327314
});
328315
$('selReset').onclick = () => { sel = null; $('selReset').style.display = 'none'; render(); };
@@ -332,8 +319,8 @@
332319
const from = Math.floor(new Date(fv).getTime() / 1000);
333320
const to = Math.floor(new Date(tv).getTime() / 1000);
334321
if (to <= from) return;
335-
document.querySelectorAll('.rng button[data-m], .rng button[data-d]').forEach(x => x.classList.remove('on'));
336-
days = 0; minutes = 0;
322+
document.querySelectorAll('.rng button[data-m]').forEach(x => x.classList.remove('on'));
323+
minutes = 0;
337324
sel = { from, to };
338325
$('selReset').style.display = '';
339326
render();
@@ -371,75 +358,6 @@
371358
render();
372359
setInterval(() => { if (!rendering) render(); }, 60000); // 慢查询进行中时跳过本次自动刷新
373360
// Long windows: smoke is a hot-data privilege — beyond 24h you get the
374-
// P50 line, a P50–P99 band, loss bars and burst counts from server-side buckets.
375-
async function renderBand(t, seq) {
376-
const rows = await (await fetch(`/api/band?target=${encodeURIComponent(current)}&days=${days}`)).json() || [];
377-
if (seq !== renderSeq) return; // 过期响应:已切走,丢弃
378-
const p50 = [], band = [], loss = [], bursts = [];
379-
let lossW = 0, nSum = 0, burstSum = 0, p99max = 0, p50W = 0, p90W = 0;
380-
rows.forEach(r => {
381-
const ts = r.t * 1000;
382-
p50.push([ts, r.p50]);
383-
band.push([ts, r.p50, r.p99]);
384-
if (r.loss > 0) loss.push([ts, r.loss]);
385-
if (r.b) { bursts.push([ts, Math.min(r.loss, 100), r.b]); burstSum += r.b; }
386-
lossW += r.loss * r.n; p50W += r.p50 * r.n; p90W += r.p90 * r.n; nSum += r.n;
387-
if (r.p99 > p99max) p99max = r.p99;
388-
});
389-
const lp = nSum ? lossW / nSum : 0;
390-
$('p50').textContent = nSum ? (p50W/nSum).toFixed(1) : '–';
391-
$('p90').textContent = nSum ? (p90W/nSum).toFixed(1) : '–';
392-
$('p99').textContent = nSum ? p99max.toFixed(1) : '–';
393-
$('loss').textContent = lp.toFixed(2); $('loss').className = 'v' + (lp > 1 ? ' bad' : '');
394-
$('bursts').textContent = burstSum; $('bursts').className = 'v' + (burstSum ? ' bad' : '');
395-
const win = days >= 300 ? 'all' : days >= 30 ? (days/30) + 'M' : days + 'd';
396-
$('conclusion').textContent = (t.down ? '🔴 ' : '🟢 ') + `${t.name}: ${win} peak P99 ${p99max.toFixed(1)} ms · loss ${lp.toFixed(2)}% · ${burstSum} bursts`;
397-
const phos = cssv('--phos');
398-
useChart('band').setOption({
399-
animation: false,
400-
grid: { left: 52, right: 46, top: 18, bottom: 42 },
401-
tooltip: { trigger: 'axis',
402-
backgroundColor: cssv('--panel'), borderColor: cssv('--line'),
403-
textStyle: { color: cssv('--ink'), fontSize: 11, fontFamily: 'monospace' },
404-
formatter: params => {
405-
const ts = params[0] && params[0].axisValue;
406-
const i = roundInfo[ts];
407-
if (!i) return '';
408-
let html = `<b>${new Date(+ts).toLocaleTimeString()}</b>`;
409-
html += `<br>rtt min/avg/max/mdev = ${i.lo.toFixed(3)}/${i.avg.toFixed(3)}/${i.hi.toFixed(3)}/${i.mdev.toFixed(3)} ms`;
410-
html += `<br>median <b>${i.med.toFixed(1)} ms</b> · ${i.n} samples`;
411-
html += `<br><span style="color:${i.lp > 0 ? cssv('--alert') : cssv('--dim')}">loss <b>${i.lp.toFixed(0)}%</b> (${i.lost}/${i.s})</span>`;
412-
if (i.b) html += ` <span style="color:${cssv('--alert')}">· ◆ burst z=${i.z ? i.z.toFixed(2) : '—'}</span>`;
413-
return html;
414-
} },
415-
xAxis: { type: 'time', axisLine: { lineStyle: { color: cssv('--grid-axis') } },
416-
axisLabel: { color: cssv('--dim'), fontSize: 10 }, splitLine: { lineStyle: { color: cssv('--grid-split') } },
417-
axisPointer: { show: true, snap: false,
418-
lineStyle: { color: cssv('--dim'), opacity: .45 },
419-
label: { show: true, backgroundColor: cssv('--panel'), color: cssv('--ink'),
420-
borderColor: cssv('--line'), borderWidth: 1, fontFamily: 'monospace', fontSize: 10,
421-
formatter: p => new Date(p.value).toLocaleTimeString() } } },
422-
yAxis: [
423-
{ type: 'value', name: 'RTT ms', nameTextStyle: { color: cssv('--dim'), fontSize: 10 },
424-
axisLabel: { color: cssv('--dim'), fontSize: 10 }, splitLine: { lineStyle: { color: cssv('--grid-split') } } },
425-
{ type: 'value', max: 100, show: false }
426-
],
427-
series: [
428-
{ type: 'custom', renderItem: (p, api) => {
429-
const lo = api.coord([api.value(0), api.value(1)]), hi = api.coord([api.value(0), api.value(2)]);
430-
return { type: 'rect', shape: { x: lo[0]-1.5, y: hi[1], width: 3, height: Math.max(lo[1]-hi[1],1) },
431-
style: { fill: cssv('--smoke') } };
432-
}, data: band, silent: true },
433-
{ type: 'line', data: p50, showSymbol: false,
434-
lineStyle: { color: phos, width: 1.5, shadowColor: phos, shadowBlur: +cssv('--glow') }, z: 5 },
435-
{ type: 'bar', yAxisIndex: 1, data: loss, barMaxWidth: 3,
436-
itemStyle: { color: cssv('--lossbar') } },
437-
{ type: 'scatter', yAxisIndex: 1, data: bursts, symbol: 'diamond', symbolSize: 8,
438-
itemStyle: { color: cssv('--alert') }, z: 6,
439-
tooltip: { formatter: p => `◆ ${p.value[2]} bursts · loss ${p.value[1].toFixed(1)}%` } }
440-
]
441-
}, true);
442-
}
443361

444362
$('themeBtn').onclick = () => {
445363
theme = THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length];

store.go

Lines changed: 1 addition & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -160,68 +160,7 @@ func (s *Store) RemoveTarget(name string) {
160160
}
161161
}
162162

163-
// BandRow: one aggregated bucket for windows beyond the 24h smoke range.
164-
type BandRow struct {
165-
T int64 `json:"t"`
166-
P50 float64 `json:"p50"`
167-
P90 float64 `json:"p90"`
168-
P99 float64 `json:"p99"`
169-
Loss float64 `json:"loss"`
170-
Bursts int `json:"b,omitempty"`
171-
N int `json:"n"`
172-
}
173-
174-
// BandSeries aggregates raw day files into fixed-width buckets. Bucket width is
175-
// hardcoded per window so long ranges stay cheap: 7d→10min, 30d→30min, 90d→2h,
176-
// 180d/all→4h. Reading a season of JSONL takes a moment; it is a manual click.
177-
func (s *Store) BandSeries(name string, days int) []BandRow {
178-
bucket := int64(600)
179-
switch {
180-
case days > 90:
181-
bucket = 14400
182-
case days > 30:
183-
bucket = 7200
184-
case days > 7:
185-
bucket = 1800
186-
}
187-
rows := []BandRow{} // never nil
188-
for i := days; i >= 0; i-- {
189-
day := time.Now().AddDate(0, 0, -i).Format("2006-01-02")
190-
rounds, _ := s.readDay(name, day)
191-
if len(rounds) == 0 {
192-
continue
193-
}
194-
rows = append(rows, bucketRounds(rounds, bucket)...)
195-
}
196-
return rows
197-
}
198-
199-
func bucketRounds(rounds []Round, size int64) []BandRow {
200-
buckets := map[int64][]Round{}
201-
for _, r := range rounds {
202-
k := r.T / size * size
203-
buckets[k] = append(buckets[k], r)
204-
}
205-
keys := make([]int64, 0, len(buckets))
206-
for k := range buckets {
207-
keys = append(keys, k)
208-
}
209-
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
210-
out := make([]BandRow, 0, len(keys))
211-
for _, k := range keys {
212-
st := calcStats(buckets[k])
213-
out = append(out, BandRow{T: k, P50: round2(st.P50), P90: round2(st.P90),
214-
P99: round2(st.P99), Loss: round2(st.LossPct), Bursts: st.Bursts, N: st.Rounds})
215-
}
216-
return out
217-
}
218-
219-
// ReadRange returns raw rounds in [from,to]. It reads the in-memory ring when the
220-
// range is recent, else falls back to per-day files on disk — so smoke can render
221-
// for any window inside the retention period, not just the last 24h.
222-
// maxPts>0 thins the result to about that many samples (stride keep) so a 30-day
223-
// window doesn't ship millions of points the browser can't draw.
224-
func (s *Store) ReadRange(name string, from, to int64, maxPts int) []Round {
163+
func (s *Store) ReadRange(name string, from, to int64) []Round {
225164
var rounds []Round
226165
// ring first (covers the recent tail cheaply)
227166
s.mu.RLock()
@@ -249,28 +188,6 @@ func (s *Store) ReadRange(name string, from, to int64, maxPts int) []Round {
249188
out = append(out, r)
250189
}
251190
}
252-
return thin(out, maxPts)
253-
}
254-
255-
// thin keeps roughly maxPts rounds by uniform stride. Rounds flagged as bursts are
256-
// always kept — an anomaly must never be sampled away.
257-
func thin(rounds []Round, maxPts int) []Round {
258-
if rounds == nil {
259-
return []Round{} // never nil: JSON null blanks the chart
260-
}
261-
if maxPts <= 0 || len(rounds) <= maxPts {
262-
return rounds
263-
}
264-
stride := (len(rounds) + maxPts - 1) / maxPts // ceil: 保证抽样后不超过 maxPts(突发除外)
265-
if stride < 1 {
266-
stride = 1
267-
}
268-
out := make([]Round, 0, maxPts+64)
269-
for i, r := range rounds {
270-
if i%stride == 0 || r.B {
271-
out = append(out, r)
272-
}
273-
}
274191
return out
275192
}
276193

web.go

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -152,35 +152,23 @@ func serveWeb(cfg *Config, store *Store, users map[string]string) error {
152152
}))
153153

154154
// raw rounds for smoke. Supports either minutes=N (recent window) or from/to unix
155-
// (arbitrary range, e.g. box-select). maxpts thins long ranges to stay drawable.
155+
// (arbitrary range). No thinning: every sample is returned as stored.
156156
mux.HandleFunc("/api/series", guard(func(w http.ResponseWriter, r *http.Request) {
157157
q := r.URL.Query()
158158
name := q.Get("target")
159159
from, _ := strconv.ParseInt(q.Get("from"), 10, 64)
160160
to, _ := strconv.ParseInt(q.Get("to"), 10, 64)
161-
maxpts, _ := strconv.Atoi(q.Get("maxpts"))
162-
if maxpts <= 0 || maxpts > 40000 {
163-
maxpts = 12000 // ~one screen of smoke; keeps the browser smooth
164-
}
165161
if from == 0 || to == 0 {
166162
minutes, _ := strconv.Atoi(q.Get("minutes"))
167-
if minutes <= 0 || minutes > 43200 { // up to 30-day hot window
163+
if minutes <= 0 || minutes > 259200 { // up to 180 days
168164
minutes = 360
169165
}
170166
to = time.Now().Unix()
171167
from = to - int64(minutes)*60
172168
}
173-
writeJSON(w, store.ReadRange(name, from, to, maxpts))
169+
writeJSON(w, store.ReadRange(name, from, to))
174170
}))
175171

176-
// aggregated buckets for long windows (7d/1M/3M/6M/all)
177-
mux.HandleFunc("/api/band", guard(func(w http.ResponseWriter, r *http.Request) {
178-
days, _ := strconv.Atoi(r.URL.Query().Get("days"))
179-
if days <= 0 || days > 300 {
180-
days = 300
181-
}
182-
writeJSON(w, store.BandSeries(r.URL.Query().Get("target"), days))
183-
}))
184172

185173
return http.ListenAndServe(cfg.Listen, mux)
186174
}

0 commit comments

Comments
 (0)