|
113 | 113 | <button data-m="1440">24h</button> |
114 | 114 | <button data-m="10080">7d</button> |
115 | 115 | <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> |
119 | 118 | </div> |
120 | 119 | <div class="rng custom"> |
121 | 120 | <input type="datetime-local" id="customFrom" step="60"> |
|
158 | 157 | const el = $('themeName'); if (el) el.textContent = theme; |
159 | 158 | } |
160 | 159 | applyTheme(); |
161 | | -let targets = [], current = null, minutes = 360, days = 0, typeFilter = 'all'; |
| 160 | +let targets = [], current = null, minutes = 360, typeFilter = 'all'; |
162 | 161 |
|
163 | 162 | // Deep link: /#t=NAME&m=1440 — send "this link, this window" to a colleague |
164 | 163 | const hp = new URLSearchParams(location.hash.slice(1)); |
165 | 164 | if (hp.get('m') && [60,360,720,1440].includes(+hp.get('m'))) minutes = +hp.get('m'); |
166 | 165 | if (hp.get('t')) current = hp.get('t'); |
167 | 166 | document.querySelectorAll('.rng button[data-m]').forEach(b => b.classList.toggle('on', +b.dataset.m === minutes)); |
168 | 167 |
|
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')); |
180 | 169 |
|
181 | 170 | async function loadTargets() { |
182 | 171 | targets = await (await fetch('/api/targets')).json() || []; |
|
194 | 183 | if ((!current || !visible.some(t => t.name === current)) && visible.length) current = visible[0].name; |
195 | 184 | } |
196 | 185 |
|
197 | | -let sel = null; // {from,to} 框选时间段(unix 秒) |
| 186 | +let sel = null; // {from,to} 自定义查询区间(unix 秒) |
198 | 187 | let renderSeq = 0; |
199 | 188 | let rendering = false; // 一次渲染(含慢速 band 查询)是否仍在进行 |
200 | 189 | async function render() { |
|
211 | 200 | await loadTargets(); |
212 | 201 | const t = targets.find(x => x.name === current); |
213 | 202 | 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)}`; |
216 | 204 | if (sel) url += `&from=${sel.from}&to=${sel.to}`; |
217 | 205 | else url += `&minutes=${minutes}`; |
218 | 206 | const rounds = await (await fetch(url)).json() || []; |
|
265 | 253 | $('conclusion').textContent = (t.down ? '🔴 ' : '🟢 ') + `${t.name}: ${win} P99 ${pool.length ? q(.99).toFixed(1) : '–'} ms · loss ${lossPct.toFixed(2)}% · ${bursts} bursts`; |
266 | 254 |
|
267 | 255 | const phos = cssv('--phos'); |
268 | | - useChart('smoke').setOption({ |
| 256 | + chart.setOption({ |
269 | 257 | animation: false, |
270 | 258 | grid: { left: 52, right: 46, top: 18, bottom: 42 }, |
271 | 259 | xAxis: { type: 'time', axisLine: { lineStyle: { color: cssv('--grid-axis') } }, |
|
316 | 304 | } |
317 | 305 |
|
318 | 306 |
|
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 = () => { |
320 | 308 | sel = null; $('selReset').style.display = 'none'; |
321 | 309 | $('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')); |
323 | 311 | 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; |
326 | 313 | render(); |
327 | 314 | }); |
328 | 315 | $('selReset').onclick = () => { sel = null; $('selReset').style.display = 'none'; render(); }; |
|
332 | 319 | const from = Math.floor(new Date(fv).getTime() / 1000); |
333 | 320 | const to = Math.floor(new Date(tv).getTime() / 1000); |
334 | 321 | 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; |
337 | 324 | sel = { from, to }; |
338 | 325 | $('selReset').style.display = ''; |
339 | 326 | render(); |
|
371 | 358 | render(); |
372 | 359 | setInterval(() => { if (!rendering) render(); }, 60000); // 慢查询进行中时跳过本次自动刷新 |
373 | 360 | // 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 | | -} |
443 | 361 |
|
444 | 362 | $('themeBtn').onclick = () => { |
445 | 363 | theme = THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length]; |
|
0 commit comments