diff --git a/.env.example b/.env.example index 153952a..6785243 100644 --- a/.env.example +++ b/.env.example @@ -23,7 +23,6 @@ TS_QUERY_TIMEOUT=3s RRDTOOL=rrdtool RRD_PATH=/var/lib/ts3monolith/linuxiarz.rrd -GRAPH_DIR=/var/lib/ts3monolith/graphs STATS_INTERVAL=60s # Per-IP rate limiting, off by default (see README "Rate limiting"). When diff --git a/README.md b/README.md index c20dae1..2e25055 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,9 @@ ten sam układ tokenów kolorów, te same komponenty UI. - `clientmove` wskazanego `clid` do nowego kanału; - dashboard statystyk: liczba online na żywo (auto-odświeżanie co 60 s przez `GET /stats/live`), uptime/wersja/platforma serwera (`serverinfo`), - wykresy RRD: 12h, 24h, 72h, tydzień, miesiąc, rok — w kolorystyce - dopasowanej do motywu aplikacji (ciemne tło, akcentowa linia, watermark); + wykresy RRD: 12h, 24h, 72h, tydzień, miesiąc, rok — dane pobierane jako + JSON i rysowane responsywnie w `` po stronie przeglądarki (patrz + sekcja [RRD](#rrd)), z podglądem na powiększenie; - przełącznik jasny/ciemny motyw (zapamiętywany w `localStorage`); - formularz pod `/embed/channel`, przeznaczony do osadzenia na innej domenie. @@ -103,14 +104,22 @@ Aplikacja używa systemowego `rrdtool`, a nie biblioteki Go, więc istniejący p nową bazę `rrdtool create` z RRA pokrywającymi 12h/24h/72h/tydzień/miesiąc/rok przy kroku 60 s — ten sam layout logiczny co istniejące wykresy aplikacji. -Wykresy renderowane są w kolorach dopasowanych do motywu dashboardu -(`--panel`/`--field`/`--accent` z `internal/web/assets/app.css`), z cienkim -wypełnieniem pod linią, watermarkiem z datą generacji i `--slope-mode` dla -gładszej linii. Każdy zakres jest renderowany dwukrotnie — `.png` -(ciemna paleta) i `-light.png` (jasna paleta) — a front-end -(`internal/web/assets/app.js`) przełącza `src` na właściwy wariant przy -zmianie motywu, więc wykresy zawsze dopasowane są do aktywnego trybu -jasny/ciemny. +Wykresy na dashboardzie **nie są** PNG renderowanymi przez `rrdtool graph`. +`GET /stats/graph/{zakres}` (`internal/rrd/rrd.go`, `Manager.Fetch`) woła +`rrdtool fetch` i zwraca surowe próbki jako mały JSON (`{"points":[{"t":..., +"v":...},...],"current":...,"average":...,"max":...}`); front-end +(`internal/web/assets/app.js`) rysuje z tego wykres samodzielnie w +``, kolorami wziętymi na żywo z CSS (`--line`/--muted`/`--accent`), +więc przy przełączeniu jasny/ciemny motyw wykres się po prostu przerysowuje +z już pobranych danych — bez kolejnego zapytania do serwera i bez osobnych +plików PNG per motyw. Dzięki temu wykres jest też w pełni responsywny +(łącznie z telefonem) i powiększenie w lightboksie to kolejne przerysowanie +tych samych danych w większym ``, a nie osobny plik. + +`Fetch` dobiera funkcję konsolidacji RRD do zakresu: `LAST` dla 12h/24h/72h +(pełna rozdzielczość, RRA `RRA:LAST:0.5:1:4500`), `AVERAGE` dla +tydzień/miesiąc/rok (te okna istnieją tylko w gęściej uśrednionych RRA — +pytanie ich o `LAST` nie zwróciłoby nic starszego niż ok. 75h). Jeżeli docelowy serwer ma inną architekturę/endianness niż źródłowy x86_64 little-endian, przenieś RRD przez `rrdtool dump`/`rrdtool restore` zamiast kopiowania binarnego pliku. @@ -146,7 +155,7 @@ podanym w nawiasie: |---|---|---|---| | `RATE_LIMIT_STATS` | `GET /stats` (na minutę) | 60 | render strony statystyk | | `RATE_LIMIT_STATS_LIVE` | `GET /stats/live` (na minutę) | 60 | auto-odświeżanie licznika online co 60 s | -| `RATE_LIMIT_GRAPH` | `GET /graph/*.png` (na minutę) | 600 | wykresy RRD — PNG są cache'owane na dysku ~55 s, więc limit jest wysoki: jeden widok strony sam zużywa 6 zakresów × 2 motywy, a NAT/kilka kart to łatwo pomnoży | +| `RATE_LIMIT_GRAPH` | `GET /stats/graph/{zakres}` (na minutę) | 600 | dane wykresów — każde trafienie to jedno tanie `rrdtool fetch`, limit jest wysoki, bo jeden widok strony sam zużywa 6 zakresów, a NAT/kilka kart to łatwo pomnoży | | `RATE_LIMIT_CHANNEL` | `POST /channel/create` (na minutę) | 40 | tworzenie kanału | | `RATE_LIMIT_VERIFY` | `POST /verify/start` (na minutę) | 10 | start weryfikacji nicku (NAT) | | `RATE_LIMIT_VERIFY_DONE` | `POST /verify/complete` (na minutę) | 10 | wpisanie 6-cyfrowego kodu | diff --git a/VERSION b/VERSION index 6678432..267577d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.8 +0.4.1 diff --git a/cmd/ts3monolith/main.go b/cmd/ts3monolith/main.go index 9febcb0..1c6bac6 100644 --- a/cmd/ts3monolith/main.go +++ b/cmd/ts3monolith/main.go @@ -24,7 +24,7 @@ func main() { log.Fatal(err) } tq := &ts3.Query{Addr: cfg.TSQueryHost + ":" + strconv.Itoa(cfg.TSQueryPort), User: cfg.TSQueryUser, Password: cfg.TSQueryPassword, VoicePort: cfg.TSVoicePort, Timeout: cfg.TSQueryTimeout} - rm := &rrd.Manager{Tool: cfg.RRDTool, Path: cfg.RRDPath, GraphDir: cfg.GraphDir} + rm := &rrd.Manager{Tool: cfg.RRDTool, Path: cfg.RRDPath} if err := rm.Validate(); err != nil { log.Fatal(err) } diff --git a/internal/config/config.go b/internal/config/config.go index 049616d..84515b3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,7 +28,6 @@ type Config struct { TSQueryTimeout time.Duration RRDTool string RRDPath string - GraphDir string StatsInterval time.Duration // Per-IP rate limiting. Off by default -- see README "Rate limiting" @@ -90,7 +89,6 @@ func Load() (Config, error) { c.RRDTool = env("RRDTOOL", "rrdtool") c.RRDPath = env("RRD_PATH", "/var/lib/ts3monolith/linuxiarz.rrd") - c.GraphDir = env("GRAPH_DIR", "/var/lib/ts3monolith/graphs") c.StatsInterval, err = envDuration("STATS_INTERVAL", time.Minute) if err != nil { return c, err diff --git a/internal/rrd/rrd.go b/internal/rrd/rrd.go index 3ec0c4a..4de9222 100644 --- a/internal/rrd/rrd.go +++ b/internal/rrd/rrd.go @@ -5,53 +5,44 @@ import ( "context" "errors" "fmt" + "math" "os" "os/exec" - "path/filepath" "strconv" "strings" "sync" - "time" ) type Manager struct { - Tool string - Path string - GraphDir string - mu sync.Mutex + Tool string + Path string + mu sync.Mutex } -// Graph colors mirror the dashboard's theme tokens (internal/web/assets/app.css) -// so rendered PNGs sit visually in place inside the panel cards. Each range -// is rendered twice (dark + light) so the client can pick the variant that -// matches the active site theme without round-tripping through rrdtool. -const ( - graphColorPanel = "#181c19" // --panel (dark) - graphColorField = "#121713" // --field (dark plot canvas) - graphColorLine = "#313832" // --line (dark minor grid) - graphColorLine2 = "#485149" // --line2 (dark axis / major grid) - graphColorText = "#edf0ecCC" - graphColorAccent = "#8fb9a4" // --accent (dark) - graphColorAccentFill = "#8fb9a43d" - - graphLightColorPanel = "#ffffff" // --panel (light) - graphLightColorField = "#f7f8f6" // --panel2 (light plot canvas) - graphLightColorLine = "#cbd2cb" // --line (light minor grid) - graphLightColorLine2 = "#aeb8af" // --line2 (light axis / major grid) - graphLightColorText = "#202622CC" - graphLightColorAccent = "#507b67" // --accent (light) - graphLightColorAccentFill = "#507b673d" -) - -type Range struct{ Key, Start, Title string } +// Range describes one of the dashboard's graph time windows. CF is the RRD +// consolidation function to fetch with: "LAST" for the windows covered by +// the full-resolution 1x60s RRA (see packaging/ts3monolith-init-rrd), and +// "AVERAGE" for the longer windows that only exist in the coarser, +// pre-averaged RRAs -- asking those for LAST would silently return nothing +// older than the full-resolution RRA's ~75h span. +type Range struct{ Key, Start, Title, CF string } var Ranges = []Range{ - {"hour", "-12h", "Ostatnie 12 godzin"}, - {"day", "-24h", "Ostatnie 24 godziny"}, - {"72h", "-72h", "Ostatnie 72 godziny"}, - {"week", "-1w", "Ostatni tydzień"}, - {"month", "-1m", "Ostatni miesiąc"}, - {"year", "-1y", "Ostatni rok"}, + {"hour", "-12h", "Ostatnie 12 godzin", "LAST"}, + {"day", "-24h", "Ostatnie 24 godziny", "LAST"}, + {"72h", "-72h", "Ostatnie 72 godziny", "LAST"}, + {"week", "-1w", "Ostatni tydzień", "AVERAGE"}, + {"month", "-1m", "Ostatni miesiąc", "AVERAGE"}, + {"year", "-1y", "Ostatni rok", "AVERAGE"}, +} + +func rangeByKey(key string) *Range { + for i := range Ranges { + if Ranges[i].Key == key { + return &Ranges[i] + } + } + return nil } func (m *Manager) Validate() error { @@ -61,7 +52,7 @@ func (m *Manager) Validate() error { if st, err := os.Stat(m.Path); err != nil || st.IsDir() { return fmt.Errorf("RRD file unavailable: %s", m.Path) } - return os.MkdirAll(m.GraphDir, 0750) + return nil } func (m *Manager) Update(ctx context.Context, online int) error { @@ -99,81 +90,164 @@ func (m *Manager) Last(ctx context.Context) (int, error) { return int(f + 0.5), err } -func (m *Manager) Graph(ctx context.Context, key, theme string) (string, error) { - var rg *Range - for i := range Ranges { - if Ranges[i].Key == key { - rg = &Ranges[i] - break - } - } +// Point is one JSON-serializable RRD sample. V is nil when the RRA has no +// data for that timestamp (rrdtool reports it as "nan"), which the +// front-end renders as a gap in the line rather than a drop to zero. +type Point struct { + T int64 `json:"t"` + V *float64 `json:"v"` +} + +// GraphData is the JSON payload served at GET /stats/graph/{key}. Aktualnie +// / Średnio / Maks. are computed here (over the fetched window) so the +// front-end doesn't need to re-derive them, mirroring what the old +// rrdtool-rendered PNGs printed under the graph via GPRINT. +type GraphData struct { + Key string `json:"key"` + Title string `json:"title"` + Step int64 `json:"step"` + Points []Point `json:"points"` + Current *float64 `json:"current"` + Average *float64 `json:"average"` + Max *float64 `json:"max"` +} + +// maxChartPoints caps how many samples Fetch returns per range. The raw +// RRA rows can be far denser than any chart will ever display pixel-for- +// pixel (72h at the full-resolution 60s RRA is 4320 rows), which both +// bloats the JSON response for no visual benefit and, rendered 1:1, +// produces a dense, spiky look where many adjacent ups/downs alias into +// what looks like noise. downsample buckets the raw series down to this +// many points (each bucket averaged, like an RRD AVERAGE RRA does) before +// it's returned, independent of any particular browser's viewport. +const maxChartPoints = 450 + +// Fetch reads the samples for one graph range directly from the RRD file +// via `rrdtool fetch`, for the front-end to render client-side. This +// replaces the previous approach of shelling out to `rrdtool graph` to +// render a PNG per theme: fetch returns a small JSON array instead of an +// image, so the browser can draw it (and redraw it on theme change or +// resize) without a round-trip, and it never produces the server-side- +// rendering quirks (fixed margins, fixed colors) that made the PNGs +// awkward to fit responsively. +func (m *Manager) Fetch(ctx context.Context, key string) (GraphData, error) { + rg := rangeByKey(key) if rg == nil { - return "", errors.New("unknown graph range") + return GraphData{}, errors.New("unknown graph range") } - if err := os.MkdirAll(m.GraphDir, 0750); err != nil { - return "", err + data, err := m.fetchCF(ctx, rg, rg.CF) + if err != nil && rg.CF != "LAST" { + // An RRD file that predates packaging/ts3monolith-init-rrd (e.g. an + // archived database carried over from the old PHP app, as this + // project's README tells operators to do) may not have an + // AVERAGE-consolidated RRA at all. LAST is the one CF every range + // used exclusively before this JSON rewrite (see git history), so + // it's guaranteed to exist -- fall back to it rather than leaving + // week/month/year permanently broken against such a file. + data, err = m.fetchCF(ctx, rg, "LAST") } + return data, err +} - panel, field, line, line2, text, accent, accentFill := graphColorPanel, graphColorField, graphColorLine, graphColorLine2, graphColorText, graphColorAccent, graphColorAccentFill - suffix := "" - if theme == "light" { - panel, field, line, line2, text, accent, accentFill = graphLightColorPanel, graphLightColorField, graphLightColorLine, graphLightColorLine2, graphLightColorText, graphLightColorAccent, graphLightColorAccentFill - suffix = "-light" - } - out := filepath.Join(m.GraphDir, key+suffix+".png") - - m.mu.Lock() - defer m.mu.Unlock() - if st, err := os.Stat(out); err == nil && time.Since(st.ModTime()) < 55*time.Second { - return out, nil - } - - def := "DEF:online=" + m.Path + ":online:LAST" - watermark := "linuxiarz.pl · " + time.Now().Format("2006-01-02 15:04") - args := []string{ - "graph", out, - "--imgformat", "PNG", - "--start", rg.Start, "--end", "now", - // By default rrdtool's --width/--height size only the plot canvas; - // the title, legend rows and axis-label margins then pad the final - // PNG to something larger and variable (depends on title/legend - // text length). --full-size-mode makes --width/--height the exact - // *total* image size instead, so the PNG is always precisely - // 1200x320 -- matching the CSS aspect-ratio on the exactly, - // with no letterboxing from a size mismatch. - "--full-size-mode", - "--width", "1200", "--height", "320", - "--lower-limit", "0", "--alt-autoscale-max", "--rigid", - "--slope-mode", - "--title", rg.Title, - "--vertical-label", "użytkownicy", - "--border", "0", - "--color", "BACK" + panel, - "--color", "CANVAS" + field, - "--color", "FONT" + text, - "--color", "AXIS" + line2, - "--color", "ARROW" + line2, - "--color", "GRID" + line, - "--color", "MGRID" + line2, - "--color", "FRAME" + panel, - "--font", "DEFAULT:0:", - "--font", "TITLE:13:", - "--font", "AXIS:9:", - "--font", "LEGEND:10:", - "--font", "UNIT:9:", - "--watermark", watermark, - def, - "AREA:online" + accentFill, - "LINE2:online" + accent + ":Online", - "GPRINT:online:LAST:Aktualnie\\: %4.0lf", - "GPRINT:online:AVERAGE:Średnio\\: %4.0lf", - "GPRINT:online:MAX:Maks.\\: %4.0lf\\n", - } - cmd := exec.CommandContext(ctx, m.Tool, args...) +func (m *Manager) fetchCF(ctx context.Context, rg *Range, cf string) (GraphData, error) { + cmd := exec.CommandContext(ctx, m.Tool, "fetch", m.Path, cf, "--start", rg.Start, "--end", "now") var stderr bytes.Buffer cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - return "", fmt.Errorf("rrd graph: %w: %s", err, strings.TrimSpace(stderr.String())) + out, err := cmd.Output() + if err != nil { + return GraphData{}, fmt.Errorf("rrd fetch: %w: %s", err, strings.TrimSpace(stderr.String())) } - return out, nil + + data := GraphData{Key: rg.Key, Title: rg.Title} + var raw []Point + var sum float64 + var n int + var prevT int64 = -1 + + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + idx := strings.IndexByte(line, ':') + if idx < 0 { + continue // header line, e.g. " online" + } + ts, err := strconv.ParseInt(strings.TrimSpace(line[:idx]), 10, 64) + if err != nil { + continue // not a data row + } + pt := Point{T: ts} + if f, err := strconv.ParseFloat(strings.TrimSpace(line[idx+1:]), 64); err == nil && !math.IsNaN(f) { + v := f + pt.V = &v + sum += f + n++ + if data.Max == nil || f > *data.Max { + mx := f + data.Max = &mx + } + cur := f + data.Current = &cur // last valid sample wins + } + if prevT >= 0 { + data.Step = ts - prevT + } + prevT = ts + raw = append(raw, pt) + } + if n > 0 { + avg := sum / float64(n) + data.Average = &avg + } + // Current/Average/Max above are computed from the full raw series, not + // diluted by downsampling; only the plotted points themselves shrink. + data.Points = downsample(raw, maxChartPoints) + if len(data.Points) > 1 { + data.Step = data.Points[1].T - data.Points[0].T + } + return data, nil +} + +// downsample buckets points down to at most target entries. Each bucket's +// value is the average of its non-null samples (nil if the bucket has +// none), and its timestamp is the last raw timestamp in the bucket -- the +// same shape as an RRD AVERAGE RRA, so long ranges read as a smooth trend +// instead of a wall of raw per-minute noise. Points already at or under +// the target are returned unchanged. +func downsample(points []Point, target int) []Point { + if target <= 0 || len(points) <= target { + return points + } + bucket := float64(len(points)) / float64(target) + out := make([]Point, 0, target) + for i := 0; i < target; i++ { + start := int(float64(i) * bucket) + end := int(float64(i+1) * bucket) + if end <= start { + end = start + 1 + } + if end > len(points) { + end = len(points) + } + if start >= end { + break + } + var sum float64 + var n int + t := points[end-1].T + for _, p := range points[start:end] { + if p.V != nil { + sum += *p.V + n++ + } + } + pt := Point{T: t} + if n > 0 { + avg := sum / float64(n) + pt.V = &avg + } + out = append(out, pt) + } + return out } diff --git a/internal/rrd/rrd_test.go b/internal/rrd/rrd_test.go new file mode 100644 index 0000000..0c64113 --- /dev/null +++ b/internal/rrd/rrd_test.go @@ -0,0 +1,56 @@ +package rrd + +import "testing" + +func f(v float64) *float64 { return &v } + +func TestDownsampleNoop(t *testing.T) { + in := []Point{{T: 1, V: f(1)}, {T: 2, V: f(2)}, {T: 3, V: f(3)}} + out := downsample(in, 10) + if len(out) != len(in) { + t.Fatalf("expected no-op for points <= target, got %d points", len(out)) + } +} + +func TestDownsampleAveragesBuckets(t *testing.T) { + // 10 points, 0..9, bucketed down to 5: expect roughly paired averages. + in := make([]Point, 10) + for i := range in { + in[i] = Point{T: int64(i), V: f(float64(i))} + } + out := downsample(in, 5) + if len(out) != 5 { + t.Fatalf("expected 5 points, got %d", len(out)) + } + want := []float64{0.5, 2.5, 4.5, 6.5, 8.5} + for i, pt := range out { + if pt.V == nil { + t.Fatalf("bucket %d: expected a value, got nil", i) + } + if *pt.V != want[i] { + t.Errorf("bucket %d: got %v, want %v", i, *pt.V, want[i]) + } + } +} + +func TestDownsampleSkipsNulls(t *testing.T) { + in := []Point{{T: 1, V: f(2)}, {T: 2, V: nil}, {T: 3, V: f(4)}, {T: 4, V: nil}} + out := downsample(in, 1) + if len(out) != 1 { + t.Fatalf("expected 1 bucket, got %d", len(out)) + } + if out[0].V == nil || *out[0].V != 3 { + t.Fatalf("expected average of non-null values (3), got %v", out[0].V) + } +} + +func TestDownsampleAllNullBucketStaysNull(t *testing.T) { + in := []Point{{T: 1, V: nil}, {T: 2, V: nil}} + out := downsample(in, 1) + if len(out) != 1 { + t.Fatalf("expected 1 bucket, got %d", len(out)) + } + if out[0].V != nil { + t.Fatalf("expected nil for an all-null bucket, got %v", *out[0].V) + } +} diff --git a/internal/web/assets/app.css b/internal/web/assets/app.css index e2afe64..9f3ed7d 100644 --- a/internal/web/assets/app.css +++ b/internal/web/assets/app.css @@ -61,28 +61,18 @@ html[data-theme=light] .theme-toggle .ic-moon{display:block} /* === dashboard shell (stats page) ======================================= */ .shell{min-height:100vh;display:grid;grid-template-rows:56px minmax(0,1fr)} -.topbar{position:relative;display:grid;grid-template-columns:240px 1fr auto;align-items:center;gap:14px;padding:0 16px;border-bottom:1px solid var(--line);background:var(--panel)} -.brand{display:flex;align-items:center;gap:10px;font-weight:700;font-size:16px} -.brand svg{color:var(--accent);width:24px;height:24px} -.header-online{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px} +.topbar{position:relative;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:14px;padding:0 16px;border-bottom:1px solid var(--line);background:var(--panel)} +.brand{display:flex;align-items:center;gap:10px;font-weight:700;font-size:16px;min-width:0} +.brand svg{color:var(--accent);width:24px;height:24px;flex:0 0 auto} +.brand span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.header-online{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px;justify-content:center} .header-online strong{color:var(--text);font-size:16px} -.top-actions{display:flex;align-items:center;gap:8px} +.top-actions{display:flex;align-items:center;gap:8px;justify-content:flex-end} .route-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden;background:transparent} .route-progress>span{display:block;width:35%;height:100%;background:var(--accent);animation:route-progress 1.1s ease-in-out infinite} .route-progress[hidden]{display:none} @keyframes route-progress{0%{transform:translateX(-110%)}50%{transform:translateX(120%)}100%{transform:translateX(310%)}} -.body{min-height:0;display:grid;grid-template-columns:220px minmax(0,1fr)} -.sidebar{min-height:0;padding:14px 10px;border-right:1px solid var(--line);background:var(--sidebar);overflow:auto} -.nav{display:grid;gap:3px} -.nav a{display:flex;align-items:center;gap:10px;height:38px;padding:0 11px;border:1px solid transparent;border-radius:var(--r);color:var(--muted)} -.nav a:hover{background:var(--hover);color:var(--text)} -.nav a.active{background:var(--selected);color:var(--text);border-color:var(--line)} -.nav a svg{width:17px;height:17px} -.nav-section{margin:16px 11px 6px;color:var(--subtle);font-size:10px;font-weight:750;letter-spacing:.08em;text-transform:uppercase} -.nav-server{display:flex;align-items:center;gap:9px;height:30px;padding:0 11px;color:var(--muted);font-size:12px} -.nav-server svg{width:15px;height:15px;flex:0 0 auto} -.nav-server span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .icon-sprite{display:none} .content{min-width:0;min-height:0;overflow:auto;padding:26px 28px 48px} @@ -102,18 +92,20 @@ html[data-theme=light] .theme-toggle .ic-moon{display:block} .panel{border:1px solid var(--line);border-radius:var(--r);background:var(--panel);overflow:hidden} .panel-head{display:flex;justify-content:space-between;align-items:center;gap:10px;min-height:44px;padding:0 13px;border-bottom:1px solid var(--line)} .panel-head h2{margin:0;font-size:14px;font-weight:650} -.panel-head p{margin:0;font-size:11px;color:var(--muted)} .graphs{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px} -.graph-image-button{display:block;width:100%;padding:0;border:0;background:var(--field);cursor:zoom-in;overflow:hidden} -.graph-image-button img{display:block;width:100%;height:auto;aspect-ratio:1200/320;object-fit:contain;transition:transform .18s ease,opacity .18s ease} -.graph-image-button:hover img{transform:scale(1.012);opacity:.96} +.graph-chart{display:block;width:100%;padding:0;border:0;background:var(--field);cursor:zoom-in} +.graph-chart canvas{display:block;width:100%;height:auto;aspect-ratio:3/1} +.graph-empty{margin:0;padding:58px 10px;text-align:center;font-size:12px} +.graph-stats{margin:0;padding:9px 13px 12px;font-size:11px;letter-spacing:.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .lightbox[hidden]{display:none} .lightbox{position:fixed;inset:0;z-index:1000;display:grid;place-items:center;padding:28px} .lightbox-backdrop{position:absolute;inset:0;width:100%;height:100%;border:0;background:rgba(0,0,0,.78);cursor:zoom-out} -.lightbox-dialog{position:relative;z-index:1;width:min(96vw,1500px);max-height:92vh;background:var(--panel);border:1px solid var(--line2);border-radius:var(--r);padding:48px 16px 16px;box-shadow:0 24px 80px var(--shadow);overflow:auto} -.lightbox-dialog img{display:block;width:100%;height:auto} +.lightbox-dialog{position:relative;z-index:1;width:min(96vw,1200px);max-height:92vh;background:var(--panel);border:1px solid var(--line2);border-radius:var(--r);padding:48px 18px 16px;box-shadow:0 24px 80px var(--shadow);overflow:auto} +.lightbox-title{margin:0 0 10px;padding-right:90px;font-size:15px;font-weight:650} +.lightbox-canvas{display:block;width:100%;height:min(62vh,560px)} +.lightbox-dialog .graph-stats{padding:10px 2px 0} .lightbox-close{position:absolute;top:10px;right:12px;display:inline-flex;gap:6px;align-items:center} .lightbox-open{overflow:hidden} @@ -158,12 +150,17 @@ html[data-theme=light] .theme-toggle .ic-moon{display:block} @media(max-width:1060px){ .cards{grid-template-columns:repeat(2,minmax(140px,1fr))} .graphs{grid-template-columns:1fr} - .graph-image-button img{aspect-ratio:auto;min-height:260px} + .graph-chart canvas{aspect-ratio:auto;height:200px} } @media(max-width:860px){ - .topbar{grid-template-columns:auto 1fr auto} - .body{grid-template-columns:1fr} - .sidebar{display:none} + .topbar{grid-template-columns:minmax(64px,auto) 1fr auto} + #refresh-status{display:none} +} +@media(max-width:520px){ + .brand span{max-width:40vw} + .header-online .badge{display:none} + .btn-label{display:none} + .top-actions .btn{padding:7px 10px} } @media(max-width:620px){ .cards{grid-template-columns:1fr} diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js index e353f58..bc5c73e 100644 --- a/internal/web/assets/app.js +++ b/internal/web/assets/app.js @@ -5,40 +5,23 @@ var THEME_KEY = 'ts3monolith-theme'; var root = document.documentElement; var toggle = document.querySelector('.theme-toggle'); - var graphs = document.querySelectorAll('.js-live-graph'); - var openers = document.querySelectorAll('.js-graph-open'); function currentTheme() { return root.getAttribute('data-theme') === 'light' ? 'light' : 'dark'; } - // RRD graphs are static PNGs rendered server-side in both a dark and a - // light palette (see internal/rrd/rrd.go); swap to the variant matching - // the active theme so panel graphs never clash with the page around them. - function applyGraphTheme(theme) { - var stamp = Date.now(); - graphs.forEach(function (img) { - var src = theme === 'light' ? img.dataset.srcLight : img.dataset.src; - if (src) img.src = src + '?v=' + stamp; - }); - openers.forEach(function (button) { - var full = theme === 'light' ? button.getAttribute('data-full-light') : button.getAttribute('data-full'); - if (full) button.setAttribute('data-full-active', full); - }); - } - function applyTheme(mode) { if (mode === 'light') root.setAttribute('data-theme', 'light'); else root.removeAttribute('data-theme'); - applyGraphTheme(mode === 'light' ? 'light' : 'dark'); + // Charts read their colors straight from the CSS custom properties at + // draw time, so a theme switch just needs a redraw of the data already + // in hand -- no re-fetch, unlike the old per-theme PNG swap. + renderAllCharts(); } try { var saved = window.localStorage.getItem(THEME_KEY); if (saved) applyTheme(saved); - else applyGraphTheme(currentTheme()); - } catch (e) { - applyGraphTheme(currentTheme()); - } + } catch (e) { /* ignore */ } if (toggle) { toggle.addEventListener('click', function () { @@ -48,7 +31,200 @@ }); } - // --- live stats + graph refresh ----------------------------------------- + // --- RRD charts ----------------------------------------------------------- + // Each panel's time series comes from GET /stats/graph/{range} as plain + // JSON (see rrd.Manager.Fetch / GraphData in internal/rrd/rrd.go) and is + // drawn here in a , instead of asking the server to render a PNG + // with rrdtool. That makes the chart a normal responsive element (it + // resizes with its card, including on mobile, instead of being locked to + // whatever pixel size rrdtool was told to render) and lets a theme toggle + // or window resize just redraw the cached points rather than re-fetching + // an image. + var chartPanels = document.querySelectorAll('.js-chart'); + var chartData = {}; + + function paletteColor(name, fallback) { + var v = getComputedStyle(root).getPropertyValue(name); + return v ? v.trim() : fallback; + } + + function timeFormatter(key) { + if (key === 'week' || key === 'month') { + return function (t) { + return new Date(t * 1000).toLocaleDateString('pl-PL', { day: '2-digit', month: '2-digit' }); + }; + } + if (key === 'year') { + return function (t) { + return new Date(t * 1000).toLocaleDateString('pl-PL', { month: 'short', year: 'numeric' }); + }; + } + return function (t) { + return new Date(t * 1000).toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit' }); + }; + } + + function fmtNum(v) { + return v === null || v === undefined ? '—' : String(Math.round(v)); + } + + function statsText(data) { + return 'Aktualnie: ' + fmtNum(data.current) + ' Średnio: ' + fmtNum(data.average) + ' Maks.: ' + fmtNum(data.max); + } + + // Draws one series into canvas, sized to the canvas's current CSS box + // (so the caller controls size purely through layout/CSS). Returns false + // when there is nothing to draw (e.g. a fresh RRD with no samples yet), + // so the caller can show a text fallback instead of a blank box. + function drawChart(canvas, data) { + var points = (data && data.points) || []; + var hasData = points.some(function (p) { return p.v !== null && p.v !== undefined; }); + if (!hasData) return false; + + var cssW = canvas.clientWidth; + var cssH = canvas.clientHeight; + if (!cssW || !cssH) return false; + var dpr = window.devicePixelRatio || 1; + canvas.width = Math.round(cssW * dpr); + canvas.height = Math.round(cssH * dpr); + var ctx = canvas.getContext('2d'); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, cssW, cssH); + + var lineColor = paletteColor('--line', '#313832'); + var textColor = paletteColor('--muted', '#9da79f'); + var accent = paletteColor('--accent', '#8fb9a4'); + + var padL = 28, padR = 6, padT = 8, padB = 16; + var plotW = Math.max(1, cssW - padL - padR); + var plotH = Math.max(1, cssH - padT - padB); + + var values = []; + points.forEach(function (p) { if (p.v !== null && p.v !== undefined) values.push(p.v); }); + var maxV = values.length ? Math.max.apply(null, values) : 1; + var top = Math.max(1, Math.ceil(maxV * 1.2)); + + function xAt(i) { + return padL + (points.length > 1 ? (i / (points.length - 1)) * plotW : plotW / 2); + } + function yAt(v) { + return padT + plotH - (v / top) * plotH; + } + + // horizontal grid + y labels (0 / mid / top) + ctx.font = '10px ' + getComputedStyle(document.body).fontFamily; + ctx.lineWidth = 1; + [0, top / 2, top].forEach(function (t) { + var y = Math.round(yAt(t)) + 0.5; + ctx.strokeStyle = lineColor; + ctx.globalAlpha = t === 0 ? 0.7 : 0.35; + ctx.beginPath(); + ctx.moveTo(padL, y); + ctx.lineTo(padL + plotW, y); + ctx.stroke(); + ctx.globalAlpha = 1; + ctx.fillStyle = textColor; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + ctx.fillText(String(Math.round(t)), padL - 6, yAt(t)); + }); + + // x labels: first / middle / last sample + if (points.length > 1) { + var fmt = timeFormatter(data.key); + ctx.fillStyle = textColor; + ctx.textBaseline = 'alphabetic'; + [0, Math.floor((points.length - 1) / 2), points.length - 1].forEach(function (i, idx) { + ctx.textAlign = idx === 0 ? 'left' : (idx === 2 ? 'right' : 'center'); + ctx.fillText(fmt(points[i].t), xAt(i), cssH - 3); + }); + } + + // area + line, broken into separate segments across data gaps (nulls) + // instead of interpolating through them + var seg = []; + function flushSegment() { + if (seg.length < 2) { + seg = []; + return; + } + ctx.beginPath(); + ctx.moveTo(seg[0][0], padT + plotH); + seg.forEach(function (pt) { ctx.lineTo(pt[0], pt[1]); }); + ctx.lineTo(seg[seg.length - 1][0], padT + plotH); + ctx.closePath(); + ctx.fillStyle = accent + '38'; + ctx.fill(); + + ctx.beginPath(); + seg.forEach(function (pt, idx) { + if (idx === 0) ctx.moveTo(pt[0], pt[1]); + else ctx.lineTo(pt[0], pt[1]); + }); + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + ctx.lineWidth = 1.6; + ctx.strokeStyle = accent; + ctx.stroke(); + seg = []; + } + points.forEach(function (p, i) { + if (p.v === null || p.v === undefined) { + flushSegment(); + return; + } + seg.push([xAt(i), yAt(p.v)]); + }); + flushSegment(); + return true; + } + + function renderPanel(panel) { + var canvas = panel.querySelector('.js-chart-canvas'); + var empty = panel.querySelector('.graph-empty'); + var statsEl = panel.querySelector('.js-graph-stats'); + if (!canvas) return; + var data = chartData[panel.dataset.range]; + var ok = !!data && drawChart(canvas, data); + canvas.hidden = !ok; + if (empty) { + empty.hidden = ok; + empty.textContent = data ? 'Brak danych' : 'Wykres niedostępny'; + } + if (statsEl) statsEl.textContent = ok ? statsText(data) : ''; + } + + function renderAllCharts() { + chartPanels.forEach(renderPanel); + } + + function loadCharts() { + return Promise.all(Array.prototype.map.call(chartPanels, function (panel) { + var range = panel.dataset.range; + return fetch('/stats/graph/' + range, { cache: 'no-store' }) + .then(function (r) { + if (!r.ok) throw new Error('graph'); + return r.json(); + }) + .then(function (data) { chartData[range] = data; }) + .catch(function () { chartData[range] = null; }); + })).then(renderAllCharts); + } + + function debounce(fn, wait) { + var t; + return function () { + clearTimeout(t); + t = setTimeout(fn, wait); + }; + } + + if (chartPanels.length) { + loadCharts(); + window.addEventListener('resize', debounce(renderAllCharts, 150)); + } + + // --- live stats + chart refresh -------------------------------------------- var count = document.getElementById('online-count'); var status = document.getElementById('refresh-status'); var progress = document.getElementById('route-progress'); @@ -61,57 +237,63 @@ setBusy(true); if (status) status.textContent = 'Odświeżanie…'; - var done = Promise.resolve(); + var tasks = []; if (count) { - done = fetch('/stats/live', { cache: 'no-store' }) + tasks.push(fetch('/stats/live', { cache: 'no-store' }) .then(function (response) { if (!response.ok) throw new Error('stats'); return response.json(); }) .then(function (data) { if (typeof data.current === 'number') count.textContent = String(data.current); - if (status) status.textContent = 'Dane aktualne'; - }) - .catch(function () { - if (status) status.textContent = 'Nie udało się odświeżyć'; - }); + })); } + if (chartPanels.length) tasks.push(loadCharts()); - applyGraphTheme(currentTheme()); - - done.then(function () { setBusy(false); }); + Promise.all(tasks) + .then(function () { if (status) status.textContent = 'Dane aktualne'; }) + .catch(function () { if (status) status.textContent = 'Nie udało się odświeżyć'; }) + .then(function () { setBusy(false); }); } - if (count || graphs.length) { + if (count || chartPanels.length) { window.setInterval(refresh, 60000); } // --- graph lightbox ------------------------------------------------------- var lightbox = document.getElementById('graph-lightbox'); - var lightboxImage = document.getElementById('lightbox-image'); + var lightboxCanvas = document.getElementById('lightbox-canvas'); + var lightboxTitle = document.getElementById('lightbox-title'); + var lightboxStats = document.getElementById('lightbox-stats'); var openers = document.querySelectorAll('.js-graph-open'); var closeButtons = document.querySelectorAll('[data-lightbox-close]'); - function openLightbox(src) { - if (!lightbox || !lightboxImage) return; - lightboxImage.src = src + (src.indexOf('?') === -1 ? '?' : '&') + 'v=' + Date.now(); + function openLightbox(range) { + if (!lightbox || !lightboxCanvas) return; + var data = chartData[range]; lightbox.hidden = false; lightbox.setAttribute('aria-hidden', 'false'); document.body.classList.add('lightbox-open'); + if (lightboxTitle) lightboxTitle.textContent = data ? data.title : ''; + if (lightboxStats) lightboxStats.textContent = data ? statsText(data) : ''; + // Wait a frame so the now-visible dialog has its real layout size + // before measuring clientWidth/clientHeight for the bigger redraw. + requestAnimationFrame(function () { + if (data) drawChart(lightboxCanvas, data); + }); } function closeLightbox() { - if (!lightbox || !lightboxImage) return; + if (!lightbox) return; lightbox.hidden = true; lightbox.setAttribute('aria-hidden', 'true'); - lightboxImage.src = ''; document.body.classList.remove('lightbox-open'); } openers.forEach(function (button) { button.addEventListener('click', function () { - var src = button.getAttribute('data-full-active') || button.getAttribute('data-full'); - if (src) openLightbox(src); + var panel = button.closest('.js-chart'); + if (panel) openLightbox(panel.dataset.range); }); }); diff --git a/internal/web/server.go b/internal/web/server.go index 6cb65c5..d797539 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -5,13 +5,13 @@ import ( "crypto/sha256" "embed" "encoding/hex" + "encoding/json" "fmt" "html/template" "log" "net" "net/http" "net/url" - "os" "path/filepath" "regexp" "strconv" @@ -111,7 +111,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/stats", http.StatusFound) }) mux.HandleFunc("GET /stats", s.stats) mux.HandleFunc("GET /stats/live", s.statsLive) - mux.HandleFunc("GET /graph/{file}", s.graph) + mux.HandleFunc("GET /stats/graph/{key}", s.graphData) mux.HandleFunc("GET /channel", s.channel) mux.HandleFunc("GET /embed/channel", s.channel) mux.HandleFunc("POST /channel/verify/start", s.verifyStart) @@ -250,43 +250,26 @@ func (s *Server) asset(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(data) } -func (s *Server) graph(w http.ResponseWriter, r *http.Request) { +func (s *Server) graphData(w http.ResponseWriter, r *http.Request) { ip := s.clientIP(r).String() - // Graph PNGs are cheap to serve: rrd.Manager.Graph caches each - // key+theme render on disk for ~55s, so most hits here are a plain - // file read, not an rrdtool invocation. One page view alone can - // legitimately touch this 6 ranges x 2 themes (lightbox open, theme - // toggle) times, before counting multiple tabs or several visitors - // sharing one IP (same household/NAT) -- see README "Rate limiting" - // for tuning RATE_LIMIT_GRAPH if this still trips in practice. + // Each hit here is one rrdtool fetch returning a few hundred small + // JSON numbers read straight from the local RRD file -- cheap, so + // the limit mainly exists to stop outright abuse. See README "Rate + // limiting" for tuning RATE_LIMIT_GRAPH. if s.cfg.RateLimitEnabled && !s.limit.allow("graph|"+ip, s.cfg.RateLimitGraph, time.Minute) { s.tooMany(w) return } ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) defer cancel() - file := r.PathValue("file") - if !strings.HasSuffix(file, ".png") { - http.NotFound(w, r) - return - } - key := strings.TrimSuffix(file, ".png") - theme := "dark" - if strings.HasSuffix(key, "-light") { - theme = "light" - key = strings.TrimSuffix(key, "-light") - } - if key == "" || strings.Contains(key, "/") { - http.NotFound(w, r) - return - } - p, err := s.rrd.Graph(ctx, key, theme) + data, err := s.rrd.Fetch(ctx, r.PathValue("key")) if err != nil { - http.Error(w, "graph unavailable", http.StatusServiceUnavailable) + http.Error(w, "graph data unavailable", http.StatusServiceUnavailable) return } - w.Header().Set("Cache-Control", "public, max-age=60") - http.ServeFile(w, r, p) + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(data) } func (s *Server) channel(w http.ResponseWriter, r *http.Request) { @@ -579,5 +562,3 @@ func (s *Server) render(w http.ResponseWriter, name string, data any) { log.Printf("template error: %v", err) } } - -func EnsureGraphDir(path string) error { return os.MkdirAll(filepath.Clean(path), 0750) } diff --git a/internal/web/templates/stats.html b/internal/web/templates/stats.html index 29c5671..bb1cf95 100644 --- a/internal/web/templates/stats.html +++ b/internal/web/templates/stats.html @@ -11,9 +11,9 @@ {{template "icons" .}}
- + - Panel serwera + {{if .InfoAvailable}}{{.ServerName}}{{else}}Serwer TS3{{end}}
Online @@ -21,8 +21,8 @@ Dane aktualne
-
- + -
-
-
-

