- 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
288 lines
11 KiB
Go
288 lines
11 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%%)
|
||
╠══════════════════════════════════════════════════════════════════╣
|
||
║ 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
|
||
}
|