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:
1 parent
32593b9515
commit
ebf6340ff0
8 files changed
+1119
-86
No files matched your search
+215
@@ -0,0 +1,215 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user