Statystyki serwera

-

Aktywność TeamSpeak 3

-

Historia liczby użytkowników połączonych z serwerem.

-
-
+
+
+ +
Online teraz
{{.Current}}{{if .InfoAvailable}}{{if gt .MaxClients 0}} / {{.MaxClients}}{{end}}{{end}}
+
+
+ +
Uptime
{{if .InfoAvailable}}{{.Uptime}}{{else}}—{{end}}
+
+
+ +
Wersja serwera
{{if .InfoAvailable}}{{.ServerVersion}}{{else}}—{{end}}
+
+
+ +
Platforma
{{if .InfoAvailable}}{{.ServerPlatform}}{{else}}—{{end}}
+
+
-
-
- -
Online teraz
{{.Current}}{{if .InfoAvailable}}{{if gt .MaxClients 0}} / {{.MaxClients}}{{end}}{{end}}
+

Użytkownicy online

+
+ {{range .Ranges}} +
+
+

{{.Title}}

-
- -
Uptime
{{if .InfoAvailable}}{{.Uptime}}{{else}}—{{end}}
-
-
- -
Wersja serwera
{{if .InfoAvailable}}{{.ServerVersion}}{{else}}—{{end}}
-
-
- -
Platforma
{{if .InfoAvailable}}{{.ServerPlatform}}{{else}}—{{end}}
-
-
- -

