package handler import ( "fmt" "net/http" "strings" "time" "vctp/components/views" "vctp/db" ) // VmTrace shows per-snapshot details for a VM across all snapshots. // @Summary Trace VM history // @Description Shows VM resource history across snapshots, with chart and table. // @Tags vm // @Produce text/html // @Param vm_id query string false "VM ID" // @Param vm_uuid query string false "VM UUID" // @Param name query string false "VM name" // @Success 200 {string} string "HTML page" // @Failure 400 {string} string "Missing identifier" // @Router /vm/trace [get] func (h *Handler) VmTrace(w http.ResponseWriter, r *http.Request) { ctx := r.Context() vmID := r.URL.Query().Get("vm_id") vmUUID := r.URL.Query().Get("vm_uuid") name := r.URL.Query().Get("name") var entries []views.VmTraceEntry chart := views.VmTraceChart{} queryLabel := firstNonEmpty(vmID, vmUUID, name) displayQuery := "" if queryLabel != "" { displayQuery = " for " + queryLabel } creationLabel := "" deletionLabel := "" creationApprox := false // Only fetch data when a query is provided; otherwise render empty page with form. if vmID != "" || vmUUID != "" || name != "" { h.Logger.Info("vm trace request", "vm_id", vmID, "vm_uuid", vmUUID, "name", name) lifecycle, lifeErr := db.FetchVmLifecycle(ctx, h.Database.DB(), vmID, vmUUID, name) if lifeErr != nil { h.Logger.Warn("failed to fetch VM lifecycle", "error", lifeErr) } rows, err := db.FetchVmTrace(ctx, h.Database.DB(), vmID, vmUUID, name) if err != nil { h.Logger.Error("failed to fetch VM trace", "error", err) http.Error(w, fmt.Sprintf("failed to fetch VM trace: %v", err), http.StatusInternalServerError) return } h.Logger.Info("vm trace results", "row_count", len(rows)) entries = make([]views.VmTraceEntry, 0, len(rows)) for _, row := range rows { creation := int64(0) if row.CreationTime.Valid { creation = row.CreationTime.Int64 } deletion := int64(0) if row.DeletionTime.Valid { deletion = row.DeletionTime.Int64 } entries = append(entries, views.VmTraceEntry{ Snapshot: time.Unix(row.SnapshotTime, 0).Local().Format("2006-01-02 15:04:05"), RawTime: row.SnapshotTime, Name: row.Name, VmId: row.VmId, VmUuid: row.VmUuid, Vcenter: row.Vcenter, ResourcePool: row.ResourcePool, VcpuCount: row.VcpuCount, RamGB: row.RamGB, ProvisionedDisk: row.ProvisionedDisk, CreationTime: formatMaybeTime(creation), DeletionTime: formatMaybeTime(deletion), }) } chart = buildVmTraceChart(entries) if len(entries) > 0 { if lifecycle.CreationTime > 0 { ts := time.Unix(lifecycle.CreationTime, 0).Local().Format("2006-01-02 15:04:05") if lifecycle.CreationApprox { creationLabel = fmt.Sprintf("%s (approx. earliest snapshot)", ts) // dont double up on the approximate text //creationApprox = true } else { creationLabel = ts } } else { creationLabel = time.Unix(entries[0].RawTime, 0).Local().Format("2006-01-02 15:04:05") creationApprox = true } if lifecycle.DeletionTime > 0 { deletionLabel = time.Unix(lifecycle.DeletionTime, 0).Local().Format("2006-01-02 15:04:05") } } } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := views.VmTracePage(queryLabel, displayQuery, vmID, vmUUID, name, creationLabel, deletionLabel, creationApprox, entries, chart).Render(ctx, w); err != nil { http.Error(w, "Failed to render template", http.StatusInternalServerError) } } func buildVmTraceChart(entries []views.VmTraceEntry) views.VmTraceChart { if len(entries) == 0 { return views.VmTraceChart{} } maxResource := float64(0) for _, e := range entries { if float64(e.VcpuCount) > maxResource { maxResource = float64(e.VcpuCount) } if float64(e.RamGB) > maxResource { maxResource = float64(e.RamGB) } } if maxResource == 0 { maxResource = 1 } tinLevel := maxResource bronzeLevel := maxResource * 0.9 silverLevel := maxResource * 0.8 goldLevel := maxResource * 0.7 labels := make([]string, 0, len(entries)) tickLabels := make([]string, 0, len(entries)) vcpuValues := make([]float64, 0, len(entries)) ramValues := make([]float64, 0, len(entries)) tinValues := make([]float64, 0, len(entries)) bronzeValues := make([]float64, 0, len(entries)) silverValues := make([]float64, 0, len(entries)) goldValues := make([]float64, 0, len(entries)) poolNames := make([]string, 0, len(entries)) for _, e := range entries { t := time.Unix(e.RawTime, 0).Local() labels = append(labels, t.Format("2006-01-02 15:04:05")) tickLabels = append(tickLabels, t.Format("01-02 15:04")) vcpuValues = append(vcpuValues, float64(e.VcpuCount)) ramValues = append(ramValues, float64(e.RamGB)) pool := strings.TrimSpace(e.ResourcePool) if pool == "" { pool = "Unknown" } poolNames = append(poolNames, pool) lower := strings.ToLower(pool) if lower == "tin" { tinValues = append(tinValues, tinLevel) } else { tinValues = append(tinValues, 0) } if lower == "bronze" { bronzeValues = append(bronzeValues, bronzeLevel) } else { bronzeValues = append(bronzeValues, 0) } if lower == "silver" { silverValues = append(silverValues, silverLevel) } else { silverValues = append(silverValues, 0) } if lower == "gold" { goldValues = append(goldValues, goldLevel) } else { goldValues = append(goldValues, 0) } } cfg := lineChartConfig{ Height: 360, XTicks: 8, YTicks: 5, YLabel: "Resources / Pool", XLabel: "Snapshots (oldest left, newest right)", Labels: labels, TickLabels: tickLabels, Series: []lineChartSeries{ { Name: "vCPU", Color: "#2563eb", Values: vcpuValues, TooltipFormat: "int", LineWidth: 2.5, }, { Name: "RAM (GB)", Color: "#16a34a", Values: ramValues, TooltipFormat: "int", LineWidth: 2.5, }, { Name: "Tin", Color: "#0ea5e9", Values: tinValues, Dash: []float64{4, 4}, LineWidth: 1.5, TooltipHidden: true, }, { Name: "Bronze", Color: "#a855f7", Values: bronzeValues, Dash: []float64{4, 4}, LineWidth: 1.5, TooltipHidden: true, }, { Name: "Silver", Color: "#94a3b8", Values: silverValues, Dash: []float64{4, 4}, LineWidth: 1.5, TooltipHidden: true, }, { Name: "Gold", Color: "#f59e0b", Values: goldValues, Dash: []float64{4, 4}, LineWidth: 1.5, TooltipHidden: true, }, }, HoverRows: []lineChartHoverRow{ { Name: "Resource Pool", Values: poolNames, }, }, } return views.VmTraceChart{ ConfigJSON: encodeLineChartConfig(cfg), } } func firstNonEmpty(vals ...string) string { for _, v := range vals { if v != "" { return v } } return "" } func formatMaybeTime(ts int64) string { if ts == 0 { return "" } return time.Unix(ts, 0).Local().Format("2006-01-02 15:04:05") }