Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions scheduler/scheduler.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ type MinerScheduler struct {
// Web server
webServer *WebServer

// Database connection
db *sql.DB

// Logging
logger *log.Logger

Expand Down Expand Up @@ -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
}
}

Expand Down
86 changes: 86 additions & 0 deletions scheduler/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions web/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -187,6 +188,8 @@ function App() {

<MPCDecisions decisions={health?.scheduler.mpc_decisions} />

<MetricsSummary />

<section className="card system-info">
<h2>System Information</h2>
<div className="info-grid">
Expand Down
4 changes: 2 additions & 2 deletions web/src/components/MPCDecisions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export function MPCDecisions({ decisions }: MPCDecisionsProps) {
if (!decisions || decisions.length === 0) {
return (
<section className="card">
<h2>MPC Optimization</h2>
<h2>Model Predictive Control Optimization Results</h2>
<div className="info-grid">
<p>No optimization data available yet.</p>
</div>
Expand Down Expand Up @@ -130,7 +130,7 @@ export function MPCDecisions({ decisions }: MPCDecisionsProps) {

return (
<section className="card">
<h2>MPC Optimization Results</h2>
<h2>Model Predictive Control Optimization Results</h2>
<div className="mpc-summary">
<div className="mpc-summary-item">
<span className="mpc-summary-label">Decisions:</span>
Expand Down
213 changes: 213 additions & 0 deletions web/src/components/MetricsSummary.tsx
Original file line number Diff line number Diff line change
@@ -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<MetricsSummaryType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState<Date>(() => {
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 (
<section className="card">
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "1rem",
}}
>
<h2 style={{ margin: 0 }}>
Actual Costs - {formatDateDisplay(selectedDate)}
</h2>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<button
onClick={() => handleDateNavigation(-1)}
style={{
padding: "0.25rem 0.5rem",
cursor: "pointer",
border: "1px solid var(--color-border)",
borderRadius: "4px",
backgroundColor: "var(--color-bg-secondary)",
color: "var(--color-text)",
}}
title="Previous day"
>
←
</button>
<button
onClick={() => handleDateNavigation(null)}
disabled={isToday()}
style={{
padding: "0.25rem 0.75rem",
cursor: isToday() ? "not-allowed" : "pointer",
border: "1px solid var(--color-border)",
borderRadius: "4px",
backgroundColor: isToday()
? "var(--color-bg)"
: "var(--color-bg-secondary)",
color: isToday()
? "var(--color-text-secondary)"
: "var(--color-text)",
opacity: isToday() ? 0.5 : 1,
}}
title="Today"
>
Today
</button>
<button
onClick={() => handleDateNavigation(1)}
disabled={isToday()}
style={{
padding: "0.25rem 0.5rem",
cursor: isToday() ? "not-allowed" : "pointer",
border: "1px solid var(--color-border)",
borderRadius: "4px",
backgroundColor: isToday()
? "var(--color-bg)"
: "var(--color-bg-secondary)",
color: isToday()
? "var(--color-text-secondary)"
: "var(--color-text)",
opacity: isToday() ? 0.5 : 1,
}}
title="Next day"
>
→
</button>
</div>
</div>

<div className="mpc-summary">
{loading && (
<div className="mpc-summary-item">
<span className="mpc-summary-label">Loading...</span>
</div>
)}
{error && (
<div className="mpc-summary-item">
<span
className="mpc-summary-label"
style={{ color: "var(--color-error)" }}
>
{error}
</span>
</div>
)}
{metricsSummary && !loading && (
<>
<div className="mpc-summary-item">
<span className="mpc-summary-label">Import Cost:</span>
<span className="mpc-summary-value value-error">
€{metricsSummary.total_import_cost.toFixed(2)} (
{metricsSummary.total_import_kwh.toFixed(2)} kWh)
</span>
</div>
<div className="mpc-summary-item">
<span className="mpc-summary-label">Export Revenue:</span>
<span className="mpc-summary-value value-success">
€{metricsSummary.total_export_cost.toFixed(2)} (
{metricsSummary.total_export_kwh.toFixed(2)} kWh)
</span>
</div>
<div className="mpc-summary-item">
<span className="mpc-summary-label">
{netActualCost !== null && netActualCost <= 0
? "Net Revenue:"
: "Net Cost:"}
</span>
<span
className={`mpc-summary-value ${netActualCost !== null && netActualCost <= 0 ? "value-success" : "value-error"}`}
>
{netActualCost !== null
? `€${Math.abs(netActualCost).toFixed(2)}`
: "N/A"}
</span>
</div>
</>
)}
</div>
</section>
);
}
Loading