- Estimates cloud vs on-prem costs per active vehicle - Queries feature_table_last_shard from ClickHouse (lightweight) - 85% savings estimate with on-prem (hardware only) - Deployed to cec-prd-cluster-1 (internal only) - Text report endpoint at /cost/report
268 lines
9.5 KiB
Go
268 lines
9.5 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 $%.3f/core-hr Memory $%.4f/GB-hr
|
|
║ On-Prem: CPU $%.3f/core-hr Memory $%.4f/GB-hr
|
|
╠══════════════════════════════════════════════════════════════════╣
|
|
║ 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,
|
|
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
|
|
}
|