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

+26
View File
@@ -64,6 +64,31 @@ Severity badges `err`/`crit` and `warning` use the colors of the `error` and `wa
Shortcuts: `/` focuses the search box, `Esc` clears it. Clicking a row shows all its fields.
## Timeline
The timeline above the list shows the volume of logs per interval, counted by **reception
time** on the server clock (so it matches the times shown in the rows).
- Hover an interval: its bounds, total and detail per severity.
- Click a bar to zoom on that interval, or drag across several bars to zoom on the selection.
The time range then shows the zoomed period ("× Reset zoom" or any other range leaves it);
the list, the counters and the CSV export follow the zoom, and the live view pauses.
- In live mode the last interval grows as messages arrive, and the timeline reloads at each new
interval. Nothing is refreshed while the browser tab is hidden; it catches up when shown again.
**Settings > Interface > Timeline** (remembered per browser): scale (linear, √ by default, log),
height (S/M/L: 40/80/120 px), color (stacked by severity, intensity compared with the median of
the window: calm, burst above 3×, anomaly above 10×, or none), bars or area, division
(automatic, about 100 intervals, or fixed: 1 s, 10 s, 1 min, 5 min, 1 h, 1 day) and refresh
(off, 5 s, 15 s, 30 s, 1 min, or at each new interval). A fixed division that would exceed
300 intervals over the range is enlarged (shown as "enlarged"). Intervals are aligned on the
local time of the time zone chosen in Settings (days start at local midnight).
API: `curl 'localhost:8080/api/histogram?range=24h&step=auto&tz=Europe/Paris'` returns
`step` (ms), `start` (ms), `count`, `now` (server clock) and the non-empty `buckets`
(`i` = interval index, `n` = total, `sev` = count per severity). Every log endpoint also accepts
`from` / `to` (Unix milliseconds) instead of `range`.
## Docker container logs
Logstream also collects the logs of the Docker containers running on the machine where it is
@@ -223,6 +248,7 @@ To update one of them:
| `syslog.go` | UDP/TCP listeners and RFC 3164 / 5424 parsing |
| `store.go` | batched inserts into VictoriaLogs and LogsQL queries |
| `query.go` | turns UI filters into LogsQL; live-view filter |
| `histogram.go` | timeline: division, interval alignment, `/api/histogram` |
| `hub.go` | pushes new messages to browsers (SSE) |
| `rdns.go` | cached reverse DNS lookups |
| `export.go` | streamed CSV export |
-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:
+215
View File
@@ -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,
})
}
+236
View File
@@ -0,0 +1,236 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestPlanHistogram(t *testing.T) {
now := time.Date(2026, 10, 1, 18, 7, 42, 0, time.UTC)
paris := 2 * time.Hour
kolkata := 5*time.Hour + 30*time.Minute
day := 24 * time.Hour
tests := []struct {
name string
span time.Duration
want string
offset time.Duration
step time.Duration
capped bool
}{
{"5m auto", 5 * time.Minute, "auto", paris, 5 * time.Second, false},
{"1h auto", time.Hour, "auto", paris, time.Minute, false},
{"24h auto", 24 * time.Hour, "", paris, 15 * time.Minute, false},
{"7d auto", 7 * day, "auto", paris, 2 * time.Hour, false},
{"30d auto", 30 * day, "auto", paris, 12 * time.Hour, false},
{"unknown step = auto", time.Hour, "3s", 0, time.Minute, false},
{"1s fixed fits", 5 * time.Minute, "1s", 0, time.Second, false},
{"1s fixed on 1h is enlarged", time.Hour, "1s", 0, 15 * time.Second, true},
{"1s fixed on 24h is enlarged", 24 * time.Hour, "1s", 0, 5 * time.Minute, true},
{"1m fixed on 6h", 6 * time.Hour, "1m", 0, 2 * time.Minute, true},
{"5m fixed on 24h", 24 * time.Hour, "5m", paris, 5 * time.Minute, false},
{"1h fixed on 30d", 30 * day, "1h", paris, 3 * time.Hour, true},
{"1d fixed on 30d", 30 * day, "1d", paris, day, false},
{"1d fixed on 1h", time.Hour, "1d", paris, day, false},
{"years: whole days", 3 * 365 * day, "auto", 0, 30 * day, false},
{"beyond the ladder", 30 * 365 * day, "1d", 0, 37 * day, true},
{"half-hour zone", 24 * time.Hour, "1h", kolkata, time.Hour, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
p := planHistogram(now.Add(-tc.span), now, tc.want, tc.offset)
if p.step != tc.step || p.capped != tc.capped {
t.Errorf("step %v capped %v, want %v capped %v", p.step, p.capped, tc.step, tc.capped)
}
if p.count < 1 || p.count > histMaxBuckets {
t.Errorf("count %d out of [1, %d]", p.count, histMaxBuckets)
}
if p.start.After(now.Add(-tc.span)) {
t.Errorf("start %v after the range start", p.start)
}
if end := p.start.Add(time.Duration(p.count) * p.step); end.Before(now) {
t.Errorf("intervals end at %v, before %v", end, now)
}
if local := p.start.Add(tc.offset).UnixNano() % int64(p.step); local != 0 {
t.Errorf("start %v not aligned on local time (%v)", p.start, time.Duration(local))
}
})
}
}
func TestHistAlign(t *testing.T) {
paris := 2 * time.Hour
tests := []struct {
t time.Time
step time.Duration
offset time.Duration
want time.Time
}{
{time.Date(2026, 10, 1, 18, 7, 42, 0, time.UTC), time.Minute, 0, time.Date(2026, 10, 1, 18, 7, 0, 0, time.UTC)},
// Local midnight in Paris (UTC+2) is 22:00 UTC the day before.
{time.Date(2026, 10, 1, 18, 7, 42, 0, time.UTC), 24 * time.Hour, paris, time.Date(2026, 9, 30, 22, 0, 0, 0, time.UTC)},
{time.Date(2026, 10, 1, 23, 0, 0, 0, time.UTC), 24 * time.Hour, paris, time.Date(2026, 10, 1, 22, 0, 0, 0, time.UTC)},
// New York (UTC-4): midnight is 04:00 UTC.
{time.Date(2026, 10, 1, 3, 0, 0, 0, time.UTC), 24 * time.Hour, -4 * time.Hour, time.Date(2026, 9, 30, 4, 0, 0, 0, time.UTC)},
// Before 1970: floor, not truncation toward zero.
{time.Date(1969, 12, 31, 23, 59, 30, 0, time.UTC), time.Minute, 0, time.Date(1969, 12, 31, 23, 59, 0, 0, time.UTC)},
}
for _, tc := range tests {
if got := histAlign(tc.t, tc.step, tc.offset); !got.Equal(tc.want) {
t.Errorf("histAlign(%v, %v, %v) = %v, want %v", tc.t, tc.step, tc.offset, got.UTC(), tc.want)
}
}
}
func TestQueryStep(t *testing.T) {
h := time.Hour
tests := []struct{ step, offset, want time.Duration }{
{time.Minute, 2 * h, time.Minute},
{24 * h, 2 * h, 2 * h},
{24 * h, -4 * h, 4 * h},
{3 * h, 2 * h, h},
{24 * h, 5*h + 30*time.Minute, 30 * time.Minute},
{24 * h, 0, 24 * h},
}
for _, tc := range tests {
if got := queryStep(tc.step, tc.offset); got != tc.want {
t.Errorf("queryStep(%v, %v) = %v, want %v", tc.step, tc.offset, got, tc.want)
}
}
}
func TestLogsqlDuration(t *testing.T) {
for d, want := range map[time.Duration]string{
time.Second: "1s", 90 * time.Second: "90s", 5 * time.Minute: "5m", 2 * time.Hour: "2h", 48 * time.Hour: "2d",
} {
if got := logsqlDuration(d); got != want {
t.Errorf("logsqlDuration(%v) = %q, want %q", d, got, want)
}
}
}
func TestFilterTimeRange(t *testing.T) {
from := time.Date(2026, 10, 1, 17, 52, 0, 0, time.UTC)
to := from.Add(15 * time.Minute)
f := Filter{Range: "1h", From: from, To: to, Severity: -1}
if got, want := f.filterExpr(), "_time:[2026-10-01T17:52:00Z, 2026-10-01T18:07:00Z)"; got != want {
t.Errorf("zoomed filter = %q, want %q", got, want)
}
f = Filter{Range: "1h", Severity: -1}
if got := f.filterExpr(); got != "_time:1h" {
t.Errorf("relative filter = %q", got)
}
}
// fakeVL answers LogsQL queries like VictoriaLogs would, without a network.
type fakeVL struct {
queries []string
answer func(query string) string
}
func (f *fakeVL) RoundTrip(r *http.Request) (*http.Response, error) {
_ = r.ParseForm()
q := r.PostForm.Get("query")
f.queries = append(f.queries, q)
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(f.answer(q))), Header: http.Header{}}, nil
}
func TestHistogramHandler(t *testing.T) {
now := time.Now()
// Two intervals of the last hour: one with errors and infos, one with a warning.
recent := now.Add(-3 * time.Minute).UTC().Truncate(time.Minute)
older := now.Add(-40 * time.Minute).UTC().Truncate(time.Minute)
vl := &fakeVL{answer: func(string) string {
return fmt.Sprintf(`{"_time":%q,"severity":"err","hits":"2"}
{"_time":%q,"severity":"info","hits":"5"}
{"_time":%q,"severity":"warning","hits":"1"}
{"_time":"2000-01-01T00:00:00Z","severity":"info","hits":"9"}
`, recent.Format(time.RFC3339), recent.Format(time.RFC3339), older.Format(time.RFC3339))
}}
store := NewStore("http://vl", 10, 10, time.Second)
store.streamClient.Transport = vl
a := &API{store: store}
rec := httptest.NewRecorder()
a.histogram(rec, httptest.NewRequest("GET", "/api/histogram?range=1h&step=auto&tz=Europe/Paris&severity=4", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
var res struct {
Step, Start, Now int64
Count int
Buckets []struct {
I int
N int64
Sev map[string]int64
}
}
if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil {
t.Fatal(err)
}
if res.Step != 60000 || res.Count != 61 {
t.Errorf("step %d count %d, want 60000 and 61", res.Step, res.Count)
}
q := vl.queries[0]
for _, want := range []string{"_time:[", `severity:in("emerg","alert","crit","err","warning")`, "| stats by (_time:1m, severity) count() hits"} {
if !strings.Contains(q, want) {
t.Errorf("query %q lacks %q", q, want)
}
}
if len(res.Buckets) != 2 {
t.Fatalf("buckets %+v, want 2 (the one outside the range is dropped)", res.Buckets)
}
b := res.Buckets[1]
if want := int((recent.UnixMilli() - res.Start) / res.Step); b.I != want || b.N != 7 || b.Sev["err"] != 2 || b.Sev["info"] != 5 {
t.Errorf("recent bucket %+v, want i=%d n=7 err=2 info=5", b, want)
}
if res.Buckets[0].Sev["warning"] != 1 {
t.Errorf("older bucket %+v", res.Buckets[0])
}
}
func TestHistogramDayInParis(t *testing.T) {
// 30 days, 1 day intervals in Paris: VictoriaLogs is asked for 2h
// intervals, summed here into local days (22:00 UTC boundaries in summer).
vl := &fakeVL{answer: func(q string) string {
if !strings.Contains(q, "stats by (_time:2h, severity)") {
return ""
}
return `{"_time":"2026-09-20T20:00:00Z","severity":"info","hits":"1"}
{"_time":"2026-09-20T22:00:00Z","severity":"info","hits":"10"}
{"_time":"2026-09-21T20:00:00Z","severity":"info","hits":"100"}
`
}}
store := NewStore("http://vl", 10, 10, time.Second)
store.streamClient.Transport = vl
a := &API{store: store}
from := time.Date(2026, 9, 10, 0, 0, 0, 0, time.UTC)
to := time.Date(2026, 9, 30, 0, 0, 0, 0, time.UTC)
rec := httptest.NewRecorder()
a.histogram(rec, httptest.NewRequest("GET", fmt.Sprintf("/api/histogram?from=%d&to=%d&step=1d&tz=Europe/Paris", from.UnixMilli(), to.UnixMilli()), nil))
var res struct {
Start int64
Buckets []struct{ I, N int }
}
if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil {
t.Fatalf("%v: %s (queries %q)", err, rec.Body, vl.queries)
}
if got := time.UnixMilli(res.Start).UTC(); !got.Equal(time.Date(2026, 9, 9, 22, 0, 0, 0, time.UTC)) {
t.Errorf("start %v, want the Paris midnight 2026-09-09T22:00Z", got)
}
want := map[int]int{10: 1, 11: 110} // index 0 = Sept 10 in Paris; 20:00Z on the 20th is still the 20th
if len(res.Buckets) != len(want) {
t.Fatalf("buckets %+v, want %v", res.Buckets, want)
}
for _, b := range res.Buckets {
if want[b.I] != b.N {
t.Errorf("bucket %d = %d, want %d", b.I, b.N, want[b.I])
}
}
}
+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()
+515 -39
View File
@@ -112,6 +112,20 @@ const I18N = {
err_purge_unavailable: 'VictoriaLogs refuses deletions: start it with -delete.enable (see docker-compose.yml)',
err_purge_forbidden: 'Purging is disabled on this server (ALLOW_PURGE=false)',
err_purge_confirm: 'Type PURGE to confirm',
liveZoomed: 'Live view is not available on a zoomed range',
connZoom: 'live paused (zoom)',
histoTitle: 'Timeline',
hScale: 'Scale', hLinear: 'Linear', hHeight: 'Height', hColor: 'Color',
hColSeverity: 'Severity', hColIntensity: 'Intensity', hColNone: 'None',
hRender: 'Display', hBars: 'Bars', hArea: 'Area',
hStep: 'Division', hAuto: 'Automatic', hRefresh: 'Refresh', hOff: 'Off', hRefreshAuto: 'At each new interval',
histoHelp: 'Click a bar to zoom on its interval, or drag across several. The √ scale keeps small volumes visible next to bursts. Intensity compares each interval with the median of the window: calm, burst (more than 3×), anomaly (more than 10×). In live mode, the last interval is updated as messages arrive.',
hLeg_err: 'error and above', hLeg_warn: 'warning', hLeg_other: 'notice, info, debug',
hLeg_calm: 'calm', hLeg_burst: 'burst > 3×', hLeg_anom: 'anomaly > 10×',
hTotal: 'Total', hRatio: (x) => `${x}× the median`, hUnshown: 'not detailed (high rate)',
hDivision: (s) => `interval ${s}`, hCapped: 'enlarged',
hCappedTitle: 'The chosen division would exceed 300 intervals over this range: it has been enlarged.',
hUnzoom: '× Reset zoom', unitDay: 'd',
},
fr: {
locale: 'fr-FR',
@@ -222,6 +236,20 @@ const I18N = {
err_purge_unavailable: 'VictoriaLogs refuse les suppressions : lancez-le avec -delete.enable (voir docker-compose.yml)',
err_purge_forbidden: 'La purge est désactivée sur ce serveur (ALLOW_PURGE=false)',
err_purge_confirm: 'Tapez PURGE pour confirmer',
liveZoomed: 'Le direct n\'est pas disponible sur une plage zoomée',
connZoom: 'direct en pause (zoom)',
histoTitle: 'Frise',
hScale: 'Échelle', hLinear: 'Linéaire', hHeight: 'Hauteur', hColor: 'Couleur',
hColSeverity: 'Sévérité', hColIntensity: 'Intensité', hColNone: 'Aucune',
hRender: 'Rendu', hBars: 'Barres', hArea: 'Aire',
hStep: 'Division', hAuto: 'Automatique', hRefresh: 'Rafraîchissement', hOff: 'Désactivé', hRefreshAuto: 'À chaque nouvel intervalle',
histoHelp: 'Cliquez sur une barre pour zoomer sur son intervalle, ou glissez sur plusieurs. L\'échelle √ garde visibles les petits volumes à côté des rafales. L\'intensité compare chaque intervalle à la médiane de la fenêtre : calme, rafale (plus de 3×), anomalie (plus de 10×). En direct, le dernier intervalle se met à jour à l\'arrivée des messages.',
hLeg_err: 'error et plus', hLeg_warn: 'warning', hLeg_other: 'notice, info, debug',
hLeg_calm: 'calme', hLeg_burst: 'rafale > 3×', hLeg_anom: 'anomalie > 10×',
hTotal: 'Total', hRatio: (x) => `${x}× la médiane`, hUnshown: 'sans détail (débit élevé)',
hDivision: (s) => `intervalle ${s}`, hCapped: 'élargi',
hCappedTitle: 'La division choisie dépasserait 300 intervalles sur cette plage : elle a été élargie.',
hUnzoom: '× Annuler le zoom', unitDay: 'j',
},
};
@@ -372,7 +400,7 @@ const state = {
live: store.get('live', '1') === '1',
es: null,
reqId: 0,
histo: null,
zoom: null, // {from, to} in ms when the timeline is zoomed (range "custom")
tookMs: null,
conn: ['', 'connPaused'],
stats: null,
@@ -401,7 +429,8 @@ function setLang(next) {
setConn(...state.conn);
renderList();
updateCount(state.tookMs);
renderHisto(state.histo);
setZoomOption();
renderHisto();
renderStats();
renderTagList();
renderTimeSettings();
@@ -498,6 +527,7 @@ function renderInterface() {
sel.innerHTML = LOG_FONTS.map((f) => `<option value="${f.id}">${esc(f.family || t('fontSystem'))}</option>`).join('');
sel.value = ui.font;
renderFontPreview();
renderHistoSettings();
}
function renderFontPreview() {
@@ -544,7 +574,13 @@ function params() {
const q = $('#q').value.trim();
if (q) p.set('q', q);
p.set('mode', state.mode);
p.set('range', $('#range').value);
if (state.zoom) {
p.set('range', 'custom');
p.set('from', state.zoom.from);
p.set('to', state.zoom.to);
} else {
p.set('range', $('#range').value);
}
for (const id of ['severity', 'host', 'app', 'source']) {
const v = $('#' + id).value;
if (v !== '') p.set(id, v);
@@ -553,7 +589,7 @@ function params() {
}
function liveAvailable() {
return !(state.mode === 'logsql' && $('#q').value.trim());
return !state.zoom && !(state.mode === 'logsql' && $('#q').value.trim());
}
function syncControls() {
@@ -564,7 +600,7 @@ function syncControls() {
const btn = $('#live');
btn.disabled = !liveAvailable();
btn.classList.toggle('on', state.live && liveAvailable());
btn.title = liveAvailable() ? t('liveTitle') : t('liveUnavailable');
btn.title = liveAvailable() ? t('liveTitle') : t(state.zoom ? 'liveZoomed' : 'liveUnavailable');
for (const id of ['severity', 'host', 'app', 'source']) $('#' + id).classList.toggle('set', $('#' + id).value !== '');
}
@@ -822,42 +858,477 @@ list.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' && ev.target.classList.contains('row')) ev.target.click();
});
/* ================= Histogram ================= */
/* ================= Timeline (histogram) ================= */
function renderHisto(h) {
const el = $('#histo');
if (!h || !h.buckets) { el.innerHTML = ''; return; }
const stepMs = h.step * 1000;
const counts = new Map();
for (const b of h.buckets) counts.set(Math.floor(parseTime(b.t).getTime() / stepMs), b.n);
// Settings > Interface > Timeline, remembered per browser; the first value is the default.
const HISTO_OPTS = {
scale: ['sqrt', 'linear', 'log'],
height: ['m', 's', 'l'],
color: ['severity', 'intensity', 'none'],
render: ['bars', 'area'],
step: ['auto', '1s', '10s', '1m', '5m', '1h', '1d'],
refresh: ['auto', 'off', '5', '15', '30', '60'],
};
const HISTO_HEIGHTS = { s: 40, m: 80, l: 120 };
const histoPrefs = {};
for (const [k, vals] of Object.entries(HISTO_OPTS)) {
const v = store.get('histo.' + k, vals[0]);
histoPrefs[k] = vals.includes(v) ? v : vals[0];
}
const span = RANGE_MS[$('#range').value];
const end = Math.floor(Date.now() / stepMs);
let start = span ? Math.floor((Date.now() - span) / stepMs) + 1 : (counts.size ? Math.min(...counts.keys()) : end);
if (end - start > 400) start = end - 400;
const HISTO_LAG = 1500; // logs reach VictoriaLogs in batches (FLUSH_MS): resync a bit after a boundary
const SEV_ORDER = ['emerg', 'alert', 'crit', 'err', 'warning', 'notice', 'info', 'debug'];
// Stacked layers, bottom to top: error and above, warning, everything else.
const sevLayer = (sev) => { const n = SEV_ORDER.indexOf(sev); return n < 0 ? 2 : n <= 3 ? 0 : n === 4 ? 1 : 2; };
const SEV_LAYERS = ['err', 'warn', 'other'];
const INTENSITY = ['calm', 'burst', 'anom']; // <= 3x, > 3x, > 10x the median of the window
let max = 1;
for (let k = start; k <= end; k++) max = Math.max(max, counts.get(k) || 0);
const loc = t('locale');
const label = (k) => {
const d = new Date(k * stepMs);
const timeZone = timePrefs.tz || undefined;
return stepMs >= 864e5
? d.toLocaleDateString(loc, { timeZone })
: d.toLocaleString(loc, { timeZone, day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
const histo = {
data: null, // see buildHisto
reqId: 0,
slideTimer: 0, // end of the last interval
refreshTimer: 0, // periodic refresh (5 s … 1 min)
resyncTimer: 0, // reload shortly after a new interval
drag: null, // {a, b}: interval indexes of a drag selection
ticksKey: '',
ticks: [],
};
const sliding = () => !state.zoom; // the window follows the current time
// Server answer -> dense arrays. offset = server clock - browser clock: the
// timeline runs on the server clock, like the reception times it counts.
function buildHisto(res, localNow) {
const d = {
step: res.step, start: res.start, count: res.count, capped: res.capped, offset: res.now - localNow,
n: [], layers: [], sev: [],
};
let bars = '';
for (let k = start; k <= end; k++) {
const n = counts.get(k) || 0;
const hgt = n ? Math.max(4, (n / max) * 100) : 0;
bars += `<i style="height:${hgt}%" title="${label(k)}: ${fmtNum(n)}"></i>`;
for (let i = 0; i < d.count; i++) { d.n.push(0); d.layers.push([0, 0, 0]); d.sev.push({}); }
for (const b of res.buckets || []) {
if (!(b.i >= 0 && b.i < d.count)) continue;
d.n[b.i] = b.n;
for (const [s, c] of Object.entries(b.sev || {})) {
d.sev[b.i][s] = c;
d.layers[b.i][sevLayer(s)] += c;
}
}
el.innerHTML = `<div class="bars">${bars}</div><div class="axis"><span>${label(start)}</span><span>${t('histoMax', fmtNum(max))}</span><span>${label(end)}</span></div>`;
return d;
}
async function refreshHisto() {
try { state.histo = await api('/api/histogram?' + params()); } catch { state.histo = null; }
renderHisto(state.histo);
const id = ++histo.reqId;
clearTimeout(histo.resyncTimer);
const p = params();
p.set('step', histoPrefs.step);
p.set('tz', timePrefs.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
const t0 = Date.now();
let res = null;
try { res = await api('/api/histogram?' + p); } catch { /* shown as an empty timeline */ }
if (id !== histo.reqId) return;
histo.data = res && buildHisto(res, (t0 + Date.now()) / 2);
renderHisto();
scheduleSlide();
scheduleRefresh();
}
// Moves the window k intervals forward (new empty intervals on the right).
function histoSlide(k, resync) {
const d = histo.data;
k = Math.min(Math.max(k, 0), d.count);
if (!k) return;
d.start += k * d.step;
for (const a of [d.n, d.layers, d.sev]) a.splice(0, k);
for (let j = 0; j < k; j++) { d.n.push(0); d.layers.push([0, 0, 0]); d.sev.push({}); }
if (resync && !document.hidden) {
clearTimeout(histo.resyncTimer);
histo.resyncTimer = setTimeout(refreshHisto, HISTO_LAG);
}
}
const liveOn = () => state.live && liveAvailable();
// At the end of the last interval: slide, so that the axis keeps up with the
// clock even without new logs. Off only when nothing refreshes the timeline.
function scheduleSlide() {
clearTimeout(histo.slideTimer);
const d = histo.data;
if (!d || !sliding() || document.hidden || (!liveOn() && histoPrefs.refresh === 'off')) return;
const delay = d.start + d.count * d.step - (Date.now() + d.offset);
histo.slideTimer = setTimeout(() => {
const x = histo.data;
if (!x) return;
const late = Date.now() + x.offset - (x.start + x.count * x.step);
histoSlide(Math.floor(late / x.step) + 1, liveOn() || histoPrefs.refresh === 'auto');
renderHisto();
scheduleSlide();
}, Math.min(Math.max(delay, 50), 2 ** 31 - 1));
}
function scheduleRefresh() {
clearTimeout(histo.refreshTimer);
const sec = Number(histoPrefs.refresh);
if (!sec || !histo.data || !sliding() || document.hidden) return;
histo.refreshTimer = setTimeout(refreshHisto, sec * 1000);
}
function scheduleHisto() {
scheduleSlide();
scheduleRefresh();
}
// Live view: count the new messages right away; the next resync corrects the
// approximations (messages skipped by the stream, late arrivals).
function histoAddLive(rows, skipped) {
const d = histo.data;
if (!d || !sliding() || document.hidden) return;
let changed = false;
for (const r of rows) {
const t = parseTime(r.received || r._time).getTime();
if (isNaN(t)) continue;
let i = Math.floor((t - d.start) / d.step);
if (i >= d.count) {
histoSlide(i - d.count + 1, true);
i = Math.floor((t - d.start) / d.step);
}
if (i < 0 || i >= d.count) continue;
const sev = r.severity || 'info';
d.n[i]++;
d.layers[i][sevLayer(sev)]++;
d.sev[i][sev] = (d.sev[i][sev] || 0) + 1;
changed = true;
}
if (skipped > 0) {
d.n[d.count - 1] += skipped;
d.layers[d.count - 1][2] += skipped;
changed = true;
}
if (changed) renderHisto();
}
/* ---- Rendering ---- */
function histoScale(n, max) {
if (n <= 0 || max <= 0) return 0;
const v = histoPrefs.scale === 'linear' ? n / max
: histoPrefs.scale === 'log' ? Math.log1p(n) / Math.log1p(max)
: Math.sqrt(n / max);
return Math.max(v, 0.04); // a single log stays visible
}
// Median of the non-empty intervals (0 when too few to judge).
function histoMedian(d) {
const v = d.n.filter((x) => x > 0).sort((a, b) => a - b);
if (v.length < 3) return 0;
const m = v.length >> 1;
return v.length % 2 ? v[m] : (v[m - 1] + v[m]) / 2;
}
const intensityOf = (n, med) => (!med || !n ? 0 : n > 10 * med ? 2 : n > 3 * med ? 1 : 0);
// Local wall-clock offset (ms) of a date in the selected time zone.
function zoneOffset(ms) {
const m = /^([+-])(\d\d):(\d\d)$/.exec(zoneParts(new Date(ms)).off);
return m ? (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3])) * 60000 : 0;
}
const TICK_STEPS = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200, 10800, 21600, 43200, 86400, 172800, 604800]
.map((s) => s * 1000);
const dayLabel = (p) => (lang === 'fr' ? `${p.D}/${p.M}` : `${p.M}/${p.D}`);
// Vertical graduations on interval boundaries, at least ~70 px apart, on
// round local times; labels hh:mm:ss, hh:mm or dd/mm depending on the spacing.
function histoTicks(d, width) {
const key = [d.start, d.step, d.count, width, timePrefs.tz, lang].join();
if (histo.ticksKey === key) return histo.ticks;
const pxPerMs = width / (d.count * d.step);
const every = TICK_STEPS.find((s) => s >= d.step && s % d.step === 0 && s * pxPerMs >= 70)
|| d.step * Math.max(1, Math.ceil(70 / (pxPerMs * d.step)));
const endMs = d.start + d.count * d.step;
const off0 = zoneOffset(d.start);
const sameOffset = off0 === zoneOffset(endMs); // no DST change in the window
const ticks = [];
for (let i = 0; i <= d.count; i++) {
const t = d.start + i * d.step;
const wall = t + (sameOffset ? off0 : zoneOffset(t));
if (((wall % every) + every) % every !== 0) continue;
const x = i / d.count;
if (x < 0.03 || x > 0.97) continue; // labels would be cut at the edges
const p = zoneParts(new Date(t));
const midnight = wall % 864e5 === 0;
const label = every >= 864e5 || (midnight && every >= 6e4) ? dayLabel(p)
: every < 6e4 ? `${p.h}:${p.m}:${p.s}` : `${p.h}:${p.m}`;
ticks.push({ x, label, day: midnight });
}
histo.ticksKey = key;
histo.ticks = ticks;
return ticks;
}
// "01/10 19:52 → 20:07" (with seconds for short intervals).
function fmtSpan(from, to, withSec) {
const a = zoneParts(new Date(from));
const b = zoneParts(new Date(to - 1));
const e = zoneParts(new Date(to));
const hm = (p) => `${p.h}:${p.m}${withSec ? ':' + p.s : ''}`;
return `${dayLabel(a)} ${hm(a)} → ${a.D === b.D && a.M === b.M ? '' : dayLabel(e) + ' '}${hm(e)}`;
}
function fmtStep(ms) {
const units = [[864e5, t('unitDay')], [36e5, 'h'], [6e4, 'min'], [1e3, 's']];
const [size, unit] = units.find(([u]) => ms % u === 0) || units[3];
return `${fmtNum(ms / size)} ${unit}`;
}
function histoSVG(d) {
const N = d.count;
const max = Math.max(0, ...d.n);
if (!max) return '';
const area = histoPrefs.render === 'area';
const mode = histoPrefs.color;
const med = mode === 'intensity' ? histoMedian(d) : 0;
const top = (n) => histoScale(n, max) * 100; // height in % of the plot
const y = (h) => (100 - h).toFixed(2);
if (!area) {
const g = N > 120 ? 0.08 : 0.15;
const groups = {};
const rect = (cls, i, y0, h) => {
if (h <= 0) return;
groups[cls] = (groups[cls] || '') + `<rect x="${(i + g).toFixed(2)}" y="${y(y0 + h)}" width="${(1 - 2 * g).toFixed(2)}" height="${h.toFixed(2)}"/>`;
};
for (let i = 0; i < N; i++) {
const n = d.n[i];
if (!n) continue;
const h = top(n);
if (mode === 'severity') {
let y0 = 0;
d.layers[i].forEach((c, k) => { const hk = (h * c) / n; rect('l-' + SEV_LAYERS[k], i, y0, hk); y0 += hk; });
} else {
rect(mode === 'intensity' ? 'l-' + INTENSITY[intensityOf(n, med)] : 'l-all', i, 0, h);
}
}
return Object.entries(groups).map(([cls, r]) => `<g class="${cls}">${r}</g>`).join('');
}
// Area: one point per interval center, stacked by severity when asked.
const line = (vals) => vals.map((v, i) => `${i === 0 ? '0' : (i + 0.5).toFixed(2)},${y(v)}`).join(' L')
+ ` L${N},${y(vals[N - 1])}`;
const back = (vals) => [...vals.keys()].reverse().map((i) => `L${i === 0 ? '0' : (i + 0.5).toFixed(2)},${y(vals[i])}`).join(' ');
if (mode === 'severity') {
let below = d.n.map(() => 0);
let out = '';
for (let k = 0; k < 3; k++) {
const cum = d.n.map((n, i) => (n ? below[i] + (top(n) * d.layers[i][k]) / n : 0));
out += `<path class="l-${SEV_LAYERS[k]}" d="M${line(cum)} L${N},${y(below[N - 1])} ${back(below)} Z"/>`;
below = cum;
}
return out;
}
const tops = d.n.map(top);
const path = `M0,100 L${line(tops)} L${N},100 Z`;
if (mode !== 'intensity') return `<path class="l-all" d="${path}"/>`;
// Intensity: a horizontal gradient with one color band per interval.
let stops = '';
for (let i = 0; i < N; i++) {
const c = `var(--h-${INTENSITY[intensityOf(d.n[i], med)]})`;
stops += `<stop offset="${i / N}" style="stop-color:${c}"/><stop offset="${(i + 1) / N}" style="stop-color:${c}"/>`;
}
return `<defs><linearGradient id="hGrad" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="${N}" y2="0">${stops}</linearGradient></defs>`
+ `<path d="${path}" fill="url(#hGrad)"/>`;
}
function histoLegend() {
const items = histoPrefs.color === 'severity' ? SEV_LAYERS.map((k) => [k, t('hLeg_' + k)])
: histoPrefs.color === 'intensity' ? INTENSITY.map((k) => [k, t('hLeg_' + k)]) : [];
return items.map(([k, label]) => `<span><i class="sw" style="background:var(--h-${k})"></i>${esc(label)}</span>`).join('');
}
function renderHisto() {
const el = $('#histo');
const d = histo.data;
el.style.setProperty('--histo-h', HISTO_HEIGHTS[histoPrefs.height] + 'px');
el.hidden = !d;
if (!d) return;
const plot = el.querySelector('.h-plot');
const svg = el.querySelector('.h-svg');
svg.setAttribute('viewBox', `0 0 ${d.count} 100`);
svg.innerHTML = histoSVG(d);
const ticks = histoTicks(d, plot.clientWidth || 1000);
el.querySelector('.h-grid').innerHTML = ticks.map((k) => `<i class="${k.day ? 'day' : ''}" style="left:${(k.x * 100).toFixed(3)}%"></i>`).join('');
el.querySelector('.h-axis').innerHTML = ticks.map((k) => `<span class="${k.day ? 'day' : ''}" style="left:${(k.x * 100).toFixed(3)}%">${esc(k.label)}</span>`).join('');
const max = Math.max(0, ...d.n);
el.querySelector('.h-meta').innerHTML = `<span class="h-leg">${histoLegend()}</span><span class="sp"></span>`
+ `<span>${esc(t('histoMax', fmtNum(max)))} · ${esc(t('hDivision', fmtStep(d.step)))}`
+ (d.capped ? ` <span class="warn" title="${esc(t('hCappedTitle'))}">${esc(t('hCapped'))}</span>` : '') + '</span>'
+ (state.zoom ? `<button type="button" class="link-btn" data-act="unzoom">${esc(t('hUnzoom'))}</button>` : '');
if (histo.tipIdx != null) showHistoTip(histo.tipIdx);
}
/* ---- Tooltip, zoom ---- */
function histoIdx(ev) {
const d = histo.data;
const r = $('#histo .h-plot').getBoundingClientRect();
return Math.min(d.count - 1, Math.max(0, Math.floor(((ev.clientX - r.left) / r.width) * d.count)));
}
function showHistoTip(i, clientX) {
const d = histo.data;
const tip = $('#histo .h-tip');
const hover = $('#histo .h-hover');
if (!d || i >= d.count) { hideHistoTip(); return; }
histo.tipIdx = i;
const from = d.start + i * d.step;
const n = d.n[i];
let html = `<div class="hd">${esc(fmtSpan(from, from + d.step, d.step < 6e4))}</div>`
+ `<div class="ln"><span>${esc(t('hTotal'))}</span><b>${fmtNum(n)}</b></div>`;
if (histoPrefs.color === 'intensity' && n) {
const med = histoMedian(d);
if (med) html += `<div class="ln muted"><span>${esc(t('hLeg_' + INTENSITY[intensityOf(n, med)]))}</span><span>${esc(t('hRatio', (n / med).toLocaleString(t('locale'), { maximumFractionDigits: 1 })))}</span></div>`;
}
const sevs = Object.keys(d.sev[i]).sort((a, b) => SEV_ORDER.indexOf(a) - SEV_ORDER.indexOf(b));
for (const s of sevs) {
html += `<div class="ln"><span><i class="sw" style="background:var(--sev-${SEV_CLASS[s] || 'info'})"></i>${esc(s)}</span><span>${fmtNum(d.sev[i][s])}</span></div>`;
}
const other = n - sevs.reduce((sum, s) => sum + d.sev[i][s], 0);
if (other > 0) html += `<div class="ln muted"><span>${esc(t('hUnshown'))}</span><span>${fmtNum(other)}</span></div>`;
tip.innerHTML = html;
tip.hidden = false;
const plot = $('#histo .h-plot');
const w = plot.clientWidth;
hover.hidden = false;
hover.style.left = (i / d.count) * 100 + '%';
hover.style.width = 100 / d.count + '%';
if (clientX != null) histo.tipX = clientX - plot.getBoundingClientRect().left;
const x = histo.tipX ?? ((i + 0.5) / d.count) * w;
tip.style.left = Math.min(Math.max(x + 12 + plot.offsetLeft, 0), plot.offsetLeft + w - tip.offsetWidth) + 'px';
tip.style.top = plot.offsetTop + plot.offsetHeight + 4 + 'px';
}
function hideHistoTip() {
histo.tipIdx = null;
$('#histo .h-tip').hidden = true;
$('#histo .h-hover').hidden = true;
}
function showHistoSel() {
const d = histo.data;
const sel = $('#histo .h-sel');
const a = Math.min(histo.drag.a, histo.drag.b);
const b = Math.max(histo.drag.a, histo.drag.b);
sel.hidden = false;
sel.style.left = (a / d.count) * 100 + '%';
sel.style.width = ((b - a + 1) / d.count) * 100 + '%';
}
function setZoomOption() {
const sel = $('#range');
let opt = sel.querySelector('option[value="custom"]');
if (!state.zoom) {
if (opt) opt.remove();
return;
}
if (!opt) { opt = new Option('', 'custom'); sel.add(opt); }
opt.textContent = fmtSpan(state.zoom.from, state.zoom.to, state.zoom.to - state.zoom.from < 3e5);
sel.value = 'custom';
}
function zoomTo(from, to) {
state.zoom = { from: Math.round(from), to: Math.round(to) };
setZoomOption();
hideHistoTip();
refresh();
}
function unzoom() {
state.zoom = null;
setZoomOption();
$('#range').value = store.get('range', '1h');
if (!$('#range').value) $('#range').value = '1h';
refresh();
}
{
const el = $('#histo');
el.innerHTML = '<div class="h-plot"><svg class="h-svg" preserveAspectRatio="none" aria-hidden="true"></svg>'
+ '<div class="h-grid"></div><div class="h-hover" hidden></div><div class="h-sel" hidden></div></div>'
+ '<div class="h-axis"></div><div class="h-meta"></div><div class="h-tip" role="tooltip" hidden></div>';
el.hidden = true;
const plot = el.querySelector('.h-plot');
plot.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0 || !histo.data) return;
const i = histoIdx(ev);
histo.drag = { a: i, b: i };
plot.setPointerCapture(ev.pointerId);
showHistoSel();
});
plot.addEventListener('pointermove', (ev) => {
if (!histo.data) return;
const i = histoIdx(ev);
if (histo.drag) { histo.drag.b = i; showHistoSel(); }
showHistoTip(i, ev.clientX);
});
plot.addEventListener('pointerup', () => {
const d = histo.data;
const dr = histo.drag;
histo.drag = null;
el.querySelector('.h-sel').hidden = true;
if (!dr || !d) return;
const a = Math.min(dr.a, dr.b);
const b = Math.max(dr.a, dr.b);
zoomTo(d.start + a * d.step, d.start + (b + 1) * d.step); // click = one interval, drag = the selection
});
plot.addEventListener('pointercancel', () => { histo.drag = null; el.querySelector('.h-sel').hidden = true; });
plot.addEventListener('pointerleave', () => { if (!histo.drag) hideHistoTip(); });
el.querySelector('.h-meta').addEventListener('click', (ev) => {
if (ev.target.closest('[data-act="unzoom"]')) unzoom();
});
plot.title = '';
window.addEventListener('resize', debounce(() => renderHisto(), 200));
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
clearTimeout(histo.slideTimer);
clearTimeout(histo.refreshTimer);
clearTimeout(histo.resyncTimer);
} else if (histo.data && sliding()) {
refreshHisto(); // catch up on what happened while the tab was hidden
}
});
}
/* ---- Settings > Interface > Timeline ---- */
const HISTO_SELECT_LABELS = {
step: (v) => (v === 'auto' ? t('hAuto') : fmtStep({ s: 1e3, m: 6e4, h: 36e5, d: 864e5 }[v.slice(-1)] * parseInt(v, 10))),
refresh: (v) => (v === 'auto' ? t('hRefreshAuto') : v === 'off' ? t('hOff') : fmtStep(Number(v) * 1000)),
};
function renderHistoSettings() {
for (const seg of document.querySelectorAll('.seg[data-histo]')) {
for (const b of seg.querySelectorAll('[data-v]')) b.setAttribute('aria-checked', String(b.dataset.v === histoPrefs[seg.dataset.histo]));
}
for (const sel of document.querySelectorAll('select[data-histo]')) {
const k = sel.dataset.histo;
sel.innerHTML = HISTO_OPTS[k].map((v) => `<option value="${v}">${esc(HISTO_SELECT_LABELS[k](v))}</option>`).join('');
sel.value = histoPrefs[k];
}
}
function setHistoPref(k, v) {
if (!HISTO_OPTS[k] || !HISTO_OPTS[k].includes(v)) return;
histoPrefs[k] = v;
store.set('histo.' + k, v);
renderHistoSettings();
if (k === 'step') refreshHisto();
else if (k === 'refresh') { scheduleHisto(); if (v !== 'off') refreshHisto(); }
else renderHisto();
}
document.addEventListener('click', (ev) => {
const b = ev.target.closest('.seg[data-histo] [data-v]');
if (b) setHistoPref(b.closest('.seg').dataset.histo, b.dataset.v);
});
for (const sel of document.querySelectorAll('select[data-histo]')) {
sel.addEventListener('change', () => setHistoPref(sel.dataset.histo, sel.value));
}
/* ================= Search ================= */
@@ -896,8 +1367,12 @@ async function refresh() {
refreshHisto();
}
const onFilterChange = () => {
store.set('range', $('#range').value);
const onFilterChange = (ev) => {
if (ev && ev.target.id === 'range' && state.zoom && ev.target.value !== 'custom') {
state.zoom = null; // another range chosen: leave the zoom
setZoomOption();
}
if (!state.zoom) store.set('range', $('#range').value);
store.set('severity', $('#severity').value);
refresh();
};
@@ -935,7 +1410,7 @@ function setConn(cls, key) {
function connectLive() {
if (state.es) { state.es.close(); state.es = null; }
if (!state.live || !liveAvailable()) {
setConn('', state.live ? 'connLogsql' : 'connPaused');
setConn('', !state.live ? 'connPaused' : state.zoom ? 'connZoom' : 'connLogsql');
return;
}
const es = new EventSource('/api/stream?' + params());
@@ -947,6 +1422,7 @@ function connectLive() {
const data = JSON.parse(ev.data);
const rows = (data.rows || []).reverse(); // newest first
if (data.skipped) toast(t('skipped', fmtNum(data.skipped)));
histoAddLive(rows, data.skipped);
if (window.scrollY > 80) {
state.pending = rows.concat(state.pending).slice(0, MAX_ROWS);
const pill = $('#newPill');
@@ -975,7 +1451,7 @@ $('#live').addEventListener('click', () => {
state.live = !state.live;
store.set('live', state.live ? '1' : '0');
if (state.live) refresh(); // catch up on what arrived while paused
else { syncControls(); connectLive(); }
else { syncControls(); connectLive(); scheduleHisto(); }
});
/* ================= Host / app lists, stats ================= */
@@ -1049,7 +1525,8 @@ function onTimePrefsChange() {
store.set('timefmt', timePrefs.fmt);
updateTimePreview();
renderList();
renderHisto(state.histo);
setZoomOption();
refreshHisto(); // intervals are aligned on the local time
}
$('#tzSelect').addEventListener('change', onTimePrefsChange);
$('#fmtSelect').addEventListener('change', onTimePrefsChange);
@@ -1473,5 +1950,4 @@ $('#severity').value = store.get('severity', '');
refresh();
setInterval(loadStats, 5000);
setInterval(loadFacets, 60000);
setInterval(() => { if (state.live && liveAvailable() && !document.hidden) refreshHisto(); }, 30000);
})();
+34 -1
View File
@@ -90,7 +90,7 @@
</div>
</section>
<section id="histo" class="histo" data-i18n-aria="histoAria"></section>
<section id="histo" class="histo" data-i18n-aria="histoAria" hidden></section>
<div id="error" class="error-banner" hidden></div>
<button id="newPill" class="pill" type="button" hidden></button>
@@ -249,6 +249,39 @@
<div id="fontPreview" class="list preview-list" aria-hidden="true"></div>
<p class="muted small" data-i18n="fontHelp"></p>
</section>
<section class="set-section">
<h3 data-i18n="histoTitle">Timeline</h3>
<div class="field-grid">
<span class="lbl" data-i18n="hScale">Scale</span>
<div class="seg" role="radiogroup" data-histo="scale" data-i18n-aria="hScale">
<button type="button" role="radio" data-v="linear" data-i18n="hLinear">Linear</button>
<button type="button" role="radio" data-v="sqrt">√</button>
<button type="button" role="radio" data-v="log">Log</button>
</div>
<span class="lbl" data-i18n="hHeight">Height</span>
<div class="seg" role="radiogroup" data-histo="height" data-i18n-aria="hHeight">
<button type="button" role="radio" data-v="s">S</button>
<button type="button" role="radio" data-v="m">M</button>
<button type="button" role="radio" data-v="l">L</button>
</div>
<span class="lbl" data-i18n="hColor">Color</span>
<div class="seg" role="radiogroup" data-histo="color" data-i18n-aria="hColor">
<button type="button" role="radio" data-v="severity" data-i18n="hColSeverity">Severity</button>
<button type="button" role="radio" data-v="intensity" data-i18n="hColIntensity">Intensity</button>
<button type="button" role="radio" data-v="none" data-i18n="hColNone">None</button>
</div>
<span class="lbl" data-i18n="hRender">Display</span>
<div class="seg" role="radiogroup" data-histo="render" data-i18n-aria="hRender">
<button type="button" role="radio" data-v="bars" data-i18n="hBars">Bars</button>
<button type="button" role="radio" data-v="area" data-i18n="hArea">Area</button>
</div>
<label for="histoStep" data-i18n="hStep">Division</label>
<select id="histoStep" class="field" data-histo="step"></select>
<label for="histoRefresh" data-i18n="hRefresh">Refresh</label>
<select id="histoRefresh" class="field" data-histo="refresh"></select>
</div>
<p class="muted small" data-i18n="histoHelp"></p>
</section>
</div>
<!-- Data -->
+62 -9
View File
@@ -20,6 +20,17 @@
--sev-info: #0d9488;
--sev-debug: #9ca3af;
/* Timeline: severity layers, intensity, graduations */
--h-err: #f87171;
--h-warn: #fbbf24;
--h-other: #a5b4fc;
--h-all: #818cf8;
--h-calm: #a5b4fc;
--h-burst: #f59e0b;
--h-anom: #e11d48;
--h-grid: rgba(100, 116, 139, .28);
--h-hover: rgba(99, 102, 241, .10);
/* Filled severity badges; overwritten by the colors of the "error" / "warning" tags */
--tag-error: #fca5a5;
--tag-error-text: #111827;
@@ -53,6 +64,15 @@
--sev-notice: #60a5fa;
--sev-info: #2dd4bf;
--sev-debug: #6b7280;
--h-err: #ef4444;
--h-warn: #f59e0b;
--h-other: #4f5bd5;
--h-all: #6d74e8;
--h-calm: #4f5bd5;
--h-burst: #fbbf24;
--h-anom: #fb7185;
--h-grid: rgba(148, 163, 184, .2);
--h-hover: rgba(129, 140, 248, .14);
color-scheme: dark;
}
@@ -76,6 +96,15 @@
--sev-notice: #60a5fa;
--sev-info: #2dd4bf;
--sev-debug: #6b7280;
--h-err: #ef4444;
--h-warn: #f59e0b;
--h-other: #4f5bd5;
--h-all: #6d74e8;
--h-calm: #4f5bd5;
--h-burst: #fbbf24;
--h-anom: #fb7185;
--h-grid: rgba(148, 163, 184, .2);
--h-hover: rgba(129, 140, 248, .14);
color-scheme: dark;
}
}
@@ -227,18 +256,41 @@ body.busy .progress::after {
.row .app.proj { color: hsl(var(--h) 55% var(--chip-l)); }
.row .app.proj .src-ico { color: inherit; }
/* ---------- Histogram ---------- */
.histo { padding: 0 20px 6px; }
.histo .bars {
height: 46px; display: flex; align-items: flex-end; gap: 1px;
/* ---------- Timeline (histogram) ---------- */
.histo { position: relative; padding: 0 20px 6px; user-select: none; -webkit-user-select: none; }
.h-plot {
position: relative; height: var(--histo-h, 80px); cursor: crosshair; touch-action: none;
border-bottom: 1px solid var(--border);
}
.histo .bars i {
flex: 1; min-width: 1px; background: var(--accent); opacity: .55;
border-radius: 2px 2px 0 0;
.h-svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.h-svg rect { shape-rendering: crispEdges; }
.h-svg .l-err { fill: var(--h-err); }
.h-svg .l-warn { fill: var(--h-warn); }
.h-svg .l-other { fill: var(--h-other); }
.h-svg .l-all { fill: var(--h-all); }
.h-svg .l-calm { fill: var(--h-calm); }
.h-svg .l-burst { fill: var(--h-burst); }
.h-svg .l-anom { fill: var(--h-anom); }
.h-grid i { position: absolute; top: 0; bottom: 0; border-left: 1px dashed var(--h-grid); pointer-events: none; }
.h-grid i.day { border-left-style: solid; }
.h-hover { position: absolute; top: 0; bottom: 0; background: var(--h-hover); pointer-events: none; }
.h-sel { position: absolute; top: 0; bottom: 0; background: var(--accent-soft); box-shadow: inset 1px 0 var(--accent), inset -1px 0 var(--accent); pointer-events: none; }
.h-axis { position: relative; height: 16px; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
.h-axis span { position: absolute; top: 2px; transform: translateX(-50%); white-space: nowrap; }
.h-axis span.day { color: var(--text); font-weight: 600; }
.h-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; font-size: 11px; color: var(--muted); }
.h-meta .sp { flex: 1; }
.h-meta .warn { color: var(--sev-warning); cursor: help; }
.h-meta .link-btn { font-size: 11px; }
.h-leg { display: inline-flex; gap: 10px; }
.histo .sw { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 5px; }
.h-tip {
position: absolute; z-index: 30; pointer-events: none; min-width: 170px;
background: var(--panel); border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow);
padding: 7px 10px; font-size: 12px; font-variant-numeric: tabular-nums;
}
.histo .bars i:hover { opacity: 1; }
.histo .axis { display: flex; justify-content: space-between; font-size: 11px; color: var(--muted); padding-top: 3px; }
.h-tip .hd { font-weight: 600; margin-bottom: 4px; }
.h-tip .ln { display: flex; justify-content: space-between; gap: 14px; line-height: 1.55; }
/* ---------- Log list ---------- */
.list { margin: 6px 20px 0; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
@@ -540,6 +592,7 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
.spacer { display: none; }
#count { flex: 1 1 50%; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.histo { padding: 0 16px 6px; }
.h-leg { display: none; }
.list, .error-banner { margin-left: 16px; margin-right: 16px; }
.row {
grid-template-columns: auto auto 1fr;