Skip to content

Commit ab74c69

Browse files
v2.4: unified smoke up to 30 days, box-select time range, sponsor + share panels
- smoke now renders for any window up to 30 days (7d/30d join the hot views); long ranges are stride-thinned server-side (~12k points, bursts always kept) so the browser stays smooth — 7d/30d look identical to the hour views, just denser history - ReadRange reads across day files from disk (not just the 24h ring); /api/series takes from/to + maxpts - drag to box-select any time span on the chart (ECharts dataZoom, zero new deps); '✕ selection' clears back to fixed windows - footer: sponsor GIF slot (left) + 'Star on GitHub / share' panel (right) linking the public project page to X/Facebook/Reddit/HN, Instagram = copy link
1 parent 65d4771 commit ab74c69

3 files changed

Lines changed: 156 additions & 11 deletions

File tree

static/index.html

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,19 @@
6565
footer { margin-top:12px; color:var(--dim); font-size:12px; display:flex; gap:10px; flex-wrap:wrap; }
6666
#conclusion { color:var(--ink); }
6767
.legend { color:var(--dim); }
68+
.promos { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-top:16px; }
69+
@media (max-width:720px) { .promos { grid-template-columns:1fr; } }
70+
.promo { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:14px 16px;
71+
box-shadow:var(--shadow); text-decoration:none; display:block; }
72+
.promo.sponsor { border-style:dashed; }
73+
.promo.sponsor:hover { border-color:var(--phos); }
74+
.promo-h { font-family:var(--mono); font-size:10px; letter-spacing:.16em; color:var(--dim); margin-bottom:6px; }
75+
.promo-b { color:var(--ink); font-size:13px; }
76+
.share .promo-b { display:flex; gap:14px; align-items:center; flex-wrap:wrap; }
77+
.share a { color:var(--phos); text-decoration:none; font-weight:600; }
78+
.share a:hover { text-decoration:underline; }
79+
.share-links { display:flex; gap:12px; }
80+
.share-links a { color:var(--dim); font-weight:500; }
6881
</style>
6982
</head>
7083
<body>
@@ -93,13 +106,13 @@
93106
<div class="ro"><div class="v" id="bursts"></div><div class="l">BURSTS · WINDOW</div></div>
94107
<div class="sp"></div>
95108
<div class="rng">
96-
<span>TIME/DIV</span>
109+
<button id="selReset" style="display:none;color:var(--amber)">✕ selection</button><span>TIME/DIV</span>
97110
<button data-m="60">1h</button>
98111
<button data-m="360" class="on">6h</button>
99112
<button data-m="720">12h</button>
100113
<button data-m="1440">24h</button>
101-
<button data-d="7">7d</button>
102-
<button data-d="30">1M</button>
114+
<button data-m="10080">7d</button>
115+
<button data-m="43200">30d</button>
103116
<button data-d="90">3M</button>
104117
<button data-d="180">6M</button>
105118
<button data-d="300">ALL</button>
@@ -112,6 +125,20 @@
112125
<span class="legend">Line = per-round median · Smoke = all samples · Red bars = loss% · ◆ = burst</span>
113126
</footer>
114127

128+
<div class="promos">
129+
<a class="promo sponsor" id="sponsorSlot" href="https://github.com/githubflyideas/pingping#sponsor" target="_blank" rel="noopener">
130+
<div class="promo-h">SPONSOR</div>
131+
<div class="promo-b">This slot is for one VPS / IDC / network sponsor. Your GIF here — reach out on GitHub.</div>
132+
</a>
133+
<div class="promo share">
134+
<div class="promo-h">LIKE PINGPING?</div>
135+
<div class="promo-b">
136+
<a href="https://github.com/githubflyideas/pingping" target="_blank" rel="noopener">★ Star on GitHub</a>
137+
<span class="share-links" id="shareLinks"></span>
138+
</div>
139+
</div>
140+
</div>
141+
115142
<script src="/static/echarts.min.js"></script>
116143
<script>
117144
const $ = id => document.getElementById(id);
@@ -151,14 +178,18 @@
151178
if ((!current || !visible.some(t => t.name === current)) && visible.length) current = visible[0].name;
152179
}
153180

