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

+31 -3
View File
@@ -5,20 +5,34 @@ import (
"regexp"
"strconv"
"strings"
"time"
)
// Filter holds the search criteria sent by the UI.
type Filter struct {
Mode string // "simple" or "logsql"
Text string
Range string // 5m, 15m, 1h, 6h, 24h, 7d, 30d; anything else = no time limit
Range string // 5m, 15m, 1h, 6h, 24h, 7d, 30d; anything else = no time limit
From, To time.Time // absolute range (timeline zoom), replaces Range when From is set
Host string
App string
Severity int // highest severity number included (0 = emerg … 7 = debug), -1 = all
Source string // "syslog", "docker" or "" for all
}
var validRanges = map[string]bool{"5m": true, "15m": true, "1h": true, "6h": true, "24h": true, "7d": true, "30d": true}
var rangeDurations = map[string]time.Duration{
"5m": 5 * time.Minute, "15m": 15 * time.Minute, "1h": time.Hour, "6h": 6 * time.Hour,
"24h": 24 * time.Hour, "7d": 7 * 24 * time.Hour, "30d": 30 * 24 * time.Hour,
}
// msParam reads a Unix time in milliseconds (zero time when absent or invalid).
func msParam(v string) time.Time {
ms, err := strconv.ParseInt(v, 10, 64)
if err != nil || ms <= 0 {
return time.Time{}
}
return time.UnixMilli(ms)
}
func FilterFromRequest(r *http.Request) Filter {
q := r.URL.Query()
@@ -34,6 +48,13 @@ func FilterFromRequest(r *http.Request) Filter {
if v, err := strconv.Atoi(q.Get("severity")); err == nil && v >= 0 && v <= 7 {
f.Severity = v
}
// from/to: Unix milliseconds, sent when the timeline is zoomed.
if from := msParam(q.Get("from")); !from.IsZero() {
f.From, f.To = from, msParam(q.Get("to"))
if !f.To.IsZero() && !f.To.After(f.From) {
f.To = time.Time{}
}
}
return f
}
@@ -83,7 +104,12 @@ func parseTerms(s string) []term {
// filterExpr turns the criteria (except raw LogsQL text) into a LogsQL filter.
func (f Filter) filterExpr() string {
var parts []string
if validRanges[f.Range] {
switch {
case !f.From.IsZero() && !f.To.IsZero():
parts = append(parts, "_time:["+logsqlTime(f.From)+", "+logsqlTime(f.To)+")")
case !f.From.IsZero():
parts = append(parts, "_time:>="+logsqlTime(f.From))
case rangeDurations[f.Range] > 0:
parts = append(parts, "_time:"+f.Range)
}
if f.Host != "" {
@@ -119,6 +145,8 @@ func (f Filter) filterExpr() string {
return strings.Join(parts, " ")
}
func logsqlTime(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) }
// LogsQL returns the filter part and any pipes ("| …") typed in LogsQL mode.
func (f Filter) LogsQL() (filter, pipes string) {
filter = f.filterExpr()