Compare commits
2
Commits
master
..
083213eada
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
083213eada | ||
|
|
9d093b691c |
No files matched your search
+1
-14
@@ -2,9 +2,6 @@
|
|||||||
APP_SECRET=CHANGE_ME_TO_A_LONG_RANDOM_SECRET
|
APP_SECRET=CHANGE_ME_TO_A_LONG_RANDOM_SECRET
|
||||||
LISTEN_ADDR=0.0.0.0:8080
|
LISTEN_ADDR=0.0.0.0:8080
|
||||||
HTTPS_ONLY=true
|
HTTPS_ONLY=true
|
||||||
# Verbose per-cycle stats-collector logging (raw TS3 reading vs what got
|
|
||||||
# written to the RRD). Noisy, meant for troubleshooting only.
|
|
||||||
DEBUG=false
|
|
||||||
|
|
||||||
# Origins allowed to embed /embed/channel in an iframe. Space-separated, origins only.
|
# Origins allowed to embed /embed/channel in an iframe. Space-separated, origins only.
|
||||||
FRAME_ANCESTORS=https://www.example.org
|
FRAME_ANCESTORS=https://www.example.org
|
||||||
@@ -23,15 +20,5 @@ TS_QUERY_TIMEOUT=3s
|
|||||||
|
|
||||||
RRDTOOL=rrdtool
|
RRDTOOL=rrdtool
|
||||||
RRD_PATH=/var/lib/ts3monolith/linuxiarz.rrd
|
RRD_PATH=/var/lib/ts3monolith/linuxiarz.rrd
|
||||||
|
GRAPH_DIR=/var/lib/ts3monolith/graphs
|
||||||
STATS_INTERVAL=60s
|
STATS_INTERVAL=60s
|
||||||
|
|
||||||
# Per-IP rate limiting, off by default (see README "Rate limiting"). When
|
|
||||||
# RATE_LIMIT_ENABLED=false, none of the RATE_LIMIT_* values below do anything.
|
|
||||||
RATE_LIMIT_ENABLED=false
|
|
||||||
RATE_LIMIT_STATS=60
|
|
||||||
RATE_LIMIT_STATS_LIVE=60
|
|
||||||
RATE_LIMIT_GRAPH=600
|
|
||||||
RATE_LIMIT_CHANNEL=40
|
|
||||||
RATE_LIMIT_VERIFY=10
|
|
||||||
RATE_LIMIT_VERIFY_DONE=10
|
|
||||||
RATE_LIMIT_CREATE=5
|
|
||||||
@@ -15,9 +15,8 @@ ten sam układ tokenów kolorów, te same komponenty UI.
|
|||||||
- `clientmove` wskazanego `clid` do nowego kanału;
|
- `clientmove` wskazanego `clid` do nowego kanału;
|
||||||
- dashboard statystyk: liczba online na żywo (auto-odświeżanie co 60 s przez
|
- dashboard statystyk: liczba online na żywo (auto-odświeżanie co 60 s przez
|
||||||
`GET /stats/live`), uptime/wersja/platforma serwera (`serverinfo`),
|
`GET /stats/live`), uptime/wersja/platforma serwera (`serverinfo`),
|
||||||
wykresy RRD: 12h, 24h, 72h, tydzień, miesiąc, rok — dane pobierane jako
|
wykresy RRD: 12h, 24h, 72h, tydzień, miesiąc, rok — w kolorystyce
|
||||||
JSON i rysowane responsywnie w `<canvas>` po stronie przeglądarki (patrz
|
dopasowanej do motywu aplikacji (ciemne tło, akcentowa linia, watermark);
|
||||||
sekcja [RRD](#rrd)), z podglądem na powiększenie;
|
|
||||||
- przełącznik jasny/ciemny motyw (zapamiętywany w `localStorage`);
|
- przełącznik jasny/ciemny motyw (zapamiętywany w `localStorage`);
|
||||||
- formularz pod `/embed/channel`, przeznaczony do osadzenia na innej domenie.
|
- formularz pod `/embed/channel`, przeznaczony do osadzenia na innej domenie.
|
||||||
|
|
||||||
@@ -104,22 +103,14 @@ 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
|
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.
|
przy kroku 60 s — ten sam layout logiczny co istniejące wykresy aplikacji.
|
||||||
|
|
||||||
Wykresy na dashboardzie **nie są** PNG renderowanymi przez `rrdtool graph`.
|
Wykresy renderowane są w kolorach dopasowanych do motywu dashboardu
|
||||||
`GET /stats/graph/{zakres}` (`internal/rrd/rrd.go`, `Manager.Fetch`) woła
|
(`--panel`/`--field`/`--accent` z `internal/web/assets/app.css`), z cienkim
|
||||||
`rrdtool fetch` i zwraca surowe próbki jako mały JSON (`{"points":[{"t":...,
|
wypełnieniem pod linią, watermarkiem z datą generacji i `--slope-mode` dla
|
||||||
"v":...},...],"current":...,"average":...,"max":...}`); front-end
|
gładszej linii. Każdy zakres jest renderowany dwukrotnie — `<zakres>.png`
|
||||||
(`internal/web/assets/app.js`) rysuje z tego wykres samodzielnie w
|
(ciemna paleta) i `<zakres>-light.png` (jasna paleta) — a front-end
|
||||||
`<canvas>`, kolorami wziętymi na żywo z CSS (`--line`/--muted`/`--accent`),
|
(`internal/web/assets/app.js`) przełącza `src` na właściwy wariant przy
|
||||||
więc przy przełączeniu jasny/ciemny motyw wykres się po prostu przerysowuje
|
zmianie motywu, więc wykresy zawsze dopasowane są do aktywnego trybu
|
||||||
z już pobranych danych — bez kolejnego zapytania do serwera i bez osobnych
|
jasny/ciemny.
|
||||||
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 `<canvas>`, 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.
|
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.
|
||||||
|
|
||||||
@@ -136,32 +127,3 @@ Jeżeli docelowy serwer ma inną architekturę/endianness niż źródłowy x86_6
|
|||||||
- build z `-trimpath` i usuniętymi symbolami/debug info.
|
- build z `-trimpath` i usuniętymi symbolami/debug info.
|
||||||
|
|
||||||
Nie da się zagwarantować, że stos technologiczny będzie całkowicie nierozpoznawalny na podstawie zachowania sieci/TLS. Celem jest brak prostych bannerów, wersji i sygnatur frameworka.
|
Nie da się zagwarantować, że stos technologiczny będzie całkowicie nierozpoznawalny na podstawie zachowania sieci/TLS. Celem jest brak prostych bannerów, wersji i sygnatur frameworka.
|
||||||
|
|
||||||
## Rate limiting
|
|
||||||
|
|
||||||
Domyślnie **wyłączony** (`RATE_LIMIT_ENABLED=false`) — endpointy mają limity
|
|
||||||
zahardkodowane w kodzie od początku, ale same w sobie nie chronią niczego
|
|
||||||
krytycznego (ServerQuery i tak ma własny timeout, `channelcreate` i tak
|
|
||||||
wymaga poprawnego tokenu CSRF), a przy nietypowym ruchu (kilku odwiedzających
|
|
||||||
za tym samym IP/NAT, kilka kart, lightbox z wykresami) dawały false-positive
|
|
||||||
`429` bez realnej korzyści. Włącz (`RATE_LIMIT_ENABLED=true`), jeśli chcesz
|
|
||||||
dodatkową warstwę ochrony przed nadużyciami na konkretnym endpoincie.
|
|
||||||
|
|
||||||
Każdy limit jest per-IP (po rozwiązaniu `TRUSTED_PROXIES`/`X-Forwarded-For`,
|
|
||||||
patrz `clientIP()` w `internal/web/server.go`) i liczony w oknie czasowym
|
|
||||||
podanym w nawiasie:
|
|
||||||
|
|
||||||
| Zmienna | Endpoint | Domyślnie | Co chroni |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `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 /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 |
|
|
||||||
| `RATE_LIMIT_CREATE` | `POST /channel/create` po weryfikacji (na godzinę) | 5 | utworzenie kanału po weryfikacji nicku przez NAT |
|
|
||||||
|
|
||||||
Przy przekroczeniu limitu endpoint odpowiada `429 Too Many Requests` z
|
|
||||||
nagłówkiem `Retry-After: 60`. Wartości domyślne są w `.env.example` i
|
|
||||||
`packaging/ts3monolith.default` — dostosuj je do realnego ruchu na danej
|
|
||||||
instalacji zanim włączysz `RATE_LIMIT_ENABLED` na produkcji.
|
|
||||||
+6
-32
@@ -24,7 +24,7 @@ func main() {
|
|||||||
log.Fatal(err)
|
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}
|
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}
|
rm := &rrd.Manager{Tool: cfg.RRDTool, Path: cfg.RRDPath, GraphDir: cfg.GraphDir}
|
||||||
if err := rm.Validate(); err != nil {
|
if err := rm.Validate(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -52,41 +52,15 @@ func main() {
|
|||||||
func collect(ctx context.Context, cfg config.Config, tq *ts3.Query, rm *rrd.Manager) {
|
func collect(ctx context.Context, cfg config.Config, tq *ts3.Query, rm *rrd.Manager) {
|
||||||
run := func() {
|
run := func() {
|
||||||
c, cancel := context.WithTimeout(ctx, cfg.TSQueryTimeout)
|
c, cancel := context.WithTimeout(ctx, cfg.TSQueryTimeout)
|
||||||
|
defer cancel()
|
||||||
n, err := tq.CountOnline(c)
|
n, err := tq.CountOnline(c)
|
||||||
cancel()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("stats query failed: %v", err)
|
log.Printf("stats query failed: %v", err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
// Treat "query failed" and "query succeeded but nobody is online"
|
c2, cancel2 := context.WithTimeout(ctx, 5*time.Second)
|
||||||
// the same way: repeat the last known sample instead of writing 0.
|
defer cancel2()
|
||||||
// This matches the original PHP app's collector exactly (it never
|
if err := rm.Update(c2, n); err != nil {
|
||||||
// distinguished the two cases either). At a 1-minute sampling
|
|
||||||
// interval a real but brief empty-server moment plots as a sharp
|
|
||||||
// spike down to 0 and back up, which reads as broken/jagged far
|
|
||||||
// more than it reads as useful information -- so, deliberately,
|
|
||||||
// a true "server went empty for a while" no longer shows as 0 in
|
|
||||||
// the graph, same trade-off the original app made.
|
|
||||||
raw := n
|
|
||||||
if n == 0 {
|
|
||||||
c2, cancel2 := context.WithTimeout(ctx, 2*time.Second)
|
|
||||||
last, lastErr := rm.Last(c2)
|
|
||||||
cancel2()
|
|
||||||
if lastErr != nil {
|
|
||||||
log.Printf("collect: raw=0, rrd lastupdate failed (writing raw 0): %v", lastErr)
|
|
||||||
} else {
|
|
||||||
n = last
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// DEBUG=1 turns on a log line for every single collection cycle
|
|
||||||
// (raw TS3 reading vs. what actually got written to the RRD) --
|
|
||||||
// useful while diagnosing the stats collector, far too noisy to
|
|
||||||
// leave on permanently (one line per STATS_INTERVAL, forever).
|
|
||||||
if cfg.Debug {
|
|
||||||
log.Printf("collect: raw=%d written=%d", raw, n)
|
|
||||||
}
|
|
||||||
c3, cancel3 := context.WithTimeout(ctx, 5*time.Second)
|
|
||||||
defer cancel3()
|
|
||||||
if err := rm.Update(c3, n); err != nil {
|
|
||||||
log.Printf("rrd update failed: %v", err)
|
log.Printf("rrd update failed: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
84c245bc8b2534b0f3b822637cc7c7d51fa7c6918e60909025a326578d284e2a ts3monolith-linux-amd64
|
3b51f741d9067586fa06db6bc3819f3e2d0cd7387f266c93c5ffa32878df6d60 ts3monolith-linux-amd64
|
||||||
@@ -15,7 +15,6 @@ type Config struct {
|
|||||||
ListenAddr string
|
ListenAddr string
|
||||||
AppSecret string
|
AppSecret string
|
||||||
HTTPSOnly bool
|
HTTPSOnly bool
|
||||||
Debug bool
|
|
||||||
FrameAncestors []string
|
FrameAncestors []string
|
||||||
TrustedProxies []*net.IPNet
|
TrustedProxies []*net.IPNet
|
||||||
TSQueryHost string
|
TSQueryHost string
|
||||||
@@ -28,19 +27,8 @@ type Config struct {
|
|||||||
TSQueryTimeout time.Duration
|
TSQueryTimeout time.Duration
|
||||||
RRDTool string
|
RRDTool string
|
||||||
RRDPath string
|
RRDPath string
|
||||||
|
GraphDir string
|
||||||
StatsInterval time.Duration
|
StatsInterval time.Duration
|
||||||
|
|
||||||
// Per-IP rate limiting. Off by default -- see README "Rate limiting"
|
|
||||||
// for what each endpoint's limit protects against and reasonable
|
|
||||||
// values if you turn it on.
|
|
||||||
RateLimitEnabled bool
|
|
||||||
RateLimitStats int
|
|
||||||
RateLimitStatsLive int
|
|
||||||
RateLimitGraph int
|
|
||||||
RateLimitChannel int
|
|
||||||
RateLimitVerify int
|
|
||||||
RateLimitVerifyDone int
|
|
||||||
RateLimitCreate int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -51,7 +39,6 @@ func Load() (Config, error) {
|
|||||||
return c, errors.New("APP_SECRET must contain at least 32 characters")
|
return c, errors.New("APP_SECRET must contain at least 32 characters")
|
||||||
}
|
}
|
||||||
c.HTTPSOnly = envBool("HTTPS_ONLY", true)
|
c.HTTPSOnly = envBool("HTTPS_ONLY", true)
|
||||||
c.Debug = envBool("DEBUG", false)
|
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
c.FrameAncestors, err = frameAncestors(os.Getenv("FRAME_ANCESTORS"))
|
c.FrameAncestors, err = frameAncestors(os.Getenv("FRAME_ANCESTORS"))
|
||||||
@@ -89,6 +76,7 @@ func Load() (Config, error) {
|
|||||||
|
|
||||||
c.RRDTool = env("RRDTOOL", "rrdtool")
|
c.RRDTool = env("RRDTOOL", "rrdtool")
|
||||||
c.RRDPath = env("RRD_PATH", "/var/lib/ts3monolith/linuxiarz.rrd")
|
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)
|
c.StatsInterval, err = envDuration("STATS_INTERVAL", time.Minute)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c, err
|
return c, err
|
||||||
@@ -96,36 +84,6 @@ func Load() (Config, error) {
|
|||||||
if c.StatsInterval < 10*time.Second {
|
if c.StatsInterval < 10*time.Second {
|
||||||
return c, errors.New("STATS_INTERVAL must be at least 10s")
|
return c, errors.New("STATS_INTERVAL must be at least 10s")
|
||||||
}
|
}
|
||||||
|
|
||||||
c.RateLimitEnabled = envBool("RATE_LIMIT_ENABLED", false)
|
|
||||||
c.RateLimitStats, err = envInt("RATE_LIMIT_STATS", 60)
|
|
||||||
if err != nil {
|
|
||||||
return c, err
|
|
||||||
}
|
|
||||||
c.RateLimitStatsLive, err = envInt("RATE_LIMIT_STATS_LIVE", 60)
|
|
||||||
if err != nil {
|
|
||||||
return c, err
|
|
||||||
}
|
|
||||||
c.RateLimitGraph, err = envInt("RATE_LIMIT_GRAPH", 600)
|
|
||||||
if err != nil {
|
|
||||||
return c, err
|
|
||||||
}
|
|
||||||
c.RateLimitChannel, err = envInt("RATE_LIMIT_CHANNEL", 40)
|
|
||||||
if err != nil {
|
|
||||||
return c, err
|
|
||||||
}
|
|
||||||
c.RateLimitVerify, err = envInt("RATE_LIMIT_VERIFY", 10)
|
|
||||||
if err != nil {
|
|
||||||
return c, err
|
|
||||||
}
|
|
||||||
c.RateLimitVerifyDone, err = envInt("RATE_LIMIT_VERIFY_DONE", 10)
|
|
||||||
if err != nil {
|
|
||||||
return c, err
|
|
||||||
}
|
|
||||||
c.RateLimitCreate, err = envInt("RATE_LIMIT_CREATE", 5)
|
|
||||||
if err != nil {
|
|
||||||
return c, err
|
|
||||||
}
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+99
-250
@@ -5,44 +5,53 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
Tool string
|
Tool string
|
||||||
Path string
|
Path string
|
||||||
mu sync.Mutex
|
GraphDir string
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// Range describes one of the dashboard's graph time windows. CF is the RRD
|
// Graph colors mirror the dashboard's theme tokens (internal/web/assets/app.css)
|
||||||
// consolidation function to fetch with: "LAST" for the windows covered by
|
// so rendered PNGs sit visually in place inside the panel cards. Each range
|
||||||
// the full-resolution 1x60s RRA (see packaging/ts3monolith-init-rrd), and
|
// is rendered twice (dark + light) so the client can pick the variant that
|
||||||
// "AVERAGE" for the longer windows that only exist in the coarser,
|
// matches the active site theme without round-tripping through rrdtool.
|
||||||
// pre-averaged RRAs -- asking those for LAST would silently return nothing
|
const (
|
||||||
// older than the full-resolution RRA's ~75h span.
|
graphColorPanel = "#181c19" // --panel (dark)
|
||||||
type Range struct{ Key, Start, Title, CF string }
|
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 }
|
||||||
|
|
||||||
var Ranges = []Range{
|
var Ranges = []Range{
|
||||||
{"hour", "-12h", "Ostatnie 12 godzin", "LAST"},
|
{"hour", "-12h", "Ostatnie 12 godzin"},
|
||||||
{"day", "-24h", "Ostatnie 24 godziny", "LAST"},
|
{"day", "-24h", "Ostatnie 24 godziny"},
|
||||||
{"72h", "-72h", "Ostatnie 72 godziny", "LAST"},
|
{"72h", "-72h", "Ostatnie 72 godziny"},
|
||||||
{"week", "-1w", "Ostatni tydzień", "AVERAGE"},
|
{"week", "-1w", "Ostatni tydzień"},
|
||||||
{"month", "-1m", "Ostatni miesiąc", "AVERAGE"},
|
{"month", "-1m", "Ostatni miesiąc"},
|
||||||
{"year", "-1y", "Ostatni rok", "AVERAGE"},
|
{"year", "-1y", "Ostatni rok"},
|
||||||
}
|
|
||||||
|
|
||||||
func rangeByKey(key string) *Range {
|
|
||||||
for i := range Ranges {
|
|
||||||
if Ranges[i].Key == key {
|
|
||||||
return &Ranges[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Validate() error {
|
func (m *Manager) Validate() error {
|
||||||
@@ -52,7 +61,7 @@ func (m *Manager) Validate() error {
|
|||||||
if st, err := os.Stat(m.Path); err != nil || st.IsDir() {
|
if st, err := os.Stat(m.Path); err != nil || st.IsDir() {
|
||||||
return fmt.Errorf("RRD file unavailable: %s", m.Path)
|
return fmt.Errorf("RRD file unavailable: %s", m.Path)
|
||||||
}
|
}
|
||||||
return nil
|
return os.MkdirAll(m.GraphDir, 0750)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Update(ctx context.Context, online int) error {
|
func (m *Manager) Update(ctx context.Context, online int) error {
|
||||||
@@ -90,233 +99,73 @@ func (m *Manager) Last(ctx context.Context) (int, error) {
|
|||||||
return int(f + 0.5), err
|
return int(f + 0.5), err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Point is one JSON-serializable RRD sample. V is nil when the RRA has no
|
func (m *Manager) Graph(ctx context.Context, key, theme string) (string, error) {
|
||||||
// data for that timestamp (rrdtool reports it as "nan"), which the
|
var rg *Range
|
||||||
// front-end renders as zero while preserving NULL as "missing" in the API.
|
for i := range Ranges {
|
||||||
type Point struct {
|
if Ranges[i].Key == key {
|
||||||
T int64 `json:"t"`
|
rg = &Ranges[i]
|
||||||
V *float64 `json:"v"`
|
break
|
||||||
}
|
|
||||||
|
|
||||||
// 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"`
|
|
||||||
PeakPoints []Point `json:"peak_points,omitempty"`
|
|
||||||
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
|
|
||||||
|
|
||||||
// chartPointTarget keeps roughly comparable visual density across ranges.
|
|
||||||
// Long ranges get wider buckets, which suppresses one-sample needles while
|
|
||||||
// preserving the overall trend. True peaks are returned separately from the
|
|
||||||
// RRD MAX archive, so smoothing the average line does not hide maxima.
|
|
||||||
func chartPointTarget(key string) int {
|
|
||||||
switch key {
|
|
||||||
case "week":
|
|
||||||
return 336 // about 30 minutes per point
|
|
||||||
case "month":
|
|
||||||
return 372 // about 2 hours per point
|
|
||||||
case "year":
|
|
||||||
return 365 // about 1 day per point
|
|
||||||
default:
|
|
||||||
return maxChartPoints
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 GraphData{}, errors.New("unknown graph range")
|
|
||||||
}
|
|
||||||
data, err := m.fetchCF(ctx, rg, rg.CF)
|
|
||||||
if err != nil && rg.CF != "LAST" {
|
|
||||||
// Older imported RRDs may not contain AVERAGE/MAX archives. Fall
|
|
||||||
// back to LAST so the dashboard still works with such databases.
|
|
||||||
data, err = m.fetchCF(ctx, rg, "LAST")
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// For consolidated long ranges, fetch the matching MAX archive too.
|
|
||||||
// The main line stays a smoothed average, while PeakPoints and Max keep
|
|
||||||
// real peaks visible instead of deriving a fake maximum from averages.
|
|
||||||
if rg.CF == "AVERAGE" {
|
|
||||||
if peaks, peakErr := m.fetchCF(ctx, rg, "MAX"); peakErr == nil {
|
|
||||||
data.PeakPoints = peaks.Points
|
|
||||||
if peaks.Max != nil {
|
|
||||||
data.Max = peaks.Max
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return data, nil
|
if rg == nil {
|
||||||
}
|
return "", errors.New("unknown graph range")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(m.GraphDir, 0750); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) fetchCF(ctx context.Context, rg *Range, cf string) (GraphData, error) {
|
panel, field, line, line2, text, accent, accentFill := graphColorPanel, graphColorField, graphColorLine, graphColorLine2, graphColorText, graphColorAccent, graphColorAccentFill
|
||||||
cmd := exec.CommandContext(ctx, m.Tool, "fetch", m.Path, cf, "--start", rg.Start, "--end", "now")
|
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",
|
||||||
|
"--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...)
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
out, err := cmd.Output()
|
if err := cmd.Run(); err != nil {
|
||||||
if err != nil {
|
return "", fmt.Errorf("rrd graph: %w: %s", err, strings.TrimSpace(stderr.String()))
|
||||||
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.
|
|
||||||
target := chartPointTarget(rg.Key)
|
|
||||||
if cf == "MAX" {
|
|
||||||
data.Points = downsampleMax(raw, target)
|
|
||||||
} else {
|
|
||||||
data.Points = downsample(raw, target)
|
|
||||||
}
|
|
||||||
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. Missing samples
|
|
||||||
// inside a bucket count as zero for the plotted average, matching the UI's
|
|
||||||
// NULL -> 0 semantics and preventing one isolated non-zero sample among many
|
|
||||||
// NULLs from becoming a full-height needle. An all-NULL bucket remains NULL
|
|
||||||
// so a completely empty RRD is still detected as "Brak danych".
|
|
||||||
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(end-start)
|
|
||||||
pt.V = &avg
|
|
||||||
}
|
|
||||||
out = append(out, pt)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// downsampleMax keeps the highest valid value from each bucket. It is used
|
|
||||||
// only for the secondary peak line fetched from an RRD MAX archive.
|
|
||||||
func downsampleMax(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
|
|
||||||
}
|
|
||||||
pt := Point{T: points[end-1].T}
|
|
||||||
var max *float64
|
|
||||||
for _, p := range points[start:end] {
|
|
||||||
if p.V == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if max == nil || *p.V > *max {
|
|
||||||
v := *p.V
|
|
||||||
max = &v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pt.V = max
|
|
||||||
out = append(out, pt)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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 TestDownsampleTreatsNullsAsZeroInsideNonEmptyBucket(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 != 1.5 {
|
|
||||||
t.Fatalf("expected NULL-as-zero bucket average (1.5), 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDownsampleMaxKeepsPeak(t *testing.T) {
|
|
||||||
in := []Point{{T: 1, V: f(1)}, {T: 2, V: f(7)}, {T: 3, V: nil}, {T: 4, V: f(3)}}
|
|
||||||
out := downsampleMax(in, 1)
|
|
||||||
if len(out) != 1 {
|
|
||||||
t.Fatalf("expected 1 bucket, got %d", len(out))
|
|
||||||
}
|
|
||||||
if out[0].V == nil || *out[0].V != 7 {
|
|
||||||
t.Fatalf("expected bucket peak 7, got %v", out[0].V)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+26
-23
@@ -61,18 +61,28 @@ html[data-theme=light] .theme-toggle .ic-moon{display:block}
|
|||||||
|
|
||||||
/* === dashboard shell (stats page) ======================================= */
|
/* === dashboard shell (stats page) ======================================= */
|
||||||
.shell{min-height:100vh;display:grid;grid-template-rows:56px minmax(0,1fr)}
|
.shell{min-height:100vh;display:grid;grid-template-rows:56px minmax(0,1fr)}
|
||||||
.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)}
|
.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;min-width:0}
|
.brand{display:flex;align-items:center;gap:10px;font-weight:700;font-size:16px}
|
||||||
.brand svg{color:var(--accent);width:24px;height:24px;flex:0 0 auto}
|
.brand svg{color:var(--accent);width:24px;height:24px}
|
||||||
.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}
|
||||||
.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}
|
.header-online strong{color:var(--text);font-size:16px}
|
||||||
.top-actions{display:flex;align-items:center;gap:8px;justify-content:flex-end}
|
.top-actions{display:flex;align-items:center;gap:8px}
|
||||||
.route-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden;background:transparent}
|
.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>span{display:block;width:35%;height:100%;background:var(--accent);animation:route-progress 1.1s ease-in-out infinite}
|
||||||
.route-progress[hidden]{display:none}
|
.route-progress[hidden]{display:none}
|
||||||
@keyframes route-progress{0%{transform:translateX(-110%)}50%{transform:translateX(120%)}100%{transform:translateX(310%)}}
|
@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}
|
.icon-sprite{display:none}
|
||||||
|
|
||||||
.content{min-width:0;min-height:0;overflow:auto;padding:26px 28px 48px}
|
.content{min-width:0;min-height:0;overflow:auto;padding:26px 28px 48px}
|
||||||
@@ -92,20 +102,18 @@ 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{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{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 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}
|
.graphs{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
|
||||||
.graph-chart{display:block;width:100%;padding:0;border:0;background:var(--field);cursor:zoom-in}
|
.graph-image-button{display:block;width:100%;padding:0;border:0;background:var(--field);cursor:zoom-in;overflow:hidden}
|
||||||
.graph-chart canvas{display:block;width:100%;height:auto;aspect-ratio:3/1}
|
.graph-image-button img{display:block;width:100%;height:auto;aspect-ratio:3.56/1;object-fit:contain;transition:transform .18s ease,opacity .18s ease}
|
||||||
.graph-empty{margin:0;padding:58px 10px;text-align:center;font-size:12px}
|
.graph-image-button:hover img{transform:scale(1.012);opacity:.96}
|
||||||
.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[hidden]{display:none}
|
||||||
.lightbox{position:fixed;inset:0;z-index:1000;display:grid;place-items:center;padding:28px}
|
.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-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,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-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-title{margin:0 0 10px;padding-right:90px;font-size:15px;font-weight:650}
|
.lightbox-dialog img{display:block;width:100%;height:auto}
|
||||||
.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-close{position:absolute;top:10px;right:12px;display:inline-flex;gap:6px;align-items:center}
|
||||||
.lightbox-open{overflow:hidden}
|
.lightbox-open{overflow:hidden}
|
||||||
|
|
||||||
@@ -150,17 +158,12 @@ html[data-theme=light] .theme-toggle .ic-moon{display:block}
|
|||||||
@media(max-width:1060px){
|
@media(max-width:1060px){
|
||||||
.cards{grid-template-columns:repeat(2,minmax(140px,1fr))}
|
.cards{grid-template-columns:repeat(2,minmax(140px,1fr))}
|
||||||
.graphs{grid-template-columns:1fr}
|
.graphs{grid-template-columns:1fr}
|
||||||
.graph-chart canvas{aspect-ratio:auto;height:200px}
|
.graph-image-button img{aspect-ratio:auto;min-height:260px}
|
||||||
}
|
}
|
||||||
@media(max-width:860px){
|
@media(max-width:860px){
|
||||||
.topbar{grid-template-columns:minmax(64px,auto) 1fr auto}
|
.topbar{grid-template-columns:auto 1fr auto}
|
||||||
#refresh-status{display:none}
|
.body{grid-template-columns:1fr}
|
||||||
}
|
.sidebar{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){
|
@media(max-width:620px){
|
||||||
.cards{grid-template-columns:1fr}
|
.cards{grid-template-columns:1fr}
|
||||||
|
|||||||
+42
-267
@@ -5,23 +5,40 @@
|
|||||||
var THEME_KEY = 'ts3monolith-theme';
|
var THEME_KEY = 'ts3monolith-theme';
|
||||||
var root = document.documentElement;
|
var root = document.documentElement;
|
||||||
var toggle = document.querySelector('.theme-toggle');
|
var toggle = document.querySelector('.theme-toggle');
|
||||||
|
var graphs = document.querySelectorAll('.js-live-graph');
|
||||||
|
var openers = document.querySelectorAll('.js-graph-open');
|
||||||
|
|
||||||
function currentTheme() {
|
function currentTheme() {
|
||||||
return root.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
|
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) {
|
function applyTheme(mode) {
|
||||||
if (mode === 'light') root.setAttribute('data-theme', 'light');
|
if (mode === 'light') root.setAttribute('data-theme', 'light');
|
||||||
else root.removeAttribute('data-theme');
|
else root.removeAttribute('data-theme');
|
||||||
// Charts read their colors straight from the CSS custom properties at
|
applyGraphTheme(mode === 'light' ? 'light' : 'dark');
|
||||||
// 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 {
|
try {
|
||||||
var saved = window.localStorage.getItem(THEME_KEY);
|
var saved = window.localStorage.getItem(THEME_KEY);
|
||||||
if (saved) applyTheme(saved);
|
if (saved) applyTheme(saved);
|
||||||
} catch (e) { /* ignore */ }
|
else applyGraphTheme(currentTheme());
|
||||||
|
} catch (e) {
|
||||||
|
applyGraphTheme(currentTheme());
|
||||||
|
}
|
||||||
|
|
||||||
if (toggle) {
|
if (toggle) {
|
||||||
toggle.addEventListener('click', function () {
|
toggle.addEventListener('click', function () {
|
||||||
@@ -31,243 +48,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- RRD charts -----------------------------------------------------------
|
// --- live stats + graph refresh -----------------------------------------
|
||||||
// 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 <canvas>, 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 peakPoints = (data && data.peak_points) || [];
|
|
||||||
var values = [];
|
|
||||||
points.forEach(function (p) { if (p.v !== null && p.v !== undefined) values.push(p.v); });
|
|
||||||
peakPoints.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.15));
|
|
||||||
|
|
||||||
function xAtFor(series, i) {
|
|
||||||
return padL + (series.length > 1 ? (i / (series.length - 1)) * plotW : plotW / 2);
|
|
||||||
}
|
|
||||||
function xAt(i) {
|
|
||||||
return xAtFor(points, i);
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// For week/month/year the API also returns a MAX series. Draw it first
|
|
||||||
// as a subtle dashed line: the solid/filled line remains the smoothed
|
|
||||||
// average trend, while real historical peaks are still visible.
|
|
||||||
if (peakPoints.length > 1) {
|
|
||||||
ctx.save();
|
|
||||||
ctx.beginPath();
|
|
||||||
peakPoints.forEach(function (p, i) {
|
|
||||||
var v = (p.v === null || p.v === undefined) ? 0 : p.v;
|
|
||||||
var x = xAtFor(peakPoints, i);
|
|
||||||
var y = yAt(v);
|
|
||||||
if (i === 0) ctx.moveTo(x, y);
|
|
||||||
else ctx.lineTo(x, y);
|
|
||||||
});
|
|
||||||
ctx.lineJoin = 'round';
|
|
||||||
ctx.lineCap = 'round';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.strokeStyle = accent;
|
|
||||||
ctx.globalAlpha = 0.42;
|
|
||||||
ctx.setLineDash([4, 4]);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main average/trend line; missing samples are rendered as zero so the
|
|
||||||
// graph falls cleanly to the baseline instead of breaking into segments.
|
|
||||||
// Short windows represent a discrete user count, so draw them as steps:
|
|
||||||
// the value stays constant until the next sample instead of producing
|
|
||||||
// narrow triangular needles between 0 and 1/2 users.
|
|
||||||
var useSteps = data.key === 'hour' || data.key === 'day';
|
|
||||||
var seg = [];
|
|
||||||
|
|
||||||
function traceSeries(segment) {
|
|
||||||
ctx.moveTo(segment[0][0], segment[0][1]);
|
|
||||||
for (var i = 1; i < segment.length; i++) {
|
|
||||||
if (useSteps) {
|
|
||||||
ctx.lineTo(segment[i][0], segment[i - 1][1]);
|
|
||||||
}
|
|
||||||
ctx.lineTo(segment[i][0], segment[i][1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function flushSegment() {
|
|
||||||
if (seg.length < 2) {
|
|
||||||
seg = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(seg[0][0], padT + plotH);
|
|
||||||
ctx.lineTo(seg[0][0], seg[0][1]);
|
|
||||||
for (var i = 1; i < seg.length; i++) {
|
|
||||||
if (useSteps) {
|
|
||||||
ctx.lineTo(seg[i][0], seg[i - 1][1]);
|
|
||||||
}
|
|
||||||
ctx.lineTo(seg[i][0], seg[i][1]);
|
|
||||||
}
|
|
||||||
ctx.lineTo(seg[seg.length - 1][0], padT + plotH);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fillStyle = accent + '38';
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
traceSeries(seg);
|
|
||||||
ctx.lineJoin = 'round';
|
|
||||||
ctx.lineCap = 'round';
|
|
||||||
ctx.lineWidth = 1.6;
|
|
||||||
ctx.strokeStyle = accent;
|
|
||||||
ctx.stroke();
|
|
||||||
seg = [];
|
|
||||||
}
|
|
||||||
points.forEach(function (p, i) {
|
|
||||||
var v = (p.v === null || p.v === undefined) ? 0 : p.v;
|
|
||||||
seg.push([xAt(i), yAt(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 count = document.getElementById('online-count');
|
||||||
var status = document.getElementById('refresh-status');
|
var status = document.getElementById('refresh-status');
|
||||||
var progress = document.getElementById('route-progress');
|
var progress = document.getElementById('route-progress');
|
||||||
@@ -280,63 +61,57 @@
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
if (status) status.textContent = 'Odświeżanie…';
|
if (status) status.textContent = 'Odświeżanie…';
|
||||||
|
|
||||||
var tasks = [];
|
var done = Promise.resolve();
|
||||||
if (count) {
|
if (count) {
|
||||||
tasks.push(fetch('/stats/live', { cache: 'no-store' })
|
done = fetch('/stats/live', { cache: 'no-store' })
|
||||||
.then(function (response) {
|
.then(function (response) {
|
||||||
if (!response.ok) throw new Error('stats');
|
if (!response.ok) throw new Error('stats');
|
||||||
return response.json();
|
return response.json();
|
||||||
})
|
})
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
if (typeof data.current === 'number') count.textContent = String(data.current);
|
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());
|
|
||||||
|
|
||||||
Promise.all(tasks)
|
applyGraphTheme(currentTheme());
|
||||||
.then(function () { if (status) status.textContent = 'Dane aktualne'; })
|
|
||||||
.catch(function () { if (status) status.textContent = 'Nie udało się odświeżyć'; })
|
done.then(function () { setBusy(false); });
|
||||||
.then(function () { setBusy(false); });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count || chartPanels.length) {
|
if (count || graphs.length) {
|
||||||
window.setInterval(refresh, 60000);
|
window.setInterval(refresh, 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- graph lightbox -------------------------------------------------------
|
// --- graph lightbox -------------------------------------------------------
|
||||||
var lightbox = document.getElementById('graph-lightbox');
|
var lightbox = document.getElementById('graph-lightbox');
|
||||||
var lightboxCanvas = document.getElementById('lightbox-canvas');
|
var lightboxImage = document.getElementById('lightbox-image');
|
||||||
var lightboxTitle = document.getElementById('lightbox-title');
|
|
||||||
var lightboxStats = document.getElementById('lightbox-stats');
|
|
||||||
var openers = document.querySelectorAll('.js-graph-open');
|
var openers = document.querySelectorAll('.js-graph-open');
|
||||||
var closeButtons = document.querySelectorAll('[data-lightbox-close]');
|
var closeButtons = document.querySelectorAll('[data-lightbox-close]');
|
||||||
|
|
||||||
function openLightbox(range) {
|
function openLightbox(src) {
|
||||||
if (!lightbox || !lightboxCanvas) return;
|
if (!lightbox || !lightboxImage) return;
|
||||||
var data = chartData[range];
|
lightboxImage.src = src + (src.indexOf('?') === -1 ? '?' : '&') + 'v=' + Date.now();
|
||||||
lightbox.hidden = false;
|
lightbox.hidden = false;
|
||||||
lightbox.setAttribute('aria-hidden', 'false');
|
lightbox.setAttribute('aria-hidden', 'false');
|
||||||
document.body.classList.add('lightbox-open');
|
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() {
|
function closeLightbox() {
|
||||||
if (!lightbox) return;
|
if (!lightbox || !lightboxImage) return;
|
||||||
lightbox.hidden = true;
|
lightbox.hidden = true;
|
||||||
lightbox.setAttribute('aria-hidden', 'true');
|
lightbox.setAttribute('aria-hidden', 'true');
|
||||||
|
lightboxImage.src = '';
|
||||||
document.body.classList.remove('lightbox-open');
|
document.body.classList.remove('lightbox-open');
|
||||||
}
|
}
|
||||||
|
|
||||||
openers.forEach(function (button) {
|
openers.forEach(function (button) {
|
||||||
button.addEventListener('click', function () {
|
button.addEventListener('click', function () {
|
||||||
var panel = button.closest('.js-chart');
|
var src = button.getAttribute('data-full-active') || button.getAttribute('data-full');
|
||||||
if (panel) openLightbox(panel.dataset.range);
|
if (src) openLightbox(src);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+32
-20
@@ -5,13 +5,13 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"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 /{$}", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/stats", http.StatusFound) })
|
||||||
mux.HandleFunc("GET /stats", s.stats)
|
mux.HandleFunc("GET /stats", s.stats)
|
||||||
mux.HandleFunc("GET /stats/live", s.statsLive)
|
mux.HandleFunc("GET /stats/live", s.statsLive)
|
||||||
mux.HandleFunc("GET /stats/graph/{key}", s.graphData)
|
mux.HandleFunc("GET /graph/{file}", s.graph)
|
||||||
mux.HandleFunc("GET /channel", s.channel)
|
mux.HandleFunc("GET /channel", s.channel)
|
||||||
mux.HandleFunc("GET /embed/channel", s.channel)
|
mux.HandleFunc("GET /embed/channel", s.channel)
|
||||||
mux.HandleFunc("POST /channel/verify/start", s.verifyStart)
|
mux.HandleFunc("POST /channel/verify/start", s.verifyStart)
|
||||||
@@ -144,7 +144,7 @@ func (s *Server) security(next http.Handler) http.Handler {
|
|||||||
|
|
||||||
func (s *Server) stats(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) stats(w http.ResponseWriter, r *http.Request) {
|
||||||
ip := s.clientIP(r).String()
|
ip := s.clientIP(r).String()
|
||||||
if s.cfg.RateLimitEnabled && !s.limit.allow("stats|"+ip, s.cfg.RateLimitStats, time.Minute) {
|
if !s.limit.allow("stats|"+ip, 60, time.Minute) {
|
||||||
s.tooMany(w)
|
s.tooMany(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -173,7 +173,7 @@ func (s *Server) stats(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (s *Server) statsLive(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) statsLive(w http.ResponseWriter, r *http.Request) {
|
||||||
ip := s.clientIP(r).String()
|
ip := s.clientIP(r).String()
|
||||||
if s.cfg.RateLimitEnabled && !s.limit.allow("statslive|"+ip, s.cfg.RateLimitStatsLive, time.Minute) {
|
if !s.limit.allow("statslive|"+ip, 60, time.Minute) {
|
||||||
s.tooMany(w)
|
s.tooMany(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -250,26 +250,36 @@ func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = w.Write(data)
|
_, _ = w.Write(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) graphData(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) graph(w http.ResponseWriter, r *http.Request) {
|
||||||
ip := s.clientIP(r).String()
|
ip := s.clientIP(r).String()
|
||||||
// Each hit here is one rrdtool fetch returning a few hundred small
|
if !s.limit.allow("graph|"+ip, 120, time.Minute) {
|
||||||
// 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)
|
s.tooMany(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
data, err := s.rrd.Fetch(ctx, r.PathValue("key"))
|
file := r.PathValue("file")
|
||||||
if err != nil {
|
if !strings.HasSuffix(file, ".png") {
|
||||||
http.Error(w, "graph data unavailable", http.StatusServiceUnavailable)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
key := strings.TrimSuffix(file, ".png")
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
theme := "dark"
|
||||||
_ = json.NewEncoder(w).Encode(data)
|
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)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "graph unavailable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=60")
|
||||||
|
http.ServeFile(w, r, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) channel(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) channel(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -278,7 +288,7 @@ func (s *Server) channel(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "invalid client address", http.StatusBadRequest)
|
http.Error(w, "invalid client address", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.cfg.RateLimitEnabled && !s.limit.allow("channel|"+ip.String(), s.cfg.RateLimitChannel, time.Minute) {
|
if !s.limit.allow("channel|"+ip.String(), 40, time.Minute) {
|
||||||
s.tooMany(w)
|
s.tooMany(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -306,7 +316,7 @@ func (s *Server) verifyStart(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !s.requirePost(w, r, ip.String()) {
|
if !s.requirePost(w, r, ip.String()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.cfg.RateLimitEnabled && !s.limit.allow("verify|"+ip.String(), s.cfg.RateLimitVerify, time.Minute) {
|
if !s.limit.allow("verify|"+ip.String(), 10, time.Minute) {
|
||||||
s.tooMany(w)
|
s.tooMany(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -352,7 +362,7 @@ func (s *Server) verifyComplete(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !s.requirePost(w, r, ip.String()) {
|
if !s.requirePost(w, r, ip.String()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.cfg.RateLimitEnabled && !s.limit.allow("verifydone|"+ip.String(), s.cfg.RateLimitVerifyDone, time.Minute) {
|
if !s.limit.allow("verifydone|"+ip.String(), 10, time.Minute) {
|
||||||
s.tooMany(w)
|
s.tooMany(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -381,7 +391,7 @@ func (s *Server) createChannel(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !s.requirePost(w, r, ip.String()) {
|
if !s.requirePost(w, r, ip.String()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.cfg.RateLimitEnabled && !s.limit.allow("create|"+ip.String(), s.cfg.RateLimitCreate, time.Hour) {
|
if !s.limit.allow("create|"+ip.String(), 5, time.Hour) {
|
||||||
s.tooMany(w)
|
s.tooMany(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -562,3 +572,5 @@ func (s *Server) render(w http.ResponseWriter, name string, data any) {
|
|||||||
log.Printf("template error: %v", err)
|
log.Printf("template error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func EnsureGraphDir(path string) error { return os.MkdirAll(filepath.Clean(path), 0750) }
|
||||||
@@ -11,9 +11,9 @@
|
|||||||
{{template "icons" .}}
|
{{template "icons" .}}
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a class="brand" href="/stats" title="{{if .InfoAvailable}}{{.ServerName}}{{end}}">
|
<a class="brand" href="/stats">
|
||||||
<svg class="icon" width="24" height="24"><use href="#icon-headset"/></svg>
|
<svg class="icon" width="24" height="24"><use href="#icon-headset"/></svg>
|
||||||
<span>{{if .InfoAvailable}}{{.ServerName}}{{else}}Serwer TS3{{end}}</span>
|
<span>Panel serwera</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="header-online" aria-live="polite">
|
<div class="header-online" aria-live="polite">
|
||||||
<span class="badge ok"><span class="dot"></span>Online</span>
|
<span class="badge ok"><span class="dot"></span>Online</span>
|
||||||
@@ -21,8 +21,8 @@
|
|||||||
<span class="muted" id="refresh-status">Dane aktualne</span>
|
<span class="muted" id="refresh-status">Dane aktualne</span>
|
||||||
</div>
|
</div>
|
||||||
<nav class="top-actions" aria-label="Nawigacja">
|
<nav class="top-actions" aria-label="Nawigacja">
|
||||||
<a class="btn ghost" href="{{.VoiceURL}}"><svg class="icon"><use href="#icon-zap"/></svg><span class="btn-label">Połącz</span></a>
|
<a class="btn ghost" href="{{.VoiceURL}}"><svg class="icon"><use href="#icon-zap"/></svg>Połącz</a>
|
||||||
<a class="btn primary" href="/channel"><svg class="icon"><use href="#icon-plus-circle"/></svg><span class="btn-label">Utwórz kanał</span></a>
|
<a class="btn primary" href="/channel"><svg class="icon"><use href="#icon-plus-circle"/></svg>Utwórz kanał</a>
|
||||||
<button class="theme-toggle" type="button" aria-label="Przełącz jasny/ciemny motyw" title="Jasny / ciemny motyw">
|
<button class="theme-toggle" type="button" aria-label="Przełącz jasny/ciemny motyw" title="Jasny / ciemny motyw">
|
||||||
<svg class="icon ic-sun"><use href="#icon-sun"/></svg>
|
<svg class="icon ic-sun"><use href="#icon-sun"/></svg>
|
||||||
<svg class="icon ic-moon"><use href="#icon-moon"/></svg>
|
<svg class="icon ic-moon"><use href="#icon-moon"/></svg>
|
||||||
@@ -31,59 +31,72 @@
|
|||||||
<div class="route-progress" id="route-progress" hidden><span></span></div>
|
<div class="route-progress" id="route-progress" hidden><span></span></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="content">
|
<div class="body">
|
||||||
<section class="page-head">
|
<aside class="sidebar">
|
||||||
<div>
|
<nav class="nav">
|
||||||
<p class="eyebrow">Statystyki serwera</p>
|
<a class="active" href="/stats"><svg class="icon"><use href="#icon-activity"/></svg>Statystyki</a>
|
||||||
<h1>Aktywność TeamSpeak 3</h1>
|
<a href="/channel"><svg class="icon"><use href="#icon-plus-circle"/></svg>Utwórz kanał</a>
|
||||||
<p>Historia liczby użytkowników połączonych z serwerem.</p>
|
<a href="{{.VoiceURL}}"><svg class="icon"><use href="#icon-zap"/></svg>Połącz z TS3</a>
|
||||||
|
</nav>
|
||||||
|
<p class="nav-section">Serwer</p>
|
||||||
|
<div class="nav-server" title="{{if .InfoAvailable}}{{.ServerName}}{{end}}">
|
||||||
|
<svg class="icon"><use href="#icon-server"/></svg><span class="mono">{{if .InfoAvailable}}{{.ServerName}}{{else}}—{{end}}</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</aside>
|
||||||
|
|
||||||
<section class="cards" aria-label="Podsumowanie">
|
<main class="content">
|
||||||
<div class="card">
|
<section class="page-head">
|
||||||
<svg class="icon"><use href="#icon-users"/></svg>
|
<div>
|
||||||
<div><span class="k">Online teraz</span><div class="v">{{.Current}}{{if .InfoAvailable}}{{if gt .MaxClients 0}}<span class="vs muted"> / {{.MaxClients}}</span>{{end}}{{end}}</div></div>
|
<p class="eyebrow">Statystyki serwera</p>
|
||||||
</div>
|
<h1>Aktywność TeamSpeak 3</h1>
|
||||||
<div class="card">
|
<p>Historia liczby użytkowników połączonych z serwerem.</p>
|
||||||
<svg class="icon"><use href="#icon-clock"/></svg>
|
|
||||||
<div><span class="k">Uptime</span><div class="vs">{{if .InfoAvailable}}{{.Uptime}}{{else}}—{{end}}</div></div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<svg class="icon"><use href="#icon-server"/></svg>
|
|
||||||
<div><span class="k">Wersja serwera</span><div class="vs">{{if .InfoAvailable}}{{.ServerVersion}}{{else}}—{{end}}</div></div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<svg class="icon"><use href="#icon-sliders"/></svg>
|
|
||||||
<div><span class="k">Platforma</span><div class="vs">{{if .InfoAvailable}}{{.ServerPlatform}}{{else}}—{{end}}</div></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<p class="section-title">Użytkownicy online</p>
|
|
||||||
<section class="graphs" aria-label="Wykresy statystyk">
|
|
||||||
{{range .Ranges}}
|
|
||||||
<article class="panel js-chart" data-range="{{.Key}}">
|
|
||||||
<div class="panel-head">
|
|
||||||
<h2>{{.Title}}</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="graph-chart js-graph-open" type="button" aria-label="Powiększ wykres {{.Title}}">
|
</section>
|
||||||
<canvas class="js-chart-canvas" aria-hidden="true"></canvas>
|
|
||||||
<p class="graph-empty muted" hidden>Wczytywanie…</p>
|
<section class="cards" aria-label="Podsumowanie">
|
||||||
</button>
|
<div class="card">
|
||||||
<p class="graph-stats muted js-graph-stats"></p>
|
<svg class="icon"><use href="#icon-users"/></svg>
|
||||||
</article>
|
<div><span class="k">Online teraz</span><div class="v">{{.Current}}{{if .InfoAvailable}}{{if gt .MaxClients 0}}<span class="vs muted"> / {{.MaxClients}}</span>{{end}}{{end}}</div></div>
|
||||||
{{end}}
|
</div>
|
||||||
</section>
|
<div class="card">
|
||||||
</main>
|
<svg class="icon"><use href="#icon-clock"/></svg>
|
||||||
|
<div><span class="k">Uptime</span><div class="vs">{{if .InfoAvailable}}{{.Uptime}}{{else}}—{{end}}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<svg class="icon"><use href="#icon-server"/></svg>
|
||||||
|
<div><span class="k">Wersja serwera</span><div class="vs">{{if .InfoAvailable}}{{.ServerVersion}}{{else}}—{{end}}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<svg class="icon"><use href="#icon-sliders"/></svg>
|
||||||
|
<div><span class="k">Platforma</span><div class="vs">{{if .InfoAvailable}}{{.ServerPlatform}}{{else}}—{{end}}</div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="section-title">Wykresy obecności</p>
|
||||||
|
<section class="graphs" aria-label="Wykresy statystyk">
|
||||||
|
{{range .Ranges}}
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>{{.Title}}</h2>
|
||||||
|
<p>Liczba użytkowników online</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="graph-image-button js-graph-open" type="button" data-full="/graph/{{.Key}}.png" data-full-light="/graph/{{.Key}}-light.png" aria-label="Powiększ wykres {{.Title}}">
|
||||||
|
<img class="js-live-graph" loading="lazy" src="/graph/{{.Key}}.png" data-src="/graph/{{.Key}}.png" data-src-light="/graph/{{.Key}}-light.png" alt="{{.Title}}">
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lightbox" id="graph-lightbox" hidden aria-hidden="true">
|
<div class="lightbox" id="graph-lightbox" hidden aria-hidden="true">
|
||||||
<button class="lightbox-backdrop" type="button" data-lightbox-close aria-label="Zamknij podgląd"></button>
|
<button class="lightbox-backdrop" type="button" data-lightbox-close aria-label="Zamknij podgląd"></button>
|
||||||
<div class="lightbox-dialog" role="dialog" aria-modal="true" aria-label="Powiększony wykres">
|
<div class="lightbox-dialog" role="dialog" aria-modal="true" aria-label="Powiększony wykres">
|
||||||
<button class="btn ghost lightbox-close" type="button" data-lightbox-close><svg class="icon"><use href="#icon-x"/></svg>Zamknij</button>
|
<button class="btn ghost lightbox-close" type="button" data-lightbox-close><svg class="icon"><use href="#icon-x"/></svg>Zamknij</button>
|
||||||
<h2 class="lightbox-title" id="lightbox-title"></h2>
|
<img id="lightbox-image" src="" alt="Powiększony wykres">
|
||||||
<canvas id="lightbox-canvas" class="lightbox-canvas"></canvas>
|
|
||||||
<p class="graph-stats muted" id="lightbox-stats"></p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -25,21 +25,5 @@ TS_QUERY_TIMEOUT=3s
|
|||||||
# === RRD / statystyki =========================================================
|
# === RRD / statystyki =========================================================
|
||||||
RRDTOOL=rrdtool
|
RRDTOOL=rrdtool
|
||||||
RRD_PATH=/var/lib/ts3monolith/online.rrd
|
RRD_PATH=/var/lib/ts3monolith/online.rrd
|
||||||
|
GRAPH_DIR=/var/lib/ts3monolith/graphs
|
||||||
STATS_INTERVAL=60s
|
STATS_INTERVAL=60s
|
||||||
# Szczegolowy log kazdego cyklu zbierania statystyk (surowy odczyt z TS3 vs
|
|
||||||
# co faktycznie trafilo do RRD). Przydatne tylko przy diagnozowaniu -- zalewa
|
|
||||||
# journald jednym wpisem na STATS_INTERVAL, zostaw wylaczone na co dzien.
|
|
||||||
DEBUG=false
|
|
||||||
|
|
||||||
# === Rate limiting ============================================================
|
|
||||||
# Domyslnie wylaczone. Przy wlaczeniu limity sa per-IP (patrz README,
|
|
||||||
# sekcja "Rate limiting") -- wartosci nizej to sensowne domyslne, dostosuj
|
|
||||||
# do realnego ruchu zanim wlaczysz na produkcji.
|
|
||||||
RATE_LIMIT_ENABLED=false
|
|
||||||
RATE_LIMIT_STATS=60
|
|
||||||
RATE_LIMIT_STATS_LIVE=60
|
|
||||||
RATE_LIMIT_GRAPH=600
|
|
||||||
RATE_LIMIT_CHANNEL=40
|
|
||||||
RATE_LIMIT_VERIFY=10
|
|
||||||
RATE_LIMIT_VERIFY_DONE=10
|
|
||||||
RATE_LIMIT_CREATE=5
|
|
||||||
@@ -92,6 +92,7 @@ if ! getent passwd ts3monolith >/dev/null 2>&1; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
install -d -o ts3monolith -g ts3monolith -m 0750 /var/lib/ts3monolith
|
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
|
if [ -f /etc/default/ts3monolith ]; then
|
||||||
chown root:ts3monolith /etc/default/ts3monolith
|
chown root:ts3monolith /etc/default/ts3monolith
|
||||||
|
|||||||
Reference in new issue
Block a user