Skip to content

Commit a2dd3c1

Browse files
committed
Charts
1 parent 92cfdd4 commit a2dd3c1

File tree

4 files changed

+330
-5
lines changed

4 files changed

+330
-5
lines changed

internal/metrics/metrics.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/http"
88
"strconv"
99
"strings"
10+
"sync"
1011
"time"
1112
)
1213

@@ -22,6 +23,26 @@ type MetricsData struct {
2223
OSName string `json:"os_name"`
2324
}
2425

26+
// DataPoint represents a single metric data point with timestamp
27+
type DataPoint struct {
28+
Timestamp time.Time `json:"timestamp"`
29+
Value float64 `json:"value"`
30+
}
31+
32+
// HistoricalMetrics represents historical data for a single metric type
33+
type HistoricalMetrics struct {
34+
CPU []DataPoint `json:"cpu"`
35+
Memory []DataPoint `json:"memory"`
36+
Disk []DataPoint `json:"disk"`
37+
}
38+
39+
// HistoricalStore manages historical data for all nodes
40+
type HistoricalStore struct {
41+
data map[string]*HistoricalMetrics // key is node name
42+
mutex sync.RWMutex
43+
maxPoints int
44+
}
45+
2546
// NodeMetrics represents metrics for a single node
2647
type NodeMetrics struct {
2748
Name string `json:"name"`
@@ -309,3 +330,81 @@ func roundToDecimal(value float64, decimals int) float64 {
309330
}
310331
return float64(int(value*multiplier+0.5)) / multiplier
311332
}
333+
334+
// NewHistoricalStore creates a new historical data store
335+
func NewHistoricalStore(maxPoints int) *HistoricalStore {
336+
return &HistoricalStore{
337+
data: make(map[string]*HistoricalMetrics),
338+
maxPoints: maxPoints,
339+
}
340+
}
341+
342+
// AddDataPoint adds a new data point for a specific node
343+
func (hs *HistoricalStore) AddDataPoint(nodeName string, metricsData *MetricsData) {
344+
hs.mutex.Lock()
345+
defer hs.mutex.Unlock()
346+
347+
// Initialize historical metrics for new nodes
348+
if hs.data[nodeName] == nil {
349+
hs.data[nodeName] = &HistoricalMetrics{
350+
CPU: make([]DataPoint, 0),
351+
Memory: make([]DataPoint, 0),
352+
Disk: make([]DataPoint, 0),
353+
}
354+
}
355+
356+
now := time.Now()
357+
historical := hs.data[nodeName]
358+
359+
// Add new data points
360+
historical.CPU = append(historical.CPU, DataPoint{Timestamp: now, Value: metricsData.CPU})
361+
historical.Memory = append(historical.Memory, DataPoint{Timestamp: now, Value: metricsData.Memory})
362+
historical.Disk = append(historical.Disk, DataPoint{Timestamp: now, Value: metricsData.Disk})
363+
364+
// Trim to max points if necessary
365+
if len(historical.CPU) > hs.maxPoints {
366+
historical.CPU = historical.CPU[len(historical.CPU)-hs.maxPoints:]
367+
}
368+
if len(historical.Memory) > hs.maxPoints {
369+
historical.Memory = historical.Memory[len(historical.Memory)-hs.maxPoints:]
370+
}
371+
if len(historical.Disk) > hs.maxPoints {
372+
historical.Disk = historical.Disk[len(historical.Disk)-hs.maxPoints:]
373+
}
374+
}
375+
376+
// GetHistoricalData returns historical data for a specific node
377+
func (hs *HistoricalStore) GetHistoricalData(nodeName string) *HistoricalMetrics {
378+
hs.mutex.RLock()
379+
defer hs.mutex.RUnlock()
380+
381+
if data, exists := hs.data[nodeName]; exists {
382+
// Return a copy to avoid race conditions
383+
return &HistoricalMetrics{
384+
CPU: append([]DataPoint(nil), data.CPU...),
385+
Memory: append([]DataPoint(nil), data.Memory...),
386+
Disk: append([]DataPoint(nil), data.Disk...),
387+
}
388+
}
389+
return &HistoricalMetrics{
390+
CPU: make([]DataPoint, 0),
391+
Memory: make([]DataPoint, 0),
392+
Disk: make([]DataPoint, 0),
393+
}
394+
}
395+
396+
// GetAllHistoricalData returns historical data for all nodes
397+
func (hs *HistoricalStore) GetAllHistoricalData() map[string]*HistoricalMetrics {
398+
hs.mutex.RLock()
399+
defer hs.mutex.RUnlock()
400+
401+
result := make(map[string]*HistoricalMetrics)
402+
for nodeName, data := range hs.data {
403+
result[nodeName] = &HistoricalMetrics{
404+
CPU: append([]DataPoint(nil), data.CPU...),
405+
Memory: append([]DataPoint(nil), data.Memory...),
406+
Disk: append([]DataPoint(nil), data.Disk...),
407+
}
408+
}
409+
return result
410+
}

