Files
2026-06-16 21:52:10 +04:00

200 lines
4.7 KiB
Go

package history
import (
"bytes"
"fmt"
"time"
chart "github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
// ChartOptions controls rendering of the node network PNG.
type ChartOptions struct {
Title string
Subtitle string
Width int
Height int
Percentile float64
}
// RenderNodeNetworkPNG returns a PNG of the node-wide Rx+Tx time series with
// the requested percentile drawn as a horizontal annotation line.
func RenderNodeNetworkPNG(points []NodeSamplePoint, opts ChartOptions) ([]byte, error) {
if opts.Width <= 0 {
opts.Width = 1280
}
if opts.Height <= 0 {
opts.Height = 640
}
if opts.Percentile <= 0 {
opts.Percentile = 95
}
if opts.Title == "" {
opts.Title = "Node network usage"
}
if len(points) < 2 {
return renderEmptyChart(opts, "insufficient history — need at least 2 samples")
}
xs := make([]time.Time, 0, len(points))
rxs := make([]float64, 0, len(points))
txs := make([]float64, 0, len(points))
totals := make([]float64, 0, len(points))
maxVal := 0.0
for _, p := range points {
xs = append(xs, p.Timestamp)
rxs = append(rxs, p.RxMbps)
txs = append(txs, p.TxMbps)
totals = append(totals, p.TotalMbps)
if p.TotalMbps > maxVal {
maxVal = p.TotalMbps
}
}
pct := PercentileMbps(points, opts.Percentile)
yMax := maxVal * 1.15
if pct*1.10 > yMax {
yMax = pct * 1.10
}
if yMax <= 0 {
yMax = 1
}
percentileSeries := chart.ContinuousSeries{
Name: fmt.Sprintf("P%.0f = %.1f Mbps", opts.Percentile, pct),
Style: chart.Style{
StrokeColor: drawing.ColorFromHex("e74c3c"),
StrokeWidth: 2.0,
StrokeDashArray: []float64{6, 4},
},
XValues: []float64{chart.TimeToFloat64(xs[0]), chart.TimeToFloat64(xs[len(xs)-1])},
YValues: []float64{pct, pct},
}
graph := chart.Chart{
Title: opts.Title,
TitleStyle: chart.Style{
FontSize: 16,
},
Width: opts.Width,
Height: opts.Height,
Background: chart.Style{
Padding: chart.Box{Top: 60, Left: 40, Right: 40, Bottom: 40},
FillColor: drawing.Color{
R: 0xf8, G: 0xf9, B: 0xfa, A: 0xff,
},
},
XAxis: chart.XAxis{
Style: chart.Style{FontSize: 9},
ValueFormatter: chart.TimeValueFormatterWithFormat("15:04:05\n02 Jan"),
},
YAxis: chart.YAxis{
Name: "Mbps",
Style: chart.Style{FontSize: 9},
Range: &chart.ContinuousRange{Min: 0, Max: yMax},
ValueFormatter: func(v any) string {
if f, ok := v.(float64); ok {
return formatMbps(f)
}
return ""
},
},
Series: []chart.Series{
chart.TimeSeries{
Name: "Total Rx+Tx",
Style: chart.Style{
StrokeColor: drawing.ColorFromHex("2d7dd2"),
StrokeWidth: 2.0,
FillColor: drawing.ColorFromHex("2d7dd2").WithAlpha(50),
},
XValues: xs,
YValues: totals,
},
chart.TimeSeries{
Name: "Rx",
Style: chart.Style{
StrokeColor: drawing.ColorFromHex("3cb371"),
StrokeWidth: 1.5,
},
XValues: xs,
YValues: rxs,
},
chart.TimeSeries{
Name: "Tx",
Style: chart.Style{
StrokeColor: drawing.ColorFromHex("ffa500"),
StrokeWidth: 1.5,
},
XValues: xs,
YValues: txs,
},
percentileSeries,
},
}
if opts.Subtitle != "" {
graph.Elements = []chart.Renderable{subtitleRenderable(opts.Subtitle)}
}
buf := &bytes.Buffer{}
if err := graph.Render(chart.PNG, buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func subtitleRenderable(text string) chart.Renderable {
return func(r chart.Renderer, cb chart.Box, chartDefaults chart.Style) {
r.SetFont(chartDefaults.GetFont())
r.SetFontColor(drawing.Color{R: 90, G: 90, B: 90, A: 0xff})
r.SetFontSize(10)
r.Text(text, cb.Left+10, cb.Top+30)
}
}
func renderEmptyChart(opts ChartOptions, note string) ([]byte, error) {
graph := chart.Chart{
Title: opts.Title,
Width: opts.Width,
Height: opts.Height,
Background: chart.Style{
Padding: chart.Box{Top: 60, Left: 40, Right: 40, Bottom: 40},
},
Series: []chart.Series{
chart.ContinuousSeries{
XValues: []float64{0, 1},
YValues: []float64{0, 0},
Style: chart.Style{
StrokeColor: drawing.ColorTransparent,
},
},
},
}
graph.Elements = []chart.Renderable{
func(r chart.Renderer, cb chart.Box, cs chart.Style) {
r.SetFont(cs.GetFont())
r.SetFontColor(drawing.Color{R: 120, G: 120, B: 120, A: 0xff})
r.SetFontSize(14)
r.Text(note, cb.Left+20, cb.Top+cb.Height()/2)
},
}
buf := &bytes.Buffer{}
if err := graph.Render(chart.PNG, buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func formatMbps(v float64) string {
switch {
case v >= 1000:
return fmt.Sprintf("%.2f Gbps", v/1000)
case v >= 1:
return fmt.Sprintf("%.1f Mbps", v)
default:
return fmt.Sprintf("%.0f Kbps", v*1000)
}
}