Timeline: scales, graduations, tooltip, colors, live updates and zoom
The histogram above the list becomes a configurable timeline: - Scale linear / sqrt (default) / log, height S/M/L, bars or area. - Colors: stacked by severity (error+, warning, the rest), intensity against the median of the window (calm, burst >3x, anomaly >10x), or none; colors are CSS variables with light and dark values. - Vertical graduations on round local times (hh:mm:ss, hh:mm, dd/mm). - Tooltip: interval bounds, total and detail per severity. - Division automatic (~100 intervals) or fixed (1 s to 1 day), capped at 300 intervals by the server; intervals aligned on the local time zone. - Refresh off / 5 s / 15 s / 30 s / 1 min / at each new interval. In live mode the last interval is incremented from the SSE stream and the timeline reloads at each new interval; paused while the tab is hidden. - Click a bar to zoom on its interval, drag to zoom on a selection: the list, export and counters follow through new from/to parameters. The timeline now runs on the server clock (bounds and "now" come from /api/histogram): the axis was drawn from the browser clock and refreshed every 30 s only, so a clock difference with the server or a hidden tab left it behind the logs. /api/histogram returns interval indexes with the count per severity; the division logic lives in histogram.go with table-driven tests. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
32593b9515
commit
ebf6340ff0
8 files changed
+1119
-86
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user