Files
cloud-services/services/cost/handlers/handlers.go
Chris Rai c48ae896a4 cost service: add platform base + per-VIN resource model with CPU/RAM display
- Updated cost model to show: (Platform Base) + (Per-VIN × VINs)
- Platform base: 176 cores / 896GB RAM (Kafka, ClickHouse, MongoDB, Redis, PostgreSQL, gateway, monitoring)
- Per-VIN marginal: 50mc / 82MB per vehicle
- Added RESOURCE USAGE MODEL and COST FORMULA sections to report
- Added CPU (mc) and RAM (MB) columns to TOP COST VEHICLES table
- Updated README with new report output
- virtual-vehicle: documented Vault cert TTL error troubleshooting
2026-02-04 21:18:24 -05:00

288 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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%%)
╠══════════════════════════════════════════════════════════════════╣
║ RESOURCE USAGE MODEL ║
║ ─────────────────────────────────────────────────────────────── ║
║ Platform Base: %.0f cores / %.0f GB RAM (fixed)
║ Per-VIN Marginal: %.0f millicores / %.0f MB RAM
║ Total Fleet: %.1f cores / %.1f GB RAM
╠══════════════════════════════════════════════════════════════════╣
║ COST FORMULA ║
║ ─────────────────────────────────────────────────────────────── ║
║ (Platform Base) + (Per-VIN × %d VINs) + Managed Services
╠══════════════════════════════════════════════════════════════════╣
║ 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
// Calculate total fleet resources
totalCPU := services.PlatformBaseCPUCores + (services.PerVinCPUCores * float64(summary.VehicleCount))
totalRAM := services.PlatformBaseMemoryGB + (services.PerVinMemoryGB * float64(summary.VehicleCount))
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.PlatformBaseCPUCores, services.PlatformBaseMemoryGB,
services.PerVinCPUCores*1000, services.PerVinMemoryGB*1024,
totalCPU, totalRAM,
summary.VehicleCount,
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 %10s %10s %12s %12s %10s\n", "VIN", "CPU (mc)", "RAM (MB)", "Cloud $", "On-Prem $", "Savings %")
fmt.Fprintf(w, "%-20s %10s %10s %12s %12s %10s\n", "───────────────────", "────────", "────────", "──────────", "──────────", "────────")
for _, v := range summary.TopCostVins {
fmt.Fprintf(w, "%-20s %10.0f %10.0f %12.2f %12.2f %9.1f%%\n",
truncateVIN(v.VIN), v.AvgCPUCores*1000, v.AvgMemoryGB*1024, v.TotalCloudCost, v.TotalOnpremCost, v.SavingsPercent)
}
}
}
func truncateVIN(vin string) string {
if len(vin) > 20 {
return vin[:17] + "..."
}
return vin
}