Files
cloud-services/services/cost/handlers/handlers.go
Chris Rai 3f7f2d559f cost: bump rates to match actual Azure billing (~$65k/mo)
- Increased CPU/memory rates and per-VIN resource estimates
- Added base infrastructure cost distribution across active VINs
- Updated README with projected costs for 5000 vehicles
- Image: fiskercloud.azurecr.io/cost:v6
2026-02-01 00:19:29 -05:00

270 lines
9.6 KiB
Go

package handlers
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/fiskerinc/cloud-services/pkg/logger"
"github.com/fiskerinc/cloud-services/services/cost/services"
)
// GetVinCost returns cost data for a specific VIN
// GET /cost/vin/{vin}?from=2024-01-01&to=2024-01-31
func GetVinCost(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract VIN from path
path := strings.TrimPrefix(r.URL.Path, "/cost/vin/")
vin := strings.TrimSuffix(path, "/")
if vin == "" {
http.Error(w, "VIN required", http.StatusBadRequest)
return
}
from, to := parseTimeRange(r)
summary, err := services.GetVinCostSummary(vin, from, to)
if err != nil {
logger.Error().Err(err).Str("vin", vin).Msg("Failed to get VIN cost")
http.Error(w, "Failed to get cost data", http.StatusInternalServerError)
return
}
respondJSON(w, summary)
}
// GetFleetCost returns fleet-wide cost summary
// GET /cost/fleet?from=2024-01-01&to=2024-01-31&limit=10
func GetFleetCost(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
from, to := parseTimeRange(r)
limit := 10
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil {
limit = parsed
}
}
summary, err := services.GetFleetCostSummary(from, to, limit)
if err != nil {
logger.Error().Err(err).Msg("Failed to get fleet cost")
http.Error(w, "Failed to get cost data", http.StatusInternalServerError)
return
}
respondJSON(w, summary)
}
// GetCostSummary returns a high-level cost summary
// GET /cost/summary?period=day|week|month
func GetCostSummary(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
period := r.URL.Query().Get("period")
if period == "" {
period = "day"
}
to := time.Now()
var from time.Time
switch period {
case "week":
from = to.AddDate(0, 0, -7)
case "month":
from = to.AddDate(0, -1, 0)
default:
from = to.AddDate(0, 0, -1)
}
summary, err := services.GetFleetCostSummary(from, to, 5)
if err != nil {
logger.Error().Err(err).Msg("Failed to get cost summary")
http.Error(w, "Failed to get cost data", http.StatusInternalServerError)
return
}
respondJSON(w, map[string]interface{}{
"period": period,
"summary": summary,
"cost_rates": map[string]interface{}{
"cloud": map[string]float64{
"cpu_per_core_hour": services.CloudCPUPerCoreHour,
"memory_per_gb_hour": services.CloudMemoryPerGBHour,
},
"onprem": map[string]float64{
"cpu_per_core_hour": services.OnpremCPUPerCoreHour,
"memory_per_gb_hour": services.OnpremMemoryPerGBHour,
},
},
})
}
// GetCostComparison returns cloud vs on-prem cost comparison
// GET /cost/comparison?from=2024-01-01&to=2024-01-31
func GetCostComparison(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
from, to := parseTimeRange(r)
summary, err := services.GetFleetCostSummary(from, to, 0)
if err != nil {
logger.Error().Err(err).Msg("Failed to get cost comparison")
http.Error(w, "Failed to get cost data", http.StatusInternalServerError)
return
}
comparison := map[string]interface{}{
"period": map[string]interface{}{
"from": from,
"to": to,
},
"cloud": map[string]interface{}{
"total_cost_usd": summary.TotalCloudCost,
"cost_per_vehicle": 0.0,
"description": "Azure cloud hosting with managed services",
},
"onprem": map[string]interface{}{
"total_cost_usd": summary.TotalOnpremCost,
"cost_per_vehicle": 0.0,
"description": "Self-hosted on owned hardware (amortized)",
},
"savings": map[string]interface{}{
"total_usd": summary.TotalSavings,
"percent": summary.SavingsPercent,
"annual_projected": summary.TotalSavings * 12,
},
"vehicle_count": summary.VehicleCount,
}
if summary.VehicleCount > 0 {
comparison["cloud"].(map[string]interface{})["cost_per_vehicle"] = summary.TotalCloudCost / float64(summary.VehicleCount)
comparison["onprem"].(map[string]interface{})["cost_per_vehicle"] = summary.TotalOnpremCost / float64(summary.VehicleCount)
}
respondJSON(w, comparison)
}
func parseTimeRange(r *http.Request) (from, to time.Time) {
to = time.Now()
from = to.AddDate(0, 0, -30) // Default to last 30 days
if fromStr := r.URL.Query().Get("from"); fromStr != "" {
if parsed, err := time.Parse("2006-01-02", fromStr); err == nil {
from = parsed
}
}
if toStr := r.URL.Query().Get("to"); toStr != "" {
if parsed, err := time.Parse("2006-01-02", toStr); err == nil {
to = parsed
}
}
return
}
func respondJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil {
logger.Error().Err(err).Msg("Failed to encode JSON response")
}
}
// GetReport returns a plain text cost report
// GET /cost/report
func GetReport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
to := time.Now()
from := to.AddDate(0, -1, 0) // Last month
summary, err := services.GetFleetCostSummary(from, to, 10)
if err != nil {
logger.Error().Err(err).Msg("Failed to get report data")
http.Error(w, "Failed to get report data", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/plain")
report := `
╔══════════════════════════════════════════════════════════════════╗
║ COST SERVICE REPORT ║
╠══════════════════════════════════════════════════════════════════╣
║ Period: %s to %s
╠══════════════════════════════════════════════════════════════════╣
║ FLEET OVERVIEW ║
║ ─────────────────────────────────────────────────────────────── ║
║ Active Vehicles: %d
║ Cloud Cost: $%.2f
║ On-Prem Cost: $%.2f
║ Savings: $%.2f (%.1f%%)
╠══════════════════════════════════════════════════════════════════╣
║ COST RATES ║
║ ─────────────────────────────────────────────────────────────── ║
║ Cloud: CPU $%.2f/core-hr Memory $%.3f/GB-hr
║ On-Prem: CPU $%.2f/core-hr Memory $%.3f/GB-hr
║ Base Infra: Cloud $%.2f/15min On-Prem $%.2f/15min
╠══════════════════════════════════════════════════════════════════╣
║ ANNUAL PROJECTION (based on current usage) ║
║ ─────────────────────────────────────────────────────────────── ║
║ Cloud Annual: $%.2f
║ On-Prem Annual: $%.2f
║ Annual Savings: $%.2f
╚══════════════════════════════════════════════════════════════════╝
`
annualCloud := summary.TotalCloudCost * 12
annualOnprem := summary.TotalOnpremCost * 12
annualSavings := annualCloud - annualOnprem
fmt.Fprintf(w, report,
from.Format("2006-01-02"), to.Format("2006-01-02"),
summary.VehicleCount,
summary.TotalCloudCost,
summary.TotalOnpremCost,
summary.TotalSavings, summary.SavingsPercent,
services.CloudCPUPerCoreHour, services.CloudMemoryPerGBHour,
services.OnpremCPUPerCoreHour, services.OnpremMemoryPerGBHour,
services.BaseInfraCloudCost, services.BaseInfraOnpremCost,
annualCloud, annualOnprem, annualSavings,
)
// Add top cost VINs if any
if len(summary.TopCostVins) > 0 {
fmt.Fprintf(w, "\nTOP COST VEHICLES:\n")
fmt.Fprintf(w, "%-20s %12s %12s %10s\n", "VIN", "Cloud $", "On-Prem $", "Savings %")
fmt.Fprintf(w, "%-20s %12s %12s %10s\n", "───────────────────", "──────────", "──────────", "────────")
for _, v := range summary.TopCostVins {
fmt.Fprintf(w, "%-20s %12.2f %12.2f %9.1f%%\n",
truncateVIN(v.VIN), v.TotalCloudCost, v.TotalOnpremCost, v.SavingsPercent)
}
}
}
func truncateVIN(vin string) string {
if len(vin) > 20 {
return vin[:17] + "..."
}
return vin
}