internal/server/charts.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package server
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/webishdev/node_dashboard/internal/metrics"
8+
)
9+
10+
// ChartConfig holds configuration for SVG chart generation
11+
type ChartConfig struct {
12+
Width int
13+
Height int
14+
Color string
15+
}
16+
17+
// GenerateSVGLineChart creates an inline SVG line chart from data points
18+
func GenerateSVGLineChart(dataPoints []metrics.DataPoint, config ChartConfig) string {
19+
if len(dataPoints) == 0 {
20+
return generateEmptyChart(config)
21+
}
22+
23+
// Find min and max values for scaling
24+
minVal, maxVal := findMinMax(dataPoints)
25+
26+
// Add some padding to the range
27+
valueRange := maxVal - minVal
28+
if valueRange == 0 {
29+
valueRange = 1 // Avoid division by zero
30+
}
31+
32+
// Generate SVG path
33+
pathData := generatePathData(dataPoints, config, minVal, maxVal)
34+
35+
// Create the complete SVG
36+
svg := fmt.Sprintf(`<svg width="%d" height="%d" viewBox="0 0 %d %d" xmlns="http://www.w3.org/2000/svg">
37+
<defs>
38+
<linearGradient id="gradient-%s" x1="0%%" y1="0%%" x2="0%%" y2="100%%">
39+
<stop offset="0%%" style="stop-color:%s;stop-opacity:0.3" />
40+
<stop offset="100%%" style="stop-color:%s;stop-opacity:0.1" />
41+
</linearGradient>
42+
</defs>
43+
<rect width="100%%" height="100%%" fill="none"/>
44+
<path d="%s" stroke="%s" stroke-width="2" fill="none"/>
45+
<path d="%s" fill="url(#gradient-%s)" opacity="0.3"/>
46+
</svg>`,
47+
config.Width, config.Height, config.Width, config.Height,
48+
strings.ReplaceAll(config.Color, "#", ""), config.Color, config.Color,
49+
pathData, config.Color,
50+
generateAreaPath(dataPoints, config, minVal, maxVal), strings.ReplaceAll(config.Color, "#", ""))
51+
52+
return svg
53+
}
54+
55+
// generateEmptyChart creates an empty chart when no data is available
56+
func generateEmptyChart(config ChartConfig) string {
57+
return fmt.Sprintf(`<svg width="%d" height="%d" viewBox="0 0 %d %d" xmlns="http://www.w3.org/2000/svg">
58+
<rect width="100%%" height="100%%" fill="none"/>
59+
<text x="50%%" y="50%%" text-anchor="middle" fill="#9CA3AF" font-size="12">No data</text>
60+
</svg>`, config.Width, config.Height, config.Width, config.Height)
61+
}
62+
63+
// findMinMax finds the minimum and maximum values in the data points
64+
func findMinMax(dataPoints []metrics.DataPoint) (float64, float64) {
65+
if len(dataPoints) == 0 {
66+
return 0, 100
67+
}
68+
69+
min := dataPoints[0].Value
70+
max := dataPoints[0].Value
71+
72+
for _, point := range dataPoints {
73+
if point.Value < min {
74+
min = point.Value
75+
}
76+
if point.Value > max {
77+
max = point.Value
78+
}
79+
}
80+
81+
// Ensure we have a reasonable range for percentage values
82+
if min > 0 {
83+
min = 0
84+
}
85+
if max < 100 {
86+
max = 100
87+
}
88+
89+
return min, max
90+
}
91+
92+
// generatePathData creates the SVG path data for the line chart
93+
func generatePathData(dataPoints []metrics.DataPoint, config ChartConfig, minVal, maxVal float64) string {
94+
if len(dataPoints) == 0 {
95+
return ""
96+
}
97+
98+
var pathParts []string
99+
valueRange := maxVal - minVal
100+
if valueRange == 0 {
101+
valueRange = 1
102+
}
103+
104+
for i, point := range dataPoints {
105+
x := float64(i) * float64(config.Width-1) / float64(len(dataPoints)-1)
106+
y := float64(config.Height-1) - ((point.Value-minVal)/valueRange)*float64(config.Height-1)
107+
108+
if i == 0 {
109+
pathParts = append(pathParts, fmt.Sprintf("M %.2f %.2f", x, y))
110+
} else {
111+
pathParts = append(pathParts, fmt.Sprintf("L %.2f %.2f", x, y))
112+
}
113+
}
114+
115+
return strings.Join(pathParts, " ")
116+
}
117+
118+
// generateAreaPath creates the SVG path data for the filled area under the line
119+
func generateAreaPath(dataPoints []metrics.DataPoint, config ChartConfig, minVal, maxVal float64) string {
120+
if len(dataPoints) == 0 {
121+
return ""
122+
}
123+
124+
var pathParts []string
125+
valueRange := maxVal - minVal
126+
if valueRange == 0 {
127+
valueRange = 1
128+
}
129+
130+
// Start from bottom left
131+
pathParts = append(pathParts, fmt.Sprintf("M 0 %d", config.Height))
132+
133+
// Draw line to first point
134+
firstY := float64(config.Height-1) - ((dataPoints[0].Value-minVal)/valueRange)*float64(config.Height-1)
135+
pathParts = append(pathParts, fmt.Sprintf("L 0 %.2f", firstY))
136+
137+
// Draw the line
138+
for i, point := range dataPoints {
139+
x := float64(i) * float64(config.Width-1) / float64(len(dataPoints)-1)
140+
y := float64(config.Height-1) - ((point.Value-minVal)/valueRange)*float64(config.Height-1)
141+
pathParts = append(pathParts, fmt.Sprintf("L %.2f %.2f", x, y))
142+
}
143+
144+
// Close the path at bottom right
145+
lastX := float64(len(dataPoints)-1) * float64(config.Width-1) / float64(len(dataPoints)-1)
146+
pathParts = append(pathParts, fmt.Sprintf("L %.2f %d", lastX, config.Height))
147+
pathParts = append(pathParts, "Z")
148+
149+
return strings.Join(pathParts, " ")
150+
}
151+
152+
// GetChartConfigs returns predefined chart configurations for different metrics
153+
func GetChartConfigs() map[string]ChartConfig {
154+
return map[string]ChartConfig{
155+
"cpu": {
156+
Width: 200,
157+
Height: 60,
158+
Color: "#3B82F6", // blue-500
159+
},
160+
"memory": {
161+
Width: 200,
162+
Height: 60,
163+
Color: "#10B981", // green-500
164+
},
165+
"disk": {
166+
Width: 200,
167+
Height: 60,
168+
Color: "#8B5CF6", // purple-500
169+
},
170+
}
171+
}

