Timeline: scales, graduations, tooltip, colors, live updates and zoom

The histogram above the list becomes a configurable timeline:

- Scale linear / sqrt (default) / log, height S/M/L, bars or area.
- Colors: stacked by severity (error+, warning, the rest), intensity
  against the median of the window (calm, burst >3x, anomaly >10x), or
  none; colors are CSS variables with light and dark values.
- Vertical graduations on round local times (hh:mm:ss, hh:mm, dd/mm).
- Tooltip: interval bounds, total and detail per severity.
- Division automatic (~100 intervals) or fixed (1 s to 1 day), capped at
  300 intervals by the server; intervals aligned on the local time zone.
- Refresh off / 5 s / 15 s / 30 s / 1 min / at each new interval. In
  live mode the last interval is incremented from the SSE stream and the
  timeline reloads at each new interval; paused while the tab is hidden.
- Click a bar to zoom on its interval, drag to zoom on a selection: the
  list, export and counters follow through new from/to parameters.

The timeline now runs on the server clock (bounds and "now" come from
/api/histogram): the axis was drawn from the browser clock and refreshed
every 30 s only, so a clock difference with the server or a hidden tab
left it behind the logs.

/api/histogram returns interval indexes with the count per severity; the
division logic lives in histogram.go with table-driven tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
cedricandClaude Opus 5.5 committed 2026-10-01 20:23:18 +02:00
1 parent 32593b9515
commit ebf6340ff0
8 files changed
+1119 -86

No files matched your search

-34
View File
@@ -142,40 +142,6 @@ func (a *API) logs(w http.ResponseWriter, r *http.Request) {
})
}
var histSteps = map[string]struct {
expr string
sec int
}{
"5m": {"10s", 10}, "15m": {"30s", 30}, "1h": {"1m", 60}, "6h": {"5m", 300},
"24h": {"15m", 900}, "7d": {"2h", 7200}, "30d": {"12h", 43200},
}
// GET /api/histogram: message count per time bucket.
func (a *API) histogram(w http.ResponseWriter, r *http.Request) {
f := FilterFromRequest(r)
step, ok := histSteps[f.Range]
if !ok {
step.expr, step.sec = "1d", 86400
}
filter, _ := f.LogsQL()
q := filter + " | stats by (_time:" + step.expr + ") count() hits | sort by (_time)"
rows, err := a.store.Query(r.Context(), q)
if err != nil {
writeErr(w, queryStatus(err), err)
return
}
type bucket struct {
T string `json:"t"`
N int64 `json:"n"`
}
out := make([]bucket, 0, len(rows))
for _, row := range rows {
t, _ := row["_time"].(string)
out = append(out, bucket{T: t, N: toInt(row["hits"])})
}
writeJSON(w, http.StatusOK, map[string]any{"step": step.sec, "buckets": out})
}
func toInt(v any) int64 {
switch x := v.(type) {
case string: