nicer plot
This commit is contained in:
1 parent
624ff1da42
commit
1275e5a821
2 files changed
+72
-14
No files matched your search
@@ -25,6 +25,8 @@ type store struct {
|
||||
history []sample
|
||||
}
|
||||
|
||||
const maxHistoryPoints = 10000
|
||||
|
||||
type sample struct {
|
||||
at time.Time
|
||||
value int
|
||||
@@ -42,6 +44,11 @@ func (s *store) set(v int) {
|
||||
now := time.Now()
|
||||
s.updatedAt = now
|
||||
s.history = append(s.history, sample{at: now, value: v})
|
||||
s.trimHistoryLocked(now)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *store) trimHistoryLocked(now time.Time) {
|
||||
cutoff := now.Add(-12 * time.Hour)
|
||||
keep := s.history[:0]
|
||||
for _, point := range s.history {
|
||||
@@ -49,8 +56,10 @@ func (s *store) set(v int) {
|
||||
keep = append(keep, point)
|
||||
}
|
||||
}
|
||||
if len(keep) > maxHistoryPoints {
|
||||
keep = keep[len(keep)-maxHistoryPoints:]
|
||||
}
|
||||
s.history = keep
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *store) snapshot() (int, time.Time, []sample) {
|
||||
@@ -63,6 +72,7 @@ func (s *store) snapshot() (int, time.Time, []sample) {
|
||||
func main() {
|
||||
secret := os.Getenv("WRITE_SECRET")
|
||||
addr := os.Getenv("ADDR")
|
||||
chartMax := 0
|
||||
if cfg, err := loadConfig("config.json"); err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
log.Fatalf("load config: %v", err)
|
||||
@@ -74,6 +84,9 @@ func main() {
|
||||
if addr == "" {
|
||||
addr = cfg.Addr
|
||||
}
|
||||
if cfg.ChartMaxWatts > 0 {
|
||||
chartMax = cfg.ChartMaxWatts
|
||||
}
|
||||
}
|
||||
if secret == "" {
|
||||
log.Fatal("WRITE_SECRET is required")
|
||||
@@ -83,8 +96,9 @@ func main() {
|
||||
}
|
||||
|
||||
app := &server{
|
||||
secret: secret,
|
||||
store: &store{},
|
||||
secret: secret,
|
||||
chartMax: chartMax,
|
||||
store: &store{},
|
||||
}
|
||||
|
||||
log.Printf("listening on %s", addr)
|
||||
@@ -92,13 +106,15 @@ func main() {
|
||||
}
|
||||
|
||||
type server struct {
|
||||
secret string
|
||||
store *store
|
||||
secret string
|
||||
chartMax int
|
||||
store *store
|
||||
}
|
||||
|
||||
type config struct {
|
||||
WriteSecret string `json:"write_secret"`
|
||||
Addr string `json:"addr"`
|
||||
WriteSecret string `json:"write_secret"`
|
||||
Addr string `json:"addr"`
|
||||
ChartMaxWatts int `json:"chart_max_watts"`
|
||||
}
|
||||
|
||||
func loadConfig(path string) (config, error) {
|
||||
@@ -143,17 +159,17 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if err := page.Execute(w, map[string]any{
|
||||
"Watts": watts,
|
||||
"UpdatedAt": updatedLabel,
|
||||
"HistoryChart": buildHistoryChart(history),
|
||||
"HistoryChart": buildHistoryChart(history, s.chartMax),
|
||||
}); err != nil {
|
||||
http.Error(w, "render error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func buildHistoryChart(history []sample) template.HTML {
|
||||
func buildHistoryChart(history []sample, maxWatts int) template.HTML {
|
||||
const (
|
||||
width = 640
|
||||
height = 220
|
||||
padL = 52
|
||||
padL = 72
|
||||
padR = 18
|
||||
padT = 18
|
||||
padB = 42
|
||||
@@ -164,10 +180,13 @@ func buildHistoryChart(history []sample) template.HTML {
|
||||
}
|
||||
|
||||
minV, maxV := 0, history[0].value
|
||||
if maxWatts > 0 {
|
||||
maxV = maxWatts
|
||||
}
|
||||
start := history[0].at
|
||||
end := history[len(history)-1].at
|
||||
for _, point := range history[1:] {
|
||||
if point.value > maxV {
|
||||
if maxWatts <= 0 && point.value > maxV {
|
||||
maxV = point.value
|
||||
}
|
||||
if point.at.Before(start) {
|
||||
@@ -193,6 +212,12 @@ func buildHistoryChart(history []sample) template.HTML {
|
||||
for _, point := range history {
|
||||
x := float64(padL) + plotW*point.at.Sub(start).Seconds()/span.Seconds()
|
||||
y := float64(height-padB) - plotH*(float64(point.value-minV)/float64(maxV-minV))
|
||||
if y < float64(padT) {
|
||||
y = float64(padT)
|
||||
}
|
||||
if y > float64(height-padB) {
|
||||
y = float64(height - padB)
|
||||
}
|
||||
points = append(points, fmt.Sprintf("%.1f,%.1f", x, y))
|
||||
lastX, lastY = x, y
|
||||
}
|
||||
|
||||
+36
-3
@@ -66,7 +66,7 @@ func TestHandleWriteStoresValue(t *testing.T) {
|
||||
|
||||
func TestLoadConfig(t *testing.T) {
|
||||
path := t.TempDir() + "/config.json"
|
||||
if err := os.WriteFile(path, []byte(`{"write_secret":"secret","addr":":9090"}`), 0o600); err != nil {
|
||||
if err := os.WriteFile(path, []byte(`{"write_secret":"secret","addr":":9090","chart_max_watts":750}`), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
@@ -80,10 +80,13 @@ func TestLoadConfig(t *testing.T) {
|
||||
if cfg.Addr != ":9090" {
|
||||
t.Fatalf("addr = %q, want %q", cfg.Addr, ":9090")
|
||||
}
|
||||
if cfg.ChartMaxWatts != 750 {
|
||||
t.Fatalf("chart_max_watts = %d, want %d", cfg.ChartMaxWatts, 750)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHistoryChartEmpty(t *testing.T) {
|
||||
chart := buildHistoryChart(nil)
|
||||
chart := buildHistoryChart(nil, 0)
|
||||
if !strings.Contains(string(chart), "No power history yet") {
|
||||
t.Fatalf("empty chart did not contain fallback text: %s", string(chart))
|
||||
}
|
||||
@@ -96,7 +99,7 @@ func TestBuildHistoryChartRendersPolyline(t *testing.T) {
|
||||
{at: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC), value: 150},
|
||||
}
|
||||
|
||||
chart := buildHistoryChart(history)
|
||||
chart := buildHistoryChart(history, 0)
|
||||
if !strings.Contains(string(chart), "<polyline") {
|
||||
t.Fatalf("chart did not contain polyline: %s", string(chart))
|
||||
}
|
||||
@@ -109,3 +112,33 @@ func TestBuildHistoryChartRendersPolyline(t *testing.T) {
|
||||
t.Fatalf("chart did not include zero baseline: %s", string(chart))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHistoryChartUsesConfiguredMax(t *testing.T) {
|
||||
history := []sample{{at: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC), value: 250}}
|
||||
chart := buildHistoryChart(history, 1000)
|
||||
if !strings.Contains(string(chart), "1000 W") {
|
||||
t.Fatalf("chart did not include configured max: %s", string(chart))
|
||||
}
|
||||
if strings.Contains(string(chart), "250 W") {
|
||||
t.Fatalf("chart unexpectedly used sample max instead of configured max: %s", string(chart))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreTrimsHistoryToMaxPoints(t *testing.T) {
|
||||
s := &store{}
|
||||
base := time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC)
|
||||
s.mu.Lock()
|
||||
for i := 0; i < maxHistoryPoints+25; i++ {
|
||||
s.history = append(s.history, sample{at: base.Add(time.Duration(i) * time.Second), value: i})
|
||||
}
|
||||
s.trimHistoryLocked(base.Add(time.Duration(maxHistoryPoints+25) * time.Second))
|
||||
got := len(s.history)
|
||||
s.mu.Unlock()
|
||||
|
||||
if got != maxHistoryPoints {
|
||||
t.Fatalf("history length = %d, want %d", got, maxHistoryPoints)
|
||||
}
|
||||
if s.history[0].value != 25 {
|
||||
t.Fatalf("oldest retained value = %d, want %d", s.history[0].value, 25)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user