diff --git a/README.md b/README.md index 6433082..528af6d 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/api.go b/api.go index c454a50..1545cc9 100644 --- a/api.go +++ b/api.go @@ -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: diff --git a/histogram.go b/histogram.go new file mode 100644 index 0000000..73eb489 --- /dev/null +++ b/histogram.go @@ -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?&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, + }) +} diff --git a/histogram_test.go b/histogram_test.go new file mode 100644 index 0000000..b4e8033 --- /dev/null +++ b/histogram_test.go @@ -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]) + } + } +} diff --git a/query.go b/query.go index 0b3fc7a..8f09a73 100644 --- a/query.go +++ b/query.go @@ -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() diff --git a/web/app.js b/web/app.js index f297b06..2ae420c 100644 --- a/web/app.js +++ b/web/app.js @@ -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) => ``).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 += ``; + 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 = `
${bars}
${label(start)}${t('histoMax', fmtNum(max))}${label(end)}
`; + 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] || '') + ``; + }; + 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]) => `${r}`).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 += ``; + 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 ``; + // 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 += ``; + } + return `${stops}` + + ``; +} + +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]) => `${esc(label)}`).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) => ``).join(''); + el.querySelector('.h-axis').innerHTML = ticks.map((k) => `${esc(k.label)}`).join(''); + const max = Math.max(0, ...d.n); + el.querySelector('.h-meta').innerHTML = `${histoLegend()}` + + `${esc(t('histoMax', fmtNum(max)))} · ${esc(t('hDivision', fmtStep(d.step)))}` + + (d.capped ? ` ${esc(t('hCapped'))}` : '') + '' + + (state.zoom ? `` : ''); + 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 = `
${esc(fmtSpan(from, from + d.step, d.step < 6e4))}
` + + `
${esc(t('hTotal'))}${fmtNum(n)}
`; + if (histoPrefs.color === 'intensity' && n) { + const med = histoMedian(d); + if (med) html += `
${esc(t('hLeg_' + INTENSITY[intensityOf(n, med)]))}${esc(t('hRatio', (n / med).toLocaleString(t('locale'), { maximumFractionDigits: 1 })))}
`; + } + const sevs = Object.keys(d.sev[i]).sort((a, b) => SEV_ORDER.indexOf(a) - SEV_ORDER.indexOf(b)); + for (const s of sevs) { + html += `
${esc(s)}${fmtNum(d.sev[i][s])}
`; + } + const other = n - sevs.reduce((sum, s) => sum + d.sev[i][s], 0); + if (other > 0) html += `
${esc(t('hUnshown'))}${fmtNum(other)}
`; + 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 = '
' + + '
' + + '
'; + 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) => ``).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); })(); diff --git a/web/index.html b/web/index.html index 574f014..70f60c3 100644 --- a/web/index.html +++ b/web/index.html @@ -90,7 +90,7 @@ -
+ @@ -249,6 +249,39 @@

+
+

Timeline

+
+ Scale +
+ + + +
+ Height +
+ + + +
+ Color +
+ + + +
+ Display +
+ + +
+ + + + +
+

+
diff --git a/web/style.css b/web/style.css index b6eaafd..899e259 100644 --- a/web/style.css +++ b/web/style.css @@ -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;