internal/server/server.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,24 @@ type DashboardServer struct {
3636
authPassword string
3737
cachedMetrics *metrics.MultiNodeMetrics
3838
metricsMutex sync.RWMutex
39+
historicalStore *metrics.HistoricalStore
3940
}
4041

4142
// New creates a new dashboard server instance
4243
func New(port int, nodeConfigs []metrics.NodeConfig, updateFrequency int, dataFetchFrequency int, enableAuth bool, authUser, authPassword string) Server {
43-
tmpl, err := template.New("dashboard").Parse(templateContent)
44+
// Create template with custom functions
45+
funcMap := template.FuncMap{
46+
"generateChart": generateChartForTemplate,
47+
}
48+
49+
tmpl, err := template.New("dashboard").Funcs(funcMap).Parse(templateContent)
4450
if err != nil {
4551
panic(fmt.Sprintf("Failed to parse template: %v", err))
4652
}
4753

54+
// Store up to 100 historical data points (about 50 minutes at 30s intervals)
55+
maxHistoricalPoints := 100
56+
4857
return &DashboardServer{
4958
port: port,
5059
nodeConfigs: nodeConfigs,
@@ -55,9 +64,22 @@ func New(port int, nodeConfigs []metrics.NodeConfig, updateFrequency int, dataFe
5564
enableAuth: enableAuth,
5665
authUser: authUser,
5766
authPassword: authPassword,
67+
historicalStore: metrics.NewHistoricalStore(maxHistoricalPoints),
5868
}
5969
}
6070

71+
// generateChartForTemplate is a template function that generates SVG charts
72+
func generateChartForTemplate(dataPoints []metrics.DataPoint, metricType string) template.HTML {
73+
configs := GetChartConfigs()
74+
config, exists := configs[metricType]
75+
if !exists {
76+
config = ChartConfig{Width: 200, Height: 60, Color: "#6B7280"}
77+
}
78+
79+
svg := GenerateSVGLineChart(dataPoints, config)
80+
return template.HTML(svg)
81+
}
82+
6183
// startBackgroundDataFetching starts a goroutine that periodically fetches metrics
6284
func (s *DashboardServer) startBackgroundDataFetching() {
6385
// Fetch initial data
@@ -80,6 +102,13 @@ func (s *DashboardServer) fetchAndCacheMetrics() {
80102
return
81103
}
82104

105+
// Store historical data points for each node
106+
for _, node := range metrics.Nodes {
107+
if node.Data != nil {
108+
s.historicalStore.AddDataPoint(node.Name, node.Data)
109+
}
110+
}
111+
83112
s.metricsMutex.Lock()
84113
s.cachedMetrics = metrics
85114
s.metricsMutex.Unlock()
@@ -164,12 +193,17 @@ func (s *DashboardServer) handleDashboard(w http.ResponseWriter, r *http.Request
164193
// Get cached metrics data
165194
cachedMetrics := s.getCachedMetrics()
166195

196+
// Get historical data for all nodes
197+
historicalData := s.historicalStore.GetAllHistoricalData()
198+
167199
templateData := struct {
168200
UpdateFrequency int
169201
Metrics *metrics.MultiNodeMetrics
202+
HistoricalData map[string]*metrics.HistoricalMetrics
170203
}{
171204
UpdateFrequency: s.updateFrequency,
172205
Metrics: cachedMetrics,
206+
HistoricalData: historicalData,
173207
}
174208

175209
if err := s.template.Execute(w, templateData); err != nil {

0 commit comments

Comments
 (0)