Initial commit — energy dashboard frontend (TimescaleDB + Vue/Chart.js)
This commit is contained in:
113
internal/api/auth.go
Normal file
113
internal/api/auth.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"energy-frontend/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
cookieName = "energy_session"
|
||||
cookieMaxAge = 7 * 24 * 3600 // 7 days
|
||||
cookiePath = "/energy"
|
||||
)
|
||||
|
||||
type authHandler struct {
|
||||
cfg config.AuthConfig
|
||||
}
|
||||
|
||||
func newAuthHandler(cfg config.AuthConfig) *authHandler {
|
||||
return &authHandler{cfg: cfg}
|
||||
}
|
||||
|
||||
func (h *authHandler) loginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(req.Username), []byte(h.cfg.Username)) != 1 ||
|
||||
subtle.ConstantTimeCompare([]byte(req.Password), []byte(h.cfg.Password)) != 1 {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, h.createSessionCookie(req.Username))
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *authHandler) logoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: "",
|
||||
Path: cookiePath,
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *authHandler) checkHandler(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *authHandler) middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(cookieName)
|
||||
if err != nil || !h.validateSession(cookie.Value) {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *authHandler) createSessionCookie(username string) *http.Cookie {
|
||||
expires := time.Now().Add(time.Duration(cookieMaxAge) * time.Second)
|
||||
payload := fmt.Sprintf("%s|%d", username, expires.Unix())
|
||||
sig := h.sign(payload)
|
||||
return &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: fmt.Sprintf("%s|%s", payload, sig),
|
||||
Path: cookiePath,
|
||||
MaxAge: cookieMaxAge,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *authHandler) validateSession(value string) bool {
|
||||
parts := strings.SplitN(value, "|", 3)
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
}
|
||||
payload := parts[0] + "|" + parts[1]
|
||||
if !hmac.Equal([]byte(h.sign(payload)), []byte(parts[2])) {
|
||||
return false
|
||||
}
|
||||
exp, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return time.Now().Unix() < exp
|
||||
}
|
||||
|
||||
func (h *authHandler) sign(payload string) string {
|
||||
mac := hmac.New(sha256.New, []byte(h.cfg.Secret))
|
||||
mac.Write([]byte(payload))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
260
internal/api/energy.go
Normal file
260
internal/api/energy.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type EnergyBar struct {
|
||||
Time time.Time `json:"time"`
|
||||
Buy float64 `json:"buy"`
|
||||
Sell float64 `json:"sell"`
|
||||
Produce float64 `json:"produce"`
|
||||
Consume float64 `json:"consume"`
|
||||
}
|
||||
|
||||
type PowerPoint struct {
|
||||
Time time.Time `json:"time"`
|
||||
PV float64 `json:"pv"`
|
||||
Barn float64 `json:"barn"`
|
||||
House float64 `json:"house"`
|
||||
}
|
||||
|
||||
type BatteryPoint struct {
|
||||
Time time.Time `json:"time"`
|
||||
Level float64 `json:"level"`
|
||||
}
|
||||
|
||||
var periodDefaults = map[string]int{
|
||||
"day": 30,
|
||||
"week": 12,
|
||||
"month": 12,
|
||||
}
|
||||
|
||||
// viewForRange picks the right continuous aggregate views for the given time range.
|
||||
func viewForRange(timeRange string) (invView, meterView, interval string) {
|
||||
switch timeRange {
|
||||
case "1h":
|
||||
return "inverter_10m", "power_meter_10m", "1 hour"
|
||||
case "6h":
|
||||
return "inverter_10m", "power_meter_10m", "6 hours"
|
||||
case "7d":
|
||||
return "inverter_1h", "power_meter_1h", "7 days"
|
||||
default: // "24h" and anything else
|
||||
return "inverter_10m", "power_meter_10m", "24 hours"
|
||||
}
|
||||
}
|
||||
|
||||
func sortedUnion(maps ...map[time.Time]float64) []time.Time {
|
||||
seen := make(map[time.Time]bool)
|
||||
for _, m := range maps {
|
||||
for t := range m {
|
||||
seen[t] = true
|
||||
}
|
||||
}
|
||||
times := make([]time.Time, 0, len(seen))
|
||||
for t := range seen {
|
||||
times = append(times, t)
|
||||
}
|
||||
sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) })
|
||||
return times
|
||||
}
|
||||
|
||||
func getPower(ctx context.Context, pool *pgxpool.Pool, timeRange string) ([]PowerPoint, error) {
|
||||
invView, meterView, interval := viewForRange(timeRange)
|
||||
|
||||
type colRow struct {
|
||||
bucket time.Time
|
||||
value float64
|
||||
}
|
||||
queryCol := func(sql string) (map[time.Time]float64, error) {
|
||||
rows, err := pool.Query(ctx, sql)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
m := make(map[time.Time]float64)
|
||||
for rows.Next() {
|
||||
var r colRow
|
||||
if err := rows.Scan(&r.bucket, &r.value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m[r.bucket] = r.value
|
||||
}
|
||||
return m, rows.Err()
|
||||
}
|
||||
|
||||
pvSQL := fmt.Sprintf(
|
||||
`SELECT bucket, COALESCE(pv_power, 0) FROM %s WHERE bucket >= NOW() - '%s'::interval ORDER BY bucket`,
|
||||
invView, interval)
|
||||
houseSQL := fmt.Sprintf(
|
||||
`SELECT bucket, COALESCE(total_power, 0) FROM %s WHERE device = 'house' AND bucket >= NOW() - '%s'::interval ORDER BY bucket`,
|
||||
meterView, interval)
|
||||
barnSQL := fmt.Sprintf(
|
||||
`SELECT bucket, COALESCE(total_power, 0) FROM %s WHERE device = 'barn' AND bucket >= NOW() - '%s'::interval ORDER BY bucket`,
|
||||
meterView, interval)
|
||||
|
||||
type result struct {
|
||||
m map[time.Time]float64
|
||||
err error
|
||||
}
|
||||
pvCh := make(chan result, 1)
|
||||
houseCh := make(chan result, 1)
|
||||
barnCh := make(chan result, 1)
|
||||
|
||||
go func() { m, e := queryCol(pvSQL); pvCh <- result{m, e} }()
|
||||
go func() { m, e := queryCol(houseSQL); houseCh <- result{m, e} }()
|
||||
go func() { m, e := queryCol(barnSQL); barnCh <- result{m, e} }()
|
||||
|
||||
pvRes := <-pvCh
|
||||
houseRes := <-houseCh
|
||||
barnRes := <-barnCh
|
||||
|
||||
if pvRes.err != nil {
|
||||
return nil, pvRes.err
|
||||
}
|
||||
if houseRes.err != nil {
|
||||
return nil, houseRes.err
|
||||
}
|
||||
if barnRes.err != nil {
|
||||
return nil, barnRes.err
|
||||
}
|
||||
|
||||
times := sortedUnion(pvRes.m, houseRes.m, barnRes.m)
|
||||
pts := make([]PowerPoint, 0, len(times))
|
||||
for _, t := range times {
|
||||
pts = append(pts, PowerPoint{
|
||||
Time: t,
|
||||
PV: pvRes.m[t],
|
||||
House: houseRes.m[t],
|
||||
Barn: barnRes.m[t],
|
||||
})
|
||||
}
|
||||
return pts, nil
|
||||
}
|
||||
|
||||
func getBattery(ctx context.Context, pool *pgxpool.Pool, timeRange string) ([]BatteryPoint, error) {
|
||||
invView, _, interval := viewForRange(timeRange)
|
||||
|
||||
sql := fmt.Sprintf(
|
||||
`SELECT bucket, COALESCE(battery_soc, 0) FROM %s WHERE bucket >= NOW() - '%s'::interval ORDER BY bucket`,
|
||||
invView, interval)
|
||||
|
||||
rows, err := pool.Query(ctx, sql)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var pts []BatteryPoint
|
||||
for rows.Next() {
|
||||
var p BatteryPoint
|
||||
if err := rows.Scan(&p.Time, &p.Level); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pts = append(pts, p)
|
||||
}
|
||||
return pts, rows.Err()
|
||||
}
|
||||
|
||||
func getBars(ctx context.Context, pool *pgxpool.Pool, period string, count int) ([]EnergyBar, error) {
|
||||
var periodTrunc, rangeInterval string
|
||||
switch period {
|
||||
case "day":
|
||||
periodTrunc = "day"
|
||||
rangeInterval = fmt.Sprintf("%d days", count+1)
|
||||
case "week":
|
||||
periodTrunc = "week"
|
||||
rangeInterval = fmt.Sprintf("%d weeks", count+1)
|
||||
case "month":
|
||||
periodTrunc = "month"
|
||||
rangeInterval = fmt.Sprintf("%d months", count+1)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown period %q", period)
|
||||
}
|
||||
|
||||
// DISTINCT ON picks the last-written daily row for each period bucket,
|
||||
// then LAG() computes the delta (energy consumed/produced/traded) over
|
||||
// that period. Fetching count+1 rows ensures LAG has a previous value
|
||||
// for the first bar; we trim to count at the end.
|
||||
sql := fmt.Sprintf(`
|
||||
WITH inv_last AS (
|
||||
SELECT DISTINCT ON (date_trunc('%[1]s', bucket))
|
||||
date_trunc('%[1]s', bucket) AS period,
|
||||
grid_import_kwh, grid_export_kwh, pv_energy_kwh
|
||||
FROM inverter_daily
|
||||
WHERE bucket >= NOW() - '%[2]s'::interval
|
||||
ORDER BY date_trunc('%[1]s', bucket), bucket DESC
|
||||
),
|
||||
house_last AS (
|
||||
SELECT DISTINCT ON (date_trunc('%[1]s', bucket))
|
||||
date_trunc('%[1]s', bucket) AS period,
|
||||
import_kwh
|
||||
FROM power_meter_daily
|
||||
WHERE device = 'house' AND bucket >= NOW() - '%[2]s'::interval
|
||||
ORDER BY date_trunc('%[1]s', bucket), bucket DESC
|
||||
),
|
||||
barn_last AS (
|
||||
SELECT DISTINCT ON (date_trunc('%[1]s', bucket))
|
||||
date_trunc('%[1]s', bucket) AS period,
|
||||
import_kwh
|
||||
FROM power_meter_daily
|
||||
WHERE device = 'barn' AND bucket >= NOW() - '%[2]s'::interval
|
||||
ORDER BY date_trunc('%[1]s', bucket), bucket DESC
|
||||
),
|
||||
joined AS (
|
||||
SELECT
|
||||
i.period,
|
||||
i.grid_import_kwh,
|
||||
LAG(i.grid_import_kwh) OVER (ORDER BY i.period) AS prev_import,
|
||||
i.grid_export_kwh,
|
||||
LAG(i.grid_export_kwh) OVER (ORDER BY i.period) AS prev_export,
|
||||
i.pv_energy_kwh,
|
||||
LAG(i.pv_energy_kwh) OVER (ORDER BY i.period) AS prev_pv,
|
||||
h.import_kwh AS house_kwh,
|
||||
LAG(h.import_kwh) OVER (ORDER BY i.period) AS prev_house,
|
||||
b.import_kwh AS barn_kwh,
|
||||
LAG(b.import_kwh) OVER (ORDER BY i.period) AS prev_barn
|
||||
FROM inv_last i
|
||||
LEFT JOIN house_last h ON h.period = i.period
|
||||
LEFT JOIN barn_last b ON b.period = i.period
|
||||
)
|
||||
SELECT
|
||||
period,
|
||||
GREATEST(0, grid_import_kwh - prev_import) AS buy,
|
||||
GREATEST(0, grid_export_kwh - prev_export) AS sell,
|
||||
GREATEST(0, pv_energy_kwh - prev_pv) AS produce,
|
||||
GREATEST(0, COALESCE(house_kwh - prev_house, 0) + COALESCE(barn_kwh - prev_barn, 0)) AS consume
|
||||
FROM joined
|
||||
WHERE prev_import IS NOT NULL
|
||||
ORDER BY period
|
||||
`, periodTrunc, rangeInterval)
|
||||
|
||||
rows, err := pool.Query(ctx, sql)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var bars []EnergyBar
|
||||
for rows.Next() {
|
||||
var b EnergyBar
|
||||
if err := rows.Scan(&b.Time, &b.Buy, &b.Sell, &b.Produce, &b.Consume); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bars = append(bars, b)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Trim to the requested count (the LAG window requires one extra row)
|
||||
if len(bars) > count {
|
||||
bars = bars[len(bars)-count:]
|
||||
}
|
||||
return bars, nil
|
||||
}
|
||||
115
internal/api/router.go
Normal file
115
internal/api/router.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"energy-frontend/internal/config"
|
||||
)
|
||||
|
||||
func NewRouter(cfg config.Config, frontendFS fs.FS) http.Handler {
|
||||
pool, err := pgxpool.New(context.Background(), cfg.DB.DSN)
|
||||
if err != nil {
|
||||
log.Fatalf("db pool: %v", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
auth := newAuthHandler(cfg.Auth)
|
||||
|
||||
mux.HandleFunc("POST /api/login", auth.loginHandler)
|
||||
mux.HandleFunc("POST /api/logout", auth.logoutHandler)
|
||||
|
||||
protected := http.NewServeMux()
|
||||
protected.HandleFunc("GET /api/auth/check", auth.checkHandler)
|
||||
protected.HandleFunc("GET /api/bars", func(w http.ResponseWriter, r *http.Request) {
|
||||
period := r.URL.Query().Get("period")
|
||||
if period == "" {
|
||||
period = "day"
|
||||
}
|
||||
count, err := strconv.Atoi(r.URL.Query().Get("count"))
|
||||
if err != nil || count <= 0 {
|
||||
count = periodDefaults[period]
|
||||
}
|
||||
bars, err := getBars(r.Context(), pool, period, count)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"bars": bars})
|
||||
})
|
||||
protected.HandleFunc("GET /api/power", func(w http.ResponseWriter, r *http.Request) {
|
||||
tr := r.URL.Query().Get("range")
|
||||
if tr == "" {
|
||||
tr = "24h"
|
||||
}
|
||||
pts, err := getPower(r.Context(), pool, tr)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"points": pts})
|
||||
})
|
||||
protected.HandleFunc("GET /api/battery", func(w http.ResponseWriter, r *http.Request) {
|
||||
tr := r.URL.Query().Get("range")
|
||||
if tr == "" {
|
||||
tr = "24h"
|
||||
}
|
||||
pts, err := getBattery(r.Context(), pool, tr)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"points": pts})
|
||||
})
|
||||
|
||||
mux.Handle("/api/", auth.middleware(protected))
|
||||
|
||||
if frontendFS != nil {
|
||||
mux.Handle("/", spaHandler(frontendFS))
|
||||
}
|
||||
|
||||
return corsMiddleware(mux)
|
||||
}
|
||||
|
||||
func spaHandler(frontendFS fs.FS) http.Handler {
|
||||
fileServer := http.FileServer(http.FS(frontendFS))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if path == "" {
|
||||
path = "index.html"
|
||||
}
|
||||
if _, err := fs.Stat(frontendFS, path); err == nil {
|
||||
fileServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
r.URL.Path = "/"
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
Reference in New Issue
Block a user