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{} } width := 1200.0 height := 220.0 plotWidth := width - 60.0 startX := 40.0 maxVal := float64(0) for _, e := range entries { if float64(e.VcpuCount) > maxVal { maxVal = float64(e.VcpuCount) } if float64(e.RamGB) > maxVal { maxVal = float64(e.RamGB) } } if maxVal == 0 { maxVal = 1 } stepX := plotWidth if len(entries) > 1 { stepX = plotWidth / float64(len(entries)-1) } scale := height / maxVal var ptsVcpu, ptsRam, ptsTin, ptsBronze, ptsSilver, ptsGold string appendPt := func(s string, x, y float64) string { if s == "" { return fmt.Sprintf("%.1f,%.1f", x, y) } return s + " " + fmt.Sprintf("%.1f,%.1f", x, y) } for i, e := range entries { x := startX + float64(i)*stepX yVcpu := 10 + height - float64(e.VcpuCount)*scale yRam := 10 + height - float64(e.RamGB)*scale ptsVcpu = appendPt(ptsVcpu, x, yVcpu) ptsRam = appendPt(ptsRam, x, yRam) poolY := map[string]float64{ "tin": 10 + height - scale*maxVal, "bronze": 10 + height - scale*maxVal*0.9, "silver": 10 + height - scale*maxVal*0.8, "gold": 10 + height - scale*maxVal*0.7, } lower := strings.ToLower(e.ResourcePool) if lower == "tin" { ptsTin = appendPt(ptsTin, x, poolY["tin"]) } else { ptsTin = appendPt(ptsTin, x, 10+height) } if lower == "bronze" { ptsBronze = appendPt(ptsBronze, x, poolY["bronze"]) } else { ptsBronze = appendPt(ptsBronze, x, 10+height) } if lower == "silver" { ptsSilver = appendPt(ptsSilver, x, poolY["silver"]) } else { ptsSilver = appendPt(ptsSilver, x, 10+height) } if lower == "gold" { ptsGold = appendPt(ptsGold, x, poolY["gold"]) } else { ptsGold = appendPt(ptsGold, x, 10+height) } } gridY := []float64{} for i := 0; i <= 4; i++ { gridY = append(gridY, 10+float64(i)*(height/4)) } gridX := []float64{} for i := 0; i < len(entries); i++ { gridX = append(gridX, startX+float64(i)*stepX) } yTicks := []views.ChartTick{} for i := 0; i <= 4; i++ { val := maxVal * float64(4-i) / 4 pos := 10 + float64(i)*(height/4) yTicks = append(yTicks, views.ChartTick{Pos: pos, Label: fmt.Sprintf("%.0f", val)}) } xTicks := []views.ChartTick{} maxTicks := 8 stepIdx := 1 if len(entries) > 1 { stepIdx = (len(entries)-1)/maxTicks + 1 } for idx := 0; idx < len(entries); idx += stepIdx { x := startX + float64(idx)*stepX label := time.Unix(entries[idx].RawTime, 0).Local().Format("01-02 15:04") xTicks = append(xTicks, views.ChartTick{Pos: x, Label: label}) } if len(entries) > 1 { lastIdx := len(entries) - 1 xLast := startX + float64(lastIdx)*stepX labelLast := time.Unix(entries[lastIdx].RawTime, 0).Local().Format("01-02 15:04") if len(xTicks) == 0 || xTicks[len(xTicks)-1].Pos != xLast { xTicks = append(xTicks, views.ChartTick{Pos: xLast, Label: labelLast}) } } return views.VmTraceChart{ PointsVcpu: ptsVcpu, PointsRam: ptsRam, PointsTin: ptsTin, PointsBronze: ptsBronze, PointsSilver: ptsSilver, PointsGold: ptsGold, Width: int(width), Height: int(height), GridX: gridX, GridY: gridY, XTicks: xTicks, YTicks: yTicks, } } 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") }