Wykresy obecności

-
- {{range .Ranges}} -
-
-
-

{{.Title}}

-

Liczba użytkowników online

-
-
- -
- {{end}} -
-
-
+ +

+ + {{end}} + +
diff --git a/packaging/ts3monolith.default b/packaging/ts3monolith.default index eac49cf..c72bf9e 100644 --- a/packaging/ts3monolith.default +++ b/packaging/ts3monolith.default @@ -25,7 +25,6 @@ TS_QUERY_TIMEOUT=3s # === RRD / statystyki ========================================================= RRDTOOL=rrdtool RRD_PATH=/var/lib/ts3monolith/online.rrd -GRAPH_DIR=/var/lib/ts3monolith/graphs STATS_INTERVAL=60s # Szczegolowy log kazdego cyklu zbierania statystyk (surowy odczyt z TS3 vs # co faktycznie trafilo do RRD). Przydatne tylko przy diagnozowaniu -- zalewa diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh index fe0a2b4..0dec17a 100755 --- a/scripts/build-deb.sh +++ b/scripts/build-deb.sh @@ -92,7 +92,6 @@ if ! getent passwd ts3monolith >/dev/null 2>&1; then fi install -d -o ts3monolith -g ts3monolith -m 0750 /var/lib/ts3monolith -install -d -o ts3monolith -g ts3monolith -m 0750 /var/lib/ts3monolith/graphs if [ -f /etc/default/ts3monolith ]; then chown root:ts3monolith /etc/default/ts3monolith diff --git a/ts3build_v9_stage.tar.gz b/ts3build_v9_stage.tar.gz new file mode 100644 index 0000000..3b3a454 Binary files /dev/null and b/ts3build_v9_stage.tar.gz differ