From ff8c1525587f77ad7dafe1a1b00ad12254cae87f Mon Sep 17 00:00:00 2001 From: Aleksei Larkov Date: Fri, 2 Jan 2026 13:13:34 +0200 Subject: [PATCH] feat: add mertics summary component to show costs --- scheduler/scheduler.go | 5 + scheduler/server.go | 86 +++++++++++ web/src/App.css | 8 + web/src/App.tsx | 3 + web/src/components/MPCDecisions.tsx | 4 +- web/src/components/MetricsSummary.tsx | 213 ++++++++++++++++++++++++++ web/src/types/api.ts | 9 ++ 7 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 web/src/components/MetricsSummary.tsx diff --git a/scheduler/scheduler.go b/scheduler/scheduler.go index a424701..3ecd103 100644 --- a/scheduler/scheduler.go +++ b/scheduler/scheduler.go @@ -86,6 +86,9 @@ type MinerScheduler struct { // Web server webServer *WebServer + // Database connection + db *sql.DB + // Logging logger *log.Logger @@ -194,6 +197,8 @@ func (s *MinerScheduler) Start(ctx context.Context, serverOnly bool) error { if dataDBErr != nil { s.logger.Printf("Data integration: failed to connect to DB: %v", dataDBErr) dataDB = nil + } else { + s.db = dataDB } } diff --git a/scheduler/server.go b/scheduler/server.go index 70063ae..f41cc72 100644 --- a/scheduler/server.go +++ b/scheduler/server.go @@ -94,6 +94,16 @@ type SunInfo struct { Sunset string `json:"sunset"` } +// MetricsSummary represents aggregated metrics data +type MetricsSummary struct { + TotalImportCost float64 `json:"total_import_cost"` + TotalExportCost float64 `json:"total_export_cost"` + TotalImportKWh float64 `json:"total_import_kwh"` + TotalExportKWh float64 `json:"total_export_kwh"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` +} + // NewWebServer creates a new web server with health endpoints and static file serving func NewWebServer(scheduler *MinerScheduler, port int) *WebServer { if port <= 0 { @@ -127,6 +137,7 @@ func NewWebServer(scheduler *MinerScheduler, port int) *WebServer { mux.HandleFunc("/api/health", hs.healthHandler) mux.HandleFunc("/api/ready", hs.readinessHandler) mux.HandleFunc("/api/ws", hs.wsHandler) + mux.HandleFunc("/api/metrics/summary", hs.metricsSummaryHandler) // Serve static files from web folder fs := http.FileServer(http.Dir("./web/dist")) @@ -263,6 +274,81 @@ func (hs *WebServer) readinessHandler(w http.ResponseWriter, r *http.Request) { } } +// metricsSummaryHandler handles the /api/metrics/summary endpoint +func (hs *WebServer) metricsSummaryHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse query parameters for time range + startTimeStr := r.URL.Query().Get("start_time") + endTimeStr := r.URL.Query().Get("end_time") + + var startTime, endTime time.Time + var err error + + if startTimeStr != "" && endTimeStr != "" { + // Use provided time range + startTime, err = time.Parse(time.RFC3339, startTimeStr) + if err != nil { + http.Error(w, "Invalid start_time format. Use RFC3339 format", http.StatusBadRequest) + return + } + endTime, err = time.Parse(time.RFC3339, endTimeStr) + if err != nil { + http.Error(w, "Invalid end_time format. Use RFC3339 format", http.StatusBadRequest) + return + } + } else { + // Default to yesterday (calendar past date - midnight to midnight) + now := time.Now() + yesterday := now.AddDate(0, 0, -1) + startTime = time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 0, 0, 0, 0, yesterday.Location()) + endTime = time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 23, 59, 59, 999999999, yesterday.Location()) + } + + // Query the database for aggregated metrics + db := hs.scheduler.db + if db == nil { + http.Error(w, "Database not available", http.StatusServiceUnavailable) + return + } + + fmt.Println("Fetching data from", startTime, endTime) + + var summary MetricsSummary + err = db.QueryRow(` + SELECT + COALESCE(SUM(grid_import_cost), 0) as total_import_cost, + COALESCE(SUM(grid_export_cost), 0) as total_export_cost, + COALESCE(SUM(grid_import_power), 0) as total_import_kwh, + COALESCE(SUM(grid_export_power), 0) as total_export_kwh + FROM metrics + WHERE timestamp >= $1 AND timestamp <= $2 + AND metric_name = 'energy_flow' + `, startTime, endTime).Scan( + &summary.TotalImportCost, + &summary.TotalExportCost, + &summary.TotalImportKWh, + &summary.TotalExportKWh, + ) + + if err != nil { + fmt.Printf("Failed to query metrics: %v\n", err) + http.Error(w, "Failed to query metrics", http.StatusInternalServerError) + return + } + + summary.StartTime = startTime.Format(time.RFC3339) + summary.EndTime = endTime.Format(time.RFC3339) + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(summary); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + // wsHandler handles WebSocket connections func (hs *WebServer) wsHandler(w http.ResponseWriter, r *http.Request) { conn, err := hs.upgrader.Upgrade(w, r, nil) diff --git a/web/src/App.css b/web/src/App.css index b297d17..2251491 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -375,6 +375,14 @@ body { color: var(--color-text); } +.mpc-summary-value.value-success { + color: var(--color-success); +} + +.mpc-summary-value.value-error { + color: var(--color-error); +} + .mpc-table-container { overflow-x: auto; border-radius: 6px; diff --git a/web/src/App.tsx b/web/src/App.tsx index ba50b5a..613c186 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import { StatusBadge } from "./components/StatusBadge"; import { PowerDisplay } from "./components/PowerDisplay"; import { SolarInfo } from "./components/SolarInfo"; import { MPCDecisions } from "./components/MPCDecisions"; +import { MetricsSummary } from "./components/MetricsSummary"; import { useWebSocket } from "./hooks/useWebSocket"; function App() { @@ -187,6 +188,8 @@ function App() { + +

System Information

diff --git a/web/src/components/MPCDecisions.tsx b/web/src/components/MPCDecisions.tsx index 22964f4..733f8d3 100644 --- a/web/src/components/MPCDecisions.tsx +++ b/web/src/components/MPCDecisions.tsx @@ -83,7 +83,7 @@ export function MPCDecisions({ decisions }: MPCDecisionsProps) { if (!decisions || decisions.length === 0) { return (
-

MPC Optimization

+

Model Predictive Control Optimization Results

No optimization data available yet.

@@ -130,7 +130,7 @@ export function MPCDecisions({ decisions }: MPCDecisionsProps) { return (
-

MPC Optimization Results

+

Model Predictive Control Optimization Results

Decisions: diff --git a/web/src/components/MetricsSummary.tsx b/web/src/components/MetricsSummary.tsx new file mode 100644 index 0000000..fa7e2f7 --- /dev/null +++ b/web/src/components/MetricsSummary.tsx @@ -0,0 +1,213 @@ +import { useEffect, useState, useRef } from "react"; +import { MetricsSummary as MetricsSummaryType } from "../types/api"; +import "../App.css"; + +export function MetricsSummary() { + const [metricsSummary, setMetricsSummary] = + useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedDate, setSelectedDate] = useState(() => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + return today; + }); + const isFetchingRef = useRef(false); + + useEffect(() => { + const fetchMetricsSummary = async () => { + // Prevent duplicate fetches + if (isFetchingRef.current) { + console.log("Already fetching metrics, skipping..."); + return; + } + + isFetchingRef.current = true; + + try { + setLoading(true); + + // Calculate time range for selected date (calendar day - midnight to midnight) + const startTime = selectedDate.toISOString(); + const endDate = new Date(selectedDate); + endDate.setHours(23, 59, 59, 999); + const endTime = endDate.toISOString(); + + const url = `/api/metrics/summary?start_time=${encodeURIComponent(startTime)}&end_time=${encodeURIComponent(endTime)}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data: MetricsSummaryType = await response.json(); + setMetricsSummary(data); + setError(null); + } catch (error) { + console.error("Failed to fetch metrics summary:", error); + setError("Failed to load actual costs data"); + } finally { + setLoading(false); + isFetchingRef.current = false; + } + }; + + fetchMetricsSummary(); + }, [selectedDate]); + + const handleDateNavigation = (dayShift: number | null) => { + if (dayShift === null) { + // Navigate to today + const today = new Date(); + today.setHours(0, 0, 0, 0); + setSelectedDate(today); + } else { + // Navigate by day shift (positive or negative) + setSelectedDate((prev) => { + const newDate = new Date(prev); + newDate.setDate(newDate.getDate() + dayShift); + return newDate; + }); + } + }; + + const formatDateDisplay = (date: Date): string => { + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + }; + + const isToday = () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + return selectedDate.getTime() === today.getTime(); + }; + + // Calculate net cost from metrics (import cost is negative, export cost is positive revenue) + const netActualCost = metricsSummary + ? metricsSummary.total_import_cost - metricsSummary.total_export_cost + : null; + + return ( +
+
+

+ Actual Costs - {formatDateDisplay(selectedDate)} +

+
+ + + +
+
+ +
+ {loading && ( +
+ Loading... +
+ )} + {error && ( +
+ + {error} + +
+ )} + {metricsSummary && !loading && ( + <> +
+ Import Cost: + + €{metricsSummary.total_import_cost.toFixed(2)} ( + {metricsSummary.total_import_kwh.toFixed(2)} kWh) + +
+
+ Export Revenue: + + €{metricsSummary.total_export_cost.toFixed(2)} ( + {metricsSummary.total_export_kwh.toFixed(2)} kWh) + +
+
+ + {netActualCost !== null && netActualCost <= 0 + ? "Net Revenue:" + : "Net Cost:"} + + + {netActualCost !== null + ? `€${Math.abs(netActualCost).toFixed(2)}` + : "N/A"} + +
+ + )} +
+
+ ); +} diff --git a/web/src/types/api.ts b/web/src/types/api.ts index f52f727..1188155 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -73,6 +73,15 @@ export interface StatusResponse { timestamp: string; } +export interface MetricsSummary { + total_import_cost: number; + total_export_cost: number; + total_import_kwh: number; + total_export_kwh: number; + start_time: string; + end_time: string; +} + export interface WebSocketMessage { type: string; health: HealthResponse;