package rrd import ( "bytes" "context" "errors" "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" "sync" "time" ) type Manager struct { Tool string Path string GraphDir 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 } 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"}, } func (m *Manager) Validate() error { if _, err := exec.LookPath(m.Tool); err != nil { return fmt.Errorf("rrdtool not found: %w", err) } 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) } func (m *Manager) Update(ctx context.Context, online int) error { if online < 0 { return errors.New("online count cannot be negative") } cmd := exec.CommandContext(ctx, m.Tool, "update", m.Path, "N:"+strconv.Itoa(online)) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return fmt.Errorf("rrd update: %w: %s", err, strings.TrimSpace(stderr.String())) } return nil } func (m *Manager) Last(ctx context.Context) (int, error) { cmd := exec.CommandContext(ctx, m.Tool, "lastupdate", m.Path) out, err := cmd.Output() if err != nil { return 0, err } lines := strings.Split(strings.TrimSpace(string(out)), "\n") if len(lines) < 2 { return 0, errors.New("unexpected rrd lastupdate output") } fields := strings.Fields(lines[len(lines)-1]) if len(fields) < 2 { return 0, errors.New("unexpected rrd lastupdate data") } v := fields[len(fields)-1] if v == "U" { return 0, nil } f, err := strconv.ParseFloat(v, 64) 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 } } if rg == nil { return "", errors.New("unknown graph range") } if err := os.MkdirAll(m.GraphDir, 0750); err != nil { return "", 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...) 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())) } return out, nil }