diff --git a/i18n/strings.json b/i18n/strings.json index adf0913..0cab5ca 100644 --- a/i18n/strings.json +++ b/i18n/strings.json @@ -1080,6 +1080,102 @@ "ja": "線 = 1時間ごとの中央値 · 煙 = 1時間ごとの min/p50/p90/p99/max · 赤柱 = 損失率 · ◆ = バースト", "ko": "선 = 시간별 중앙값 · 연기 = 시간별 min/p50/p90/p99/max · 빨간 막대 = 손실률 · ◆ = 버스트" }, + "console.compare": { + "en": "COMPARE", + "zh": "对比", + "es": "COMPARAR", + "fr": "COMPARER", + "pt": "COMPARAR", + "ru": "СРАВНЕНИЕ", + "id": "BANDINGKAN", + "de": "VERGLEICH", + "ja": "比較", + "ko": "비교" + }, + "console.cmpOff": { + "en": "Off", + "zh": "关闭", + "es": "Desactivado", + "fr": "Désactivé", + "pt": "Desligado", + "ru": "Выкл.", + "id": "Mati", + "de": "Aus", + "ja": "オフ", + "ko": "끄기" + }, + "console.cmpPrev": { + "en": "Previous period", + "zh": "上一周期", + "es": "Período anterior", + "fr": "Période précédente", + "pt": "Período anterior", + "ru": "Предыдущий период", + "id": "Periode sebelumnya", + "de": "Vorheriger Zeitraum", + "ja": "前の期間", + "ko": "이전 기간" + }, + "console.cmpDay": { + "en": "Yesterday, same time", + "zh": "昨天同时", + "es": "Ayer, misma hora", + "fr": "Hier, même heure", + "pt": "Ontem, mesmo horário", + "ru": "Вчера в это же время", + "id": "Kemarin, jam yang sama", + "de": "Gestern, gleiche Zeit", + "ja": "昨日の同時刻", + "ko": "어제 같은 시간" + }, + "console.cmpWeek": { + "en": "Last week, same time", + "zh": "上周同时", + "es": "Semana pasada, misma hora", + "fr": "Semaine dernière, même heure", + "pt": "Semana passada, mesmo horário", + "ru": "Неделю назад в это же время", + "id": "Minggu lalu, jam yang sama", + "de": "Letzte Woche, gleiche Zeit", + "ja": "先週の同時刻", + "ko": "지난주 같은 시간" + }, + "console.cmpTitle": { + "en": "Overlay the median of an earlier period as a dashed line. Periods older than the retention window are unavailable.", + "zh": "用虚线叠加更早同时期的中位数。超出保留期的时段不可选。", + "es": "Superpone la mediana de un período anterior como línea discontinua. Los períodos fuera de la retención no están disponibles.", + "fr": "Superpose la médiane d'une période antérieure en pointillés. Les périodes hors rétention ne sont pas disponibles.", + "pt": "Sobrepõe a mediana de um período anterior como linha tracejada. Períodos fora da retenção não estão disponíveis.", + "ru": "Накладывает медиану более раннего периода пунктиром. Периоды за пределами срока хранения недоступны.", + "id": "Menampilkan median periode sebelumnya sebagai garis putus-putus. Periode di luar masa retensi tidak tersedia.", + "de": "Blendet den Median eines früheren Zeitraums gestrichelt ein. Zeiträume außerhalb der Aufbewahrung sind nicht verfügbar.", + "ja": "以前の同じ時間帯の中央値を破線で重ねて表示します。保存期間を超える期間は選べません。", + "ko": "이전 기간의 중앙값을 점선으로 겹쳐 표시합니다. 보존 기간을 벗어난 기간은 선택할 수 없습니다." + }, + "console.cmpBeyond": { + "en": "Beyond retention", + "zh": "超出保留期", + "es": "Fuera del período de retención", + "fr": "Hors période de rétention", + "pt": "Fora do período de retenção", + "ru": "За пределами срока хранения", + "id": "Di luar masa retensi", + "de": "Außerhalb der Aufbewahrung", + "ja": "保存期間外", + "ko": "보존 기간 초과" + }, + "console.cmpNoData": { + "en": "no data in comparison period", + "zh": "对比期无数据", + "es": "sin datos en el período de comparación", + "fr": "aucune donnée sur la période de comparaison", + "pt": "sem dados no período de comparação", + "ru": "нет данных за период сравнения", + "id": "tidak ada data pada periode pembanding", + "de": "keine Daten im Vergleichszeitraum", + "ja": "比較期間のデータなし", + "ko": "비교 기간에 데이터 없음" + }, "console.tagline": { "en": "One binary. One graph. One metric: Latency.", "zh": "一个文件。一张图。一个指标:延迟。", diff --git a/static/index.html b/static/index.html index f924abb..952cc0c 100644 --- a/static/index.html +++ b/static/index.html @@ -135,6 +135,12 @@ .rng button:hover { color:var(--ink); } .rng button.on { background:color-mix(in srgb, var(--amber) 15%, transparent); color:var(--amber); font-weight:700; } + /* 同时期对比:一个下拉,和 TIME/DIV 并排,沿用同一套面板样式 */ + .rng select { font-family:var(--sans); font-size:var(--fs-small); background:none; border:0; + color:var(--ink); padding:7px 10px 7px 4px; cursor:pointer; } + .rng select:focus { outline:none; color:var(--phos); } + .rng select option { background:var(--panel); color:var(--ink); } + .rng select option:disabled { color:var(--dim); } .scope { background:var(--panel); border:1px solid var(--line); border-radius:6px; padding:14px; } .crt { position:relative; } #chart { height:420px; background:var(--screen); border-radius:4px; } @@ -295,6 +301,15 @@ +
+ COMPARE + +
→ @@ -366,6 +381,112 @@ } let sel = null; // {from,to} 自定义查询区间(unix 秒) + +// ---- 同时期对比虚线 ---- +// 取同一目标更早的一段(当前窗口整体前移 shift 秒),再把时间戳加回 shift 叠到当前窗口上。 +// 后端不用改:/api/series 本来就支持 from/to。模式只放内存,和时间窗一样不持久化。 +let cmpMode = 'off'; +let cmpData = null; // 当前图上对比线的数据,tooltip 用 +let cmpInflight = null; // 对比请求单独一个 AbortController,新渲染开始时一并掐掉 +let retentionSec = 0; // /api/version 的 retention_days;0 = 还不知道,先不限制 +const RAW_KEEP = 2 * 86400; // 原始样本只留 HotDays(2 天),再早只剩小时汇总 +const CMP_KEYS = { prev: 'console.cmpPrev', day: 'console.cmpDay', week: 'console.cmpWeek' }; +const cmpShift = (mode, w) => mode === 'prev' ? w : mode === 'day' ? 86400 : mode === 'week' ? 604800 : 0; +// 当前窗口 [from,to](unix 秒)。预设时间窗按本地时钟算,和服务端的 now 差几秒无所谓。 +function curWindow() { + if (sel) return { from: sel.from, to: sel.to }; + const to = Math.floor(Date.now() / 1000); + return { from: to - minutes * 60, to }; +} +// 对比段整个必须落在保留期内:最早的点 from - shift >= now - retention。 +function cmpValid(mode, win) { + if (mode === 'off') return true; + if (!retentionSec) return true; + const now = Math.floor(Date.now() / 1000); + return (now - win.from) + cmpShift(mode, win.to - win.from) <= retentionSec; +} +// 把选不了的模式置灰;当前模式失效就退回关闭。返回是否发生了回退。 +function syncCmpOptions(win) { + const box = $('cmpMode'); + // 主窗口本身已超出保留期时,shift>0 的模式一律不满足,全部置灰。 + [...box.options].forEach(o => { + o.disabled = !cmpValid(o.value, win); + o.title = o.disabled ? T('console.cmpBeyond') : ''; + }); + const fell = !cmpValid(cmpMode, win); + if (fell) cmpMode = 'off'; + box.value = cmpMode; + return fell; +} + +// Row -> median, shared by the main chart and the compare line. Raw rounds carry +// samples (median of the sorted samples); hourly buckets carry five percentiles +// [min,p50,p90,p99,max] and the median is q[1] — sorting those five and taking the +// middle would give P90, not a median. +function roundMedian(r) { + const srt = (r.ms && r.ms.length) ? [...r.ms].sort((a,b)=>a-b) : []; + return { srt, med: srt.length ? srt[Math.floor(srt.length/2)] : 0 }; +} +const bucketMedian = b => b.q ? b.q[1] : undefined; +// [[ts_ms, median], ...] for a whole /api/series response, shifted by shiftSec. +function medianLine(d, shiftSec = 0) { + const off = shiftSec * 1000, out = []; + if (d.tier === 'hourly') { + (d.buckets || []).forEach(b => { const m = bucketMedian(b); if (m !== undefined) out.push([b.t * 1000 + off, m]); }); + } else { + (d.rounds || []).forEach(r => { const { srt, med } = roundMedian(r); if (srt.length) out.push([r.t * 1000 + off, med]); }); + } + return out; +} + +// 拉对比段。任何失败(网络、被掐、坏 JSON)都返回 null —— 对比线画不出来不能拖垮主图。 +// 请求成功但对比段没数据(新装、数据还不够早)返回 { empty: true },图例上提示一句。 +async function fetchCompare(name, win, mode) { + const shift = cmpShift(mode, win.to - win.from); + const from = win.from - shift, to = win.to - shift; + let reqFrom = from; + // 服务端按跨度选层:<=24h 读原始样本。对比段若早于原始样本的保留期,原始层是空的, + // 于是把请求放宽到 25h 逼它返回小时汇总,拿回来再裁回 [from,to]。 + const now = Math.floor(Date.now() / 1000); + const widen = (to - from) <= 86400 && from < now - RAW_KEEP - 3600; + if (widen) reqFrom = to - 86400 - 3600; + if (cmpInflight) cmpInflight.abort(); + const ctl = new AbortController(); + cmpInflight = ctl; + try { + const res = await fetch(`/api/series?target=${encodeURIComponent(name)}&from=${reqFrom}&to=${to}`, { signal: ctl.signal }); + if (!res.ok) return null; + const d = await res.json() || {}; + if (widen && d.tier === 'hourly') d.buckets = (d.buckets || []).filter(b => b.t > from - 3600 && b.t <= to); + const pts = medianLine(d, shift); + if (!pts.length) return { mode, empty: true }; + // 跨过窗口起点的那个小时桶盖的是桶起点,平移后会落在窗口左边、把 x 轴撑宽;夹到窗口起点 + if (pts[0][0] < win.from * 1000) pts[0][0] = win.from * 1000; + const hourly = d.tier === 'hourly'; + // 原始层的点间隔 = 探测间隔;查 tooltip 时用它当"附近"的容差 + let gap = 3600e3; + if (!hourly && pts.length > 1) { + const ds = pts.slice(1).map((p, i) => p[0] - pts[i][0]).sort((a,b)=>a-b); + gap = ds[Math.floor(ds.length/2)] || 60e3; + } + return { mode, hourly, pts, gap }; + } catch { + return null; + } finally { + if (cmpInflight === ctl) cmpInflight = null; + } +} +// 悬停时间 ts 对应的对比值。小时汇总按桶起点盖章,取包含 ts 的那个桶;原始层取最近的一轮。 +function cmpValueAt(cmp, ts) { + if (!cmp || !cmp.pts.length) return undefined; + const a = cmp.pts; + let lo = 0, hi = a.length - 1; + while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (a[mid][0] <= ts) lo = mid; else hi = mid - 1; } + if (cmp.hourly) return (a[lo][0] <= ts && ts - a[lo][0] < 3600e3) ? a[lo][1] : undefined; + let best = a[lo]; + if (lo + 1 < a.length && Math.abs(a[lo+1][0] - ts) < Math.abs(best[0] - ts)) best = a[lo+1]; + return Math.abs(best[0] - ts) <= cmp.gap * 0.6 ? best[1] : undefined; +} let renderSeq = 0; let inflight = null; // 正在飞的 series 请求 —— 新请求发起前先掐掉它, // 否则快速点击会在服务端堆起一串没人要的慢查询,互相拖死。 @@ -388,8 +509,14 @@ if (sel) url += `&from=${sel.from}&to=${sel.to}`; else url += `&minutes=${minutes}`; if (inflight) inflight.abort(); + if (cmpInflight) { cmpInflight.abort(); cmpInflight = null; } const ctl = new AbortController(); inflight = ctl; + // 对比段和主图并行拉,不叠加延迟;主图先画,对比线到了再补上。 + const win = curWindow(); + syncCmpOptions(win); + const mode = cmpMode; + const cmpP = mode !== 'off' ? fetchCompare(current, win, mode) : Promise.resolve(null); let d; try { d = await (await fetch(url, { signal: ctl.signal })).json() || {}; @@ -413,8 +540,7 @@ const pool = []; (d.rounds || []).forEach(r => { const ts = r.t * 1000; - const srt = (r.ms && r.ms.length) ? [...r.ms].sort((a,b)=>a-b) : []; - const rmed = srt.length ? srt[Math.floor(srt.length/2)] : 0; + const { srt, med: rmed } = roundMedian(r); if (srt.length) median.push([ts, rmed]); srt.forEach(v => { smoke.push([ts, v, rmed ? Math.abs(v - rmed) / rmed : 0]); // 维度2 = 偏离该轮中位数的比例 @@ -442,7 +568,7 @@ (d.buckets || []).forEach(b => { const ts = b.t * 1000; if (b.q) { - const m = b.q[1]; + const m = bucketMedian(b); median.push([ts, m]); b.q.forEach(v => smoke.push([ts, v, m ? Math.abs(v - m) / m : 0])); qs.forEach((arr, i) => arr.push([b.q[i+1], b.n])); @@ -469,7 +595,9 @@ // T(), not a literal: this runs on every redraw and overwrites whatever the // data-i18n pass put here, so the markup version below used to be assigned to // textContent and rendered as visible angle brackets. - $('legend').textContent = hourly ? T('console.legendHourly') : T('console.legend'); + const cmpName = mode !== 'off' ? T(CMP_KEYS[mode]) + ' · P50' : ''; + $('legend').textContent = (hourly ? T('console.legendHourly') : T('console.legend')) + + (cmpName ? ` · ┄ = ${cmpName}` : ''); const approx = hourly ? '≈' : ''; const fmt = v => v === undefined ? '–' : approx + v.toFixed(1); ['p50','p90','p99'].forEach((id, i) => { @@ -481,8 +609,8 @@ $('bursts').textContent = bursts; $('bursts').className = 'v' + (bursts ? ' bad' : ''); - const win = sel ? 'custom' : (document.querySelector('.rng button[data-m].on')?.textContent || minutes + 'm'); - $('conclusion').textContent = (t.down ? '🔴 ' : '🟢 ') + `${t.name}: ${win} P99 ${fmt(p99)} ms · loss ${lossPct.toFixed(2)}% · ${bursts} bursts`; + const winLabel = sel ? 'custom' : (document.querySelector('.rng button[data-m].on')?.textContent || minutes + 'm'); + $('conclusion').textContent = (t.down ? '🔴 ' : '🟢 ') + `${t.name}: ${winLabel} P99 ${fmt(p99)} ms · loss ${lossPct.toFixed(2)}% · ${bursts} bursts`; const phos = cssv('--phos'); chart.setOption({ @@ -514,23 +642,37 @@ backgroundColor: cssv('--panel'), borderColor: cssv('--line'), textStyle: { color: cssv('--ink'), fontSize: 11, fontFamily: 'monospace' }, formatter: params => { - const ts = params[0] && params[0].axisValue; + // 以主中位线的点为准取时间:对比线的点(平移过的时间戳)也会进 params, + // 不能让它决定 roundInfo 查哪一轮。 + const mp = params.find(p => p.seriesId === 'median'); + const ts = mp ? mp.value[0] : (params[0] && params[0].axisValue); const i = roundInfo[ts]; if (!i) return ''; let html = `${new Date(+ts).toLocaleString()}`; + const cv = cmpValueAt(cmpData, +ts); + const cur = i.hourly ? (i.q ? i.q[1] : undefined) : (i.n ? i.med : undefined); + let cmpLine = ''; + if (cv !== undefined) { + cmpLine = `
┄ ${T(CMP_KEYS[cmpData.mode])} ${cv.toFixed(1)} ms`; + if (cur !== undefined && cv > 0) { + const dp = (cur - cv) / cv * 100; + cmpLine += ` · Δ ${dp >= 0 ? '+' : ''}${dp.toFixed(1)}%`; + } + cmpLine += ''; + } if (i.hourly) { const f = v => v.toFixed(1); html += i.q ? `
hour min/p50/p90/p99/max = ${i.q.map(f).join('/')} ms` : '
no replies this hour'; html += `
${i.n} rounds`; html += `
loss ${i.lp.toFixed(2)}%`; if (i.bursts) html += ` · ◆ ${i.bursts} burst${i.bursts > 1 ? 's' : ''}`; - return html; + return html + cmpLine; } html += `
rtt min/avg/max/mdev = ${i.lo.toFixed(3)}/${i.avg.toFixed(3)}/${i.hi.toFixed(3)}/${i.mdev.toFixed(3)} ms`; html += `
median ${i.med.toFixed(1)} ms · ${i.n} samples`; html += `
loss ${i.lp.toFixed(0)}% (${i.lost}/${i.s})`; if (i.b) html += ` · ◆ burst z=${i.z ? i.z.toFixed(2) : '—'}`; - return html; + return html + cmpLine; } }, // 荧光束渐变:贴线的样本 = 炽亮核心,散开的样本渐冷渐暗 visualMap: { show: false, type: 'continuous', dimension: 2, min: 0, max: 0.5, @@ -541,16 +683,31 @@ { type: 'scatter', data: smoke, symbolSize: 2.2, silent: true, large: true }, { type: 'line', data: median, showSymbol: false, silent: true, z: 4, lineStyle: { color: phos, width: 7, opacity: 0.10 } }, - { type: 'line', data: median, showSymbol: false, + { id: 'median', type: 'line', data: median, showSymbol: false, lineStyle: { color: phos, width: 1.6, shadowColor: phos, shadowBlur: +cssv('--glow') * 2 }, z: 5 }, { type: 'bar', yAxisIndex: 1, data: lossBars, barMaxWidth: 3, itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: cssv('--alert') }, { offset: 1, color: cssv('--lossbar') }]) } }, { type: 'scatter', yAxisIndex: 1, data: burstPts, symbol: 'diamond', symbolSize: 9, itemStyle: { color: cssv('--alert') }, z: 6, - tooltip: { formatter: p => `◆ ${p.value[2]}` } } + tooltip: { formatter: p => `◆ ${p.value[2]}` } }, + // 对比虚线:只画 P50,不画烟。数据异步补进来(按 id 合并)。 + { id: 'cmp', name: cmpName, type: 'line', data: [], showSymbol: false, z: 3, + lineStyle: { color: cssv('--dim'), width: 1.3, type: 'dashed', opacity: 0.9 }, + itemStyle: { color: cssv('--dim') } } ] }, true); + cmpData = null; + + const cmp = await cmpP; + if (seq !== renderSeq || !cmp || cmp.mode !== cmpMode) return; // 过期 / 失败 / 已切换:主图照旧 + if (cmp.empty) { // 有多少画多少;一点都没有就只在图例上说明 + $('legend').textContent += ` (${T('console.cmpNoData')})`; + return; + } + cmpData = cmp; + // 当前是原始层而对比段只剩小时汇总时,按桶画阶梯线(桶以起点盖章,值管到下一个桶) + chart.setOption({ series: [{ id: 'cmp', data: cmp.pts, step: (cmp.hourly && !hourly) ? 'end' : false }] }); } @@ -575,6 +732,7 @@ $('selReset').style.display = ''; render(); }; +$('cmpMode').onchange = () => { cmpMode = $('cmpMode').value; render(); }; document.querySelectorAll('#tfilter button').forEach(b => b.onclick = () => { document.querySelectorAll('#tfilter button').forEach(x => x.classList.remove('on')); b.classList.add('on'); @@ -604,6 +762,8 @@ const el = $('ver'); if (el && d.version) el.textContent = 'v' + String(d.version).replace(/^v/, ''); const keep = (d.retention_days || 0) * 1440; + retentionSec = keep * 60; + if (syncCmpOptions(curWindow())) render(); // 当前对比模式超出保留期 -> 已退回关闭,重画 if (keep > 0) { document.querySelectorAll('.rng button[data-m]').forEach(b => { if (+b.dataset.m > keep) b.style.display = 'none';