Files
cedricandClaude Opus 5.5 ebf6340ff0 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>
2026-10-01 20:23:18 +02:00

216 lines
6.4 KiB
Go

package main
import (
"net/http"
"strconv"
"time"
)
const (
histAutoBuckets = 100 // target number of intervals with the automatic division
histMaxBuckets = 300 // hard limit, whatever the division asked for
)
// Divisions the automatic mode picks from; a fixed division too fine for the
// span is enlarged to the first of them that fits in histMaxBuckets.
var histLadder = []time.Duration{
time.Second, 2 * time.Second, 5 * time.Second, 10 * time.Second, 15 * time.Second, 30 * time.Second,
time.Minute, 2 * time.Minute, 5 * time.Minute, 10 * time.Minute, 15 * time.Minute, 30 * time.Minute,
time.Hour, 2 * time.Hour, 3 * time.Hour, 6 * time.Hour, 12 * time.Hour,
24 * time.Hour, 2 * 24 * time.Hour, 7 * 24 * time.Hour, 30 * 24 * time.Hour,
}
// Fixed divisions offered in Settings > Interface > Timeline.
var histFixedSteps = map[string]time.Duration{
"1s": time.Second, "10s": 10 * time.Second, "1m": time.Minute,
"5m": 5 * time.Minute, "1h": time.Hour, "1d": 24 * time.Hour,
}
// histPlan is the bucketing of a time range: count intervals of step,
// starting at start, aligned on the local time (offset east of UTC).
type histPlan struct {
step time.Duration
start time.Time
count int
capped bool // the fixed division asked for was enlarged
}
// histAlign returns the start of the interval containing t; intervals are
// aligned so that local midnight, local hours… fall on boundaries.
func histAlign(t time.Time, step, offset time.Duration) time.Time {
ns := t.UnixNano() + int64(offset)
s := int64(step)
k := ns / s
if ns%s < 0 {
k--
}
return time.Unix(0, k*s-int64(offset))
}
// histBuckets returns the plan for [from, to) with this step.
func histBuckets(from, to time.Time, step, offset time.Duration) histPlan {
start := histAlign(from, step, offset)
n := int((to.Sub(start) + step - 1) / step)
if n < 1 {
n = 1
}
return histPlan{step: step, start: start, count: n}
}
// planHistogram chooses the division of [from, to): `want` is "auto" (about
// histAutoBuckets intervals) or one of histFixedSteps (enlarged when it would
// exceed histMaxBuckets intervals).
func planHistogram(from, to time.Time, want string, offset time.Duration) histPlan {
if !to.After(from) {
to = from.Add(time.Second)
}
limit := histAutoBuckets
var fixed time.Duration
if d, ok := histFixedSteps[want]; ok {
fixed, limit = d, histMaxBuckets
if p := histBuckets(from, to, fixed, offset); p.count <= histMaxBuckets {
return p
}
}
for _, step := range histLadder {
if step < fixed {
continue
}
if p := histBuckets(from, to, step, offset); p.count <= limit {
p.capped = fixed > 0
return p
}
}
// Longer than the ladder allows (years): whole days, as many as needed.
day := 24 * time.Hour
step := (to.Sub(from)/time.Duration(histMaxBuckets-1) + day - 1) / day * day
p := histBuckets(from, to, step, offset)
p.capped = fixed > 0
return p
}
// queryStep is the division asked to VictoriaLogs: its intervals are aligned
// on UTC, so when the local offset does not fall on a boundary (1d in Paris),
// finer intervals are summed here into the local ones.
func queryStep(step, offset time.Duration) time.Duration {
a, b := step, (offset%step+step)%step
for b != 0 {
a, b = b, a%b
}
if a < time.Second {
return time.Second
}
return a
}
// logsqlDuration writes a step the way LogsQL expects it (1d, 2h, 5m, 10s).
func logsqlDuration(d time.Duration) string {
switch {
case d%(24*time.Hour) == 0:
return strconv.FormatInt(int64(d/(24*time.Hour)), 10) + "d"
case d%time.Hour == 0:
return strconv.FormatInt(int64(d/time.Hour), 10) + "h"
case d%time.Minute == 0:
return strconv.FormatInt(int64(d/time.Minute), 10) + "m"
}
return strconv.FormatInt(int64(d/time.Second), 10) + "s"
}
// GET /api/histogram?<filters>&step=auto|1s|10s|1m|5m|1h|1d&tz=Europe/Paris
// Message count per interval, with the detail per severity. Every bound is
// returned in Unix milliseconds, from the server clock.
func (a *API) histogram(w http.ResponseWriter, r *http.Request) {
f := FilterFromRequest(r)
now := time.Now()
from, to := f.From, f.To
switch {
case !from.IsZero():
if to.IsZero() {
to = now
}
case rangeDurations[f.Range] > 0:
from, to = now.Add(-rangeDurations[f.Range]), now
default: // all logs: from the oldest one matching the filters
from, to = now.Add(-time.Hour), now
filter, _ := f.LogsQL()
rows, err := a.store.Query(r.Context(), filter+" | stats min(_time) first")
if err != nil {
writeErr(w, queryStatus(err), err)
return
}
if len(rows) > 0 {
if s, _ := rows[0]["first"].(string); s != "" {
if t, err := time.Parse(time.RFC3339Nano, s); err == nil && t.Before(from) {
from = t
}
}
}
}
loc := time.Local
if tz := r.URL.Query().Get("tz"); tz != "" {
if l, err := time.LoadLocation(tz); err == nil {
loc = l
}
}
_, offSec := to.In(loc).Zone()
offset := time.Duration(offSec) * time.Second
p := planHistogram(from, to, r.URL.Query().Get("step"), offset)
// Whole intervals, so that the first bar is not cut by the range start.
hf := f
hf.From, hf.To = p.start, p.start.Add(time.Duration(p.count)*p.step)
filter, _ := hf.LogsQL()
rows, err := a.store.Query(r.Context(), filter+" | stats by (_time:"+logsqlDuration(queryStep(p.step, offset))+", severity) count() hits")
if err != nil {
writeErr(w, queryStatus(err), err)
return
}
type bucket struct {
I int `json:"i"` // interval index, from 0
N int64 `json:"n"` // total
Sev map[string]int64 `json:"sev"` // count per severity name
}
byIdx := map[int]*bucket{}
for _, row := range rows {
s, _ := row["_time"].(string)
t, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
continue
}
i := int(histAlign(t, p.step, offset).Sub(p.start) / p.step)
if i < 0 || i >= p.count {
continue
}
b := byIdx[i]
if b == nil {
b = &bucket{I: i, Sev: map[string]int64{}}
byIdx[i] = b
}
n := toInt(row["hits"])
sev, _ := row["severity"].(string)
if sev == "" {
sev = "info"
}
b.N += n
b.Sev[sev] += n
}
out := make([]*bucket, 0, len(byIdx))
for i := 0; i < p.count; i++ {
if b := byIdx[i]; b != nil {
out = append(out, b)
}
}
writeJSON(w, http.StatusOK, map[string]any{
"step": int64(p.step / time.Millisecond),
"start": p.start.UnixMilli(),
"count": p.count,
"from": from.UnixMilli(),
"to": to.UnixMilli(),
"now": now.UnixMilli(),
"capped": p.capped,
"buckets": out,
})
}