You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
459 lines
13 KiB
459 lines
13 KiB
package handlers |
|
|
|
import ( |
|
"encoding/json" |
|
"fmt" |
|
"net/http" |
|
"systemdesigngame/internal/design" |
|
"systemdesigngame/internal/level" |
|
"systemdesigngame/internal/simulation" |
|
) |
|
|
|
type SimulationHandler struct{} |
|
|
|
type SimulationResponse struct { |
|
Success bool `json:"success"` |
|
Metrics map[string]interface{} `json:"metrics,omitempty"` |
|
Timeline []interface{} `json:"timeline,omitempty"` |
|
Passed bool `json:"passed"` |
|
Score int `json:"score,omitempty"` |
|
Feedback []string `json:"feedback,omitempty"` |
|
LevelName string `json:"levelName,omitempty"` |
|
Error string `json:"error,omitempty"` |
|
} |
|
|
|
func (h *SimulationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
|
if r.Method != http.MethodPost { |
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) |
|
return |
|
} |
|
|
|
var requestBody struct { |
|
Design design.Design `json:"design"` |
|
LevelID string `json:"levelId,omitempty"` |
|
} |
|
|
|
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { |
|
// Try to decode as just design for backward compatibility |
|
r.Body.Close() |
|
var design design.Design |
|
if err2 := json.NewDecoder(r.Body).Decode(&design); err2 != nil { |
|
http.Error(w, "Invalid request JSON: "+err.Error(), http.StatusBadRequest) |
|
return |
|
} |
|
requestBody.Design = design |
|
} |
|
|
|
// Extract the design for processing |
|
design := requestBody.Design |
|
|
|
// Run the actual simulation |
|
engine := simulation.NewEngineFromDesign(design, 100) |
|
if engine == nil { |
|
response := SimulationResponse{ |
|
Success: false, |
|
Error: "Failed to create simulation engine - no valid components found", |
|
} |
|
w.Header().Set("Content-Type", "application/json") |
|
json.NewEncoder(w).Encode(response) |
|
return |
|
} |
|
|
|
// Set simulation parameters |
|
defaultRPS := 50 |
|
targetRPS := defaultRPS |
|
|
|
if requestBody.LevelID != "" { |
|
if lvl, err := level.GetLevelByID(requestBody.LevelID); err == nil { |
|
targetRPS = lvl.TargetRPS |
|
} |
|
} |
|
|
|
engine.RPS = targetRPS |
|
|
|
// Find entry node by analyzing topology |
|
entryNode := findEntryNode(design) |
|
|
|
if entryNode == "" { |
|
response := SimulationResponse{ |
|
Success: false, |
|
Error: "No entry point found - design must include a component with no incoming connections (webserver, microservice, load balancer, etc.)", |
|
} |
|
w.Header().Set("Content-Type", "application/json") |
|
json.NewEncoder(w).Encode(response) |
|
return |
|
} |
|
|
|
engine.EntryNode = entryNode |
|
|
|
// Run simulation for 60 ticks (6 seconds at 100ms per tick) |
|
snapshots := engine.Run(60, 100) |
|
|
|
// Calculate metrics from snapshots |
|
metrics := calculateMetrics(snapshots, design) |
|
|
|
// Convert snapshots to interface{} for JSON serialization |
|
timeline := make([]interface{}, len(snapshots)) |
|
for i, snapshot := range snapshots { |
|
timeline[i] = snapshot |
|
} |
|
|
|
// Perform level validation if level info provided |
|
var passed bool |
|
var score int |
|
var feedback []string |
|
var levelName string |
|
|
|
if requestBody.LevelID != "" { |
|
if lvl, err := level.GetLevelByID(requestBody.LevelID); err == nil { |
|
levelName = lvl.Name |
|
passed, score, feedback = validateLevel(lvl, design, metrics) |
|
} else { |
|
feedback = []string{"Warning: Level not found, simulation ran without validation"} |
|
} |
|
} |
|
|
|
response := SimulationResponse{ |
|
Success: true, |
|
Metrics: metrics, |
|
Timeline: timeline, |
|
Passed: passed, |
|
Score: score, |
|
Feedback: feedback, |
|
LevelName: levelName, |
|
} |
|
|
|
w.Header().Set("Content-Type", "application/json") |
|
if err := json.NewEncoder(w).Encode(response); err != nil { |
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError) |
|
return |
|
} |
|
} |
|
|
|
// calculateMetrics computes key performance metrics from simulation snapshots |
|
func calculateMetrics(snapshots []*simulation.TickSnapshot, design design.Design) map[string]interface{} { |
|
if len(snapshots) == 0 { |
|
return map[string]interface{}{ |
|
"throughput": 0, |
|
"latency_avg": 0, |
|
"cost_monthly": 0, |
|
"availability": 0, |
|
} |
|
} |
|
|
|
totalRequests := 0 |
|
totalLatency := 0 |
|
totalHealthy := 0 |
|
totalNodes := 0 |
|
|
|
// Calculate aggregate metrics across all snapshots |
|
for _, snapshot := range snapshots { |
|
// Count total requests processed in this tick |
|
for _, requests := range snapshot.Emitted { |
|
totalRequests += len(requests) |
|
for _, req := range requests { |
|
totalLatency += req.LatencyMS |
|
} |
|
} |
|
|
|
// Count healthy vs total nodes |
|
for _, healthy := range snapshot.NodeHealth { |
|
totalNodes++ |
|
if healthy { |
|
totalHealthy++ |
|
} |
|
} |
|
} |
|
|
|
// Calculate throughput (requests per second) |
|
// snapshots represent 6 seconds of simulation (60 ticks * 100ms) |
|
simulationSeconds := float64(len(snapshots)) * 0.1 // 100ms per tick |
|
throughput := float64(totalRequests) / simulationSeconds |
|
|
|
// Calculate average latency |
|
avgLatency := 0.0 |
|
if totalRequests > 0 { |
|
avgLatency = float64(totalLatency) / float64(totalRequests) |
|
} |
|
|
|
// Calculate availability percentage |
|
availability := 0.0 |
|
if totalNodes > 0 { |
|
availability = (float64(totalHealthy) / float64(totalNodes)) * 100 |
|
} |
|
|
|
// Calculate monthly cost based on component specifications |
|
monthlyCost := calculateRealMonthlyCost(design.Nodes) |
|
|
|
return map[string]interface{}{ |
|
"throughput": int(throughput), |
|
"latency_avg": avgLatency, |
|
"cost_monthly": int(monthlyCost), |
|
"availability": availability, |
|
} |
|
} |
|
|
|
// findEntryNode analyzes the design topology to find the best entry point |
|
func findEntryNode(design design.Design) string { |
|
// Build map of incoming connections |
|
incomingCount := make(map[string]int) |
|
|
|
// Initialize all nodes with 0 incoming connections |
|
for _, node := range design.Nodes { |
|
incomingCount[node.ID] = 0 |
|
} |
|
|
|
// Count incoming connections for each node |
|
for _, conn := range design.Connections { |
|
incomingCount[conn.Target]++ |
|
} |
|
|
|
// Find nodes with no incoming connections (potential entry points) |
|
var entryPoints []string |
|
for nodeID, count := range incomingCount { |
|
if count == 0 { |
|
entryPoints = append(entryPoints, nodeID) |
|
} |
|
} |
|
|
|
// If multiple entry points exist, prefer certain types |
|
if len(entryPoints) > 1 { |
|
return preferredEntryPoint(design.Nodes, entryPoints) |
|
} else if len(entryPoints) == 1 { |
|
return entryPoints[0] |
|
} |
|
|
|
return "" // No entry point found |
|
} |
|
|
|
// preferredEntryPoint selects the best entry point from candidates based on component type |
|
func preferredEntryPoint(nodes []design.Node, candidateIDs []string) string { |
|
// Priority order for entry points (most logical first) |
|
priority := []string{ |
|
"webserver", |
|
"microservice", |
|
"loadBalancer", // Could be edge load balancer |
|
"cdn", // Edge CDN |
|
"data pipeline", // Data ingestion entry |
|
"messageQueue", // For event-driven architectures |
|
} |
|
|
|
// Create lookup for candidate nodes |
|
candidates := make(map[string]design.Node) |
|
for _, node := range nodes { |
|
for _, id := range candidateIDs { |
|
if node.ID == id { |
|
candidates[id] = node |
|
break |
|
} |
|
} |
|
} |
|
|
|
// Find highest priority candidate |
|
for _, nodeType := range priority { |
|
for id, node := range candidates { |
|
if node.Type == nodeType { |
|
return id |
|
} |
|
} |
|
} |
|
|
|
// If no preferred type, return first candidate |
|
if len(candidateIDs) > 0 { |
|
return candidateIDs[0] |
|
} |
|
|
|
return "" |
|
} |
|
|
|
// validateLevel checks if the design and simulation results meet level requirements |
|
func validateLevel(lvl *level.Level, design design.Design, metrics map[string]interface{}) (bool, int, []string) { |
|
var feedback []string |
|
var failedRequirements []string |
|
var passedRequirements []string |
|
|
|
// Extract metrics |
|
avgLatency := int(metrics["latency_avg"].(float64)) |
|
availability := metrics["availability"].(float64) |
|
monthlyCost := metrics["cost_monthly"].(int) |
|
|
|
// Check latency requirement (using avg latency as approximation for P95) |
|
if avgLatency <= lvl.MaxP95LatencyMs { |
|
passedRequirements = append(passedRequirements, "Latency requirement met") |
|
} else { |
|
failedRequirements = append(failedRequirements, |
|
fmt.Sprintf("Latency: %dms (max allowed: %dms)", avgLatency, lvl.MaxP95LatencyMs)) |
|
} |
|
|
|
// Check availability requirement |
|
if availability >= lvl.RequiredAvailabilityPct { |
|
passedRequirements = append(passedRequirements, "Availability requirement met") |
|
} else { |
|
failedRequirements = append(failedRequirements, |
|
fmt.Sprintf("Availability: %.1f%% (required: %.1f%%)", availability, lvl.RequiredAvailabilityPct)) |
|
} |
|
|
|
// Check cost requirement |
|
if monthlyCost <= lvl.MaxMonthlyUSD { |
|
passedRequirements = append(passedRequirements, "Cost requirement met") |
|
} else { |
|
failedRequirements = append(failedRequirements, |
|
fmt.Sprintf("Cost: $%d/month (max allowed: $%d/month)", monthlyCost, lvl.MaxMonthlyUSD)) |
|
} |
|
|
|
// Check component requirements |
|
componentFeedback := validateComponentRequirements(lvl, design) |
|
if len(componentFeedback.Failed) > 0 { |
|
failedRequirements = append(failedRequirements, componentFeedback.Failed...) |
|
} |
|
if len(componentFeedback.Passed) > 0 { |
|
passedRequirements = append(passedRequirements, componentFeedback.Passed...) |
|
} |
|
|
|
// Determine if passed |
|
passed := len(failedRequirements) == 0 |
|
|
|
// Calculate score (0-100) |
|
score := calculateScore(len(passedRequirements), len(failedRequirements), metrics) |
|
|
|
// Build feedback |
|
if passed { |
|
feedback = append(feedback, "Level completed successfully!") |
|
feedback = append(feedback, "") |
|
feedback = append(feedback, passedRequirements...) |
|
} else { |
|
feedback = append(feedback, "Level failed - requirements not met:") |
|
feedback = append(feedback, "") |
|
feedback = append(feedback, failedRequirements...) |
|
if len(passedRequirements) > 0 { |
|
feedback = append(feedback, "") |
|
feedback = append(feedback, "Requirements passed:") |
|
feedback = append(feedback, passedRequirements...) |
|
} |
|
} |
|
|
|
return passed, score, feedback |
|
} |
|
|
|
type ComponentValidationResult struct { |
|
Passed []string |
|
Failed []string |
|
} |
|
|
|
// validateComponentRequirements checks mustInclude, mustNotInclude, etc. |
|
func validateComponentRequirements(lvl *level.Level, design design.Design) ComponentValidationResult { |
|
result := ComponentValidationResult{} |
|
|
|
// Build map of component types in design |
|
componentTypes := make(map[string]int) |
|
for _, node := range design.Nodes { |
|
componentTypes[node.Type]++ |
|
} |
|
|
|
// Check mustInclude requirements |
|
for _, required := range lvl.MustInclude { |
|
if count, exists := componentTypes[required]; exists && count > 0 { |
|
result.Passed = append(result.Passed, fmt.Sprintf("Required component '%s' included", required)) |
|
} else { |
|
result.Failed = append(result.Failed, fmt.Sprintf("Missing required component: '%s'", required)) |
|
} |
|
} |
|
|
|
// Check mustNotInclude requirements |
|
for _, forbidden := range lvl.MustNotInclude { |
|
if count, exists := componentTypes[forbidden]; exists && count > 0 { |
|
result.Failed = append(result.Failed, fmt.Sprintf("Forbidden component used: '%s'", forbidden)) |
|
} |
|
} |
|
|
|
// Check minReplicas requirements |
|
for component, minCount := range lvl.MinReplicas { |
|
if count, exists := componentTypes[component]; exists && count >= minCount { |
|
result.Passed = append(result.Passed, fmt.Sprintf("Sufficient '%s' replicas (%d)", component, count)) |
|
} else { |
|
actualCount := 0 |
|
if exists { |
|
actualCount = count |
|
} |
|
result.Failed = append(result.Failed, |
|
fmt.Sprintf("Insufficient '%s' replicas: %d (minimum: %d)", component, actualCount, minCount)) |
|
} |
|
} |
|
|
|
return result |
|
} |
|
|
|
// calculateScore computes a score from 0-100 based on performance |
|
func calculateScore(passedCount, failedCount int, metrics map[string]interface{}) int { |
|
if failedCount > 0 { |
|
// Failed level - score based on how many requirements passed |
|
return (passedCount * 100) / (passedCount + failedCount) |
|
} |
|
|
|
// Passed level - bonus points for performance |
|
baseScore := 70 // Base score for passing |
|
|
|
// Performance bonuses (up to 30 points) |
|
performanceBonus := 0 |
|
|
|
// Throughput bonus (higher throughput = better) |
|
if throughput, ok := metrics["throughput"].(int); ok && throughput > 0 { |
|
performanceBonus += min(10, throughput/100) // 1 point per 100 RPS, max 10 |
|
} |
|
|
|
// Availability bonus (higher availability = better) |
|
if availability, ok := metrics["availability"].(float64); ok { |
|
if availability >= 99.9 { |
|
performanceBonus += 10 |
|
} else if availability >= 99.5 { |
|
performanceBonus += 5 |
|
} |
|
} |
|
|
|
// Cost efficiency bonus (lower cost = better) |
|
if cost, ok := metrics["cost_monthly"].(int); ok && cost > 0 { |
|
if cost <= 50 { |
|
performanceBonus += 10 |
|
} else if cost <= 100 { |
|
performanceBonus += 5 |
|
} |
|
} |
|
|
|
return min(100, baseScore+performanceBonus) |
|
} |
|
|
|
// calculateRealMonthlyCost computes monthly cost based on actual component specifications |
|
func calculateRealMonthlyCost(nodes []design.Node) float64 { |
|
totalCost := 0.0 |
|
|
|
for _, node := range nodes { |
|
switch node.Type { |
|
case "user": |
|
// User components don't cost anything |
|
continue |
|
case "microservice": |
|
if monthlyUsd, ok := node.Props["monthlyUsd"].(float64); ok { |
|
if instanceCount, ok := node.Props["instanceCount"].(float64); ok { |
|
totalCost += monthlyUsd * instanceCount |
|
} |
|
} |
|
case "webserver": |
|
if monthlyCost, ok := node.Props["monthlyCostUsd"].(float64); ok { |
|
totalCost += monthlyCost |
|
} |
|
default: |
|
// Default cost for other components (cache, database, load balancer, etc.) |
|
totalCost += 20 // $20/month baseline |
|
} |
|
} |
|
|
|
return totalCost |
|
} |
|
|
|
// Helper function |
|
func min(a, b int) int { |
|
if a < b { |
|
return a |
|
} |
|
return b |
|
}
|
|
|