181+
let sel = null; // {from,to} 框选时间段(unix 秒)
154182
let renderSeq = 0;
155183
async function render() {
156184
const seq = ++renderSeq;
157185
await loadTargets();
158186
const t = targets.find(x => x.name === current);
159187
if (!t) return;
160188
if (days > 0) { await renderBand(t); return; }
161-
const rounds = await (await fetch(`/api/series?target=${encodeURIComponent(current)}&minutes=${minutes}`)).json() || [];
189+
let url = `/api/series?target=${encodeURIComponent(current)}&maxpts=12000`;
190+
if (sel) url += `&from=${sel.from}&to=${sel.to}`;
191+
else url += `&minutes=${minutes}`;
192+
const rounds = await (await fetch(url)).json() || [];
162193
if (seq !== renderSeq) return; // 过期响应:已切走,丢弃
163194

164195
const smoke = [], median = [], lossBars = [], burstPts = [];
@@ -208,7 +239,15 @@
208239
const phos = cssv('--phos');
209240
chart.setOption({
210241
animation: false,
211-
grid: { left: 52, right: 46, top: 18, bottom: 42 },
242+
grid: { left: 52, right: 46, top: 18, bottom: 56 },
243+
dataZoom: [
244+
{ type: 'inside', filterMode: 'none' },
245+
{ type: 'slider', height: 16, bottom: 8, filterMode: 'none',
246+
borderColor: cssv('--line'), fillerColor: 'color-mix(in srgb, var(--phos) 12%, transparent)',
247+
handleStyle: { color: cssv('--phos') }, moveHandleStyle: { color: cssv('--dim') },
248+
dataBackground: { lineStyle: { color: cssv('--dim') }, areaStyle: { color: cssv('--grid-split') } },
249+
textStyle: { color: cssv('--dim'), fontSize: 9 } }
250+
],
212251
xAxis: { type: 'time', axisLine: { lineStyle: { color: cssv('--grid-axis') } },
213252
axisLabel: { color: cssv('--dim'), fontSize: 10 }, splitLine: { lineStyle: { color: cssv('--grid-split') } },
214253
axisPointer: { show: true, snap: false,
@@ -256,20 +295,61 @@
256295
}, true);
257296
}
258297

298+
// 图上拖拽框选一段时间 -> 精确查询那一段(dataZoom 的 datazoom 事件给出可视范围)
299+
let zoomTimer = null;
300+
chart.on('dataZoom', () => {
301+
clearTimeout(zoomTimer);
302+
zoomTimer = setTimeout(() => {
303+
const opt = chart.getOption();
304+
const dz = opt.dataZoom && opt.dataZoom[0];
305+
if (!dz || dz.startValue == null) return;
306+
const from = Math.floor(dz.startValue / 1000), to = Math.ceil(dz.endValue / 1000);
307+
if (to - from < 120) return; // 太窄忽略
308+
sel = { from, to };
309+
$('selReset').style.display = '';
310+
render();
311+
}, 350);
312+
});
313+
259314
document.querySelectorAll('.rng button[data-m], .rng button[data-d]').forEach(b => b.onclick = () => {
315+
sel = null; $('selReset').style.display = 'none';
260316
document.querySelectorAll('.rng button[data-m], .rng button[data-d]').forEach(x => x.classList.remove('on'));
261317
b.classList.add('on');
262318
if (b.dataset.d) { days = +b.dataset.d; minutes = 0; }
263319
else { minutes = +b.dataset.m; days = 0; }
264320
render();
265321
});
322+
$('selReset').onclick = () => { sel = null; $('selReset').style.display = 'none'; render(); };
266323
document.querySelectorAll('#tfilter button').forEach(b => b.onclick = () => {
267324
document.querySelectorAll('#tfilter button').forEach(x => x.classList.remove('on'));
268325
b.classList.add('on');
269326
typeFilter = b.dataset.t;
270327
render();
271328
});
272329

330+
// 分享的是公开项目主页,不是本地私有面板
331+
(function buildShare() {
332+
const url = 'https://github.com/githubflyideas/pingping';
333+
const text = 'pingping — a single-binary link quality oscilloscope. One binary. One graph. Zero guesswork.';
334+
const links = {
335+
X: `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}`,
336+
Facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
337+
Reddit: `https://www.reddit.com/submit?url=${encodeURIComponent(url)}&title=${encodeURIComponent('pingping — modern SmokePing alternative')}`,
338+
HN: `https://news.ycombinator.com/submitlink?u=${encodeURIComponent(url)}&t=${encodeURIComponent('pingping — single-binary link quality oscilloscope')}`
339+
};
340+
const box = document.getElementById('shareLinks');
341+
Object.entries(links).forEach(([k, href]) => {
342+
const a = document.createElement('a');
343+
a.href = href; a.target = '_blank'; a.rel = 'noopener'; a.textContent = k;
344+
box.appendChild(a);
345+
});
346+
const ig = document.createElement('a');
347+
ig.href = '#'; ig.textContent = 'Instagram (copy link)';
348+
ig.onclick = e => { e.preventDefault(); navigator.clipboard && navigator.clipboard.writeText(url);
349+
ig.textContent = 'link copied ✓'; setTimeout(() => ig.textContent = 'Instagram (copy link)', 1500); };
350+
box.appendChild(ig);
351+
})();
352+
273353
render();
274354
setInterval(render, 60000);
275355
// Long windows: smoke is a hot-data privilege — beyond 24h you get the

store.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,59 @@ func bucketRounds(rounds []Round, size int64) []BandRow {
213213
return out
214214
}
215215

216+
// ReadRange returns raw rounds in [from,to]. It reads the in-memory ring when the
217+
// range is recent, else falls back to per-day files on disk — so smoke can render
218+
// for any window inside the retention period, not just the last 24h.
219+
// maxPts>0 thins the result to about that many samples (stride keep) so a 30-day
220+
// window doesn't ship millions of points the browser can't draw.
221+
func (s *Store) ReadRange(name string, from, to int64, maxPts int) []Round {
222+
var rounds []Round
223+
// ring first (covers the recent tail cheaply)
224+
s.mu.RLock()
225+
ring := s.rings[name]
226+
ringFrom := int64(1<<62)
227+
if len(ring) > 0 {
228+
ringFrom = ring[0].T
229+
}
230+
s.mu.RUnlock()
231+
232+
if from >= ringFrom {
233+
rounds = s.Recent(name, from)
234+
} else {
235+
for d := time.Unix(from, 0); !d.After(time.Unix(to, 0)); d = d.AddDate(0, 0, 1) {
236+
day, _ := s.readDay(name, d.Format("2006-01-02"))
237+
rounds = append(rounds, day...)
238+
}
239+
}
240+
// clip to [from,to]
241+
out := rounds[:0]
242+
for _, r := range rounds {
243+
if r.T >= from && r.T <= to {
244+
out = append(out, r)
245+
}
246+
}
247+
return thin(out, maxPts)
248+
}
249+
250+
// thin keeps roughly maxPts rounds by uniform stride. Rounds flagged as bursts are
251+
// always kept — an anomaly must never be sampled away.
252+
func thin(rounds []Round, maxPts int) []Round {
253+
if maxPts <= 0 || len(rounds) <= maxPts {
254+
return rounds
255+
}
256+
stride := (len(rounds) + maxPts - 1) / maxPts // ceil: 保证抽样后不超过 maxPts(突发除外)
257+
if stride < 1 {
258+
stride = 1
259+
}
260+
out := make([]Round, 0, maxPts+64)
261+
for i, r := range rounds {
262+
if i%stride == 0 || r.B {
263+
out = append(out, r)
264+
}
265+
}
266+
return out
267+
}
268+
216269
func (s *Store) readDay(name, day string) ([]Round, error) {
217270
f, err := os.Open(s.dayFile(name, day))
218271
if err != nil {

web.go

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -147,14 +147,26 @@ func serveWeb(cfg *Config, store *Store, users map[string]string) error {
147147
writeJSON(w, out)
148148
}))
149149

150-
// raw rounds for smoke (≤24h)
150+
// raw rounds for smoke. Supports either minutes=N (recent window) or from/to unix
151+
// (arbitrary range, e.g. box-select). maxpts thins long ranges to stay drawable.
151152
mux.HandleFunc("/api/series", guard(func(w http.ResponseWriter, r *http.Request) {
152-
name := r.URL.Query().Get("target")
153-
minutes, _ := strconv.Atoi(r.URL.Query().Get("minutes"))
154-
if minutes <= 0 || minutes > 1440 {
155-
minutes = 360
153+
q := r.URL.Query()
154+
name := q.Get("target")
155+
from, _ := strconv.ParseInt(q.Get("from"), 10, 64)
156+
to, _ := strconv.ParseInt(q.Get("to"), 10, 64)
157+
maxpts, _ := strconv.Atoi(q.Get("maxpts"))
158+
if maxpts <= 0 || maxpts > 40000 {
159+
maxpts = 12000 // ~one screen of smoke; keeps the browser smooth
156160
}
157-
writeJSON(w, store.Recent(name, time.Now().Add(-time.Duration(minutes)*time.Minute).Unix()))
161+
if from == 0 || to == 0 {
162+
minutes, _ := strconv.Atoi(q.Get("minutes"))
163+
if minutes <= 0 || minutes > 43200 { // up to 30-day hot window
164+
minutes = 360
165+
}
166+
to = time.Now().Unix()
167+
from = to - int64(minutes)*60
168+
}
169+
writeJSON(w, store.ReadRange(name, from, to, maxpts))
158170
}))
159171

160172
// aggregated buckets for long windows (7d/1M/3M/6M/all)

0 commit comments

Comments
 (0)