Files
vctp2/server/handler/vmTrace.go
Nathan Coad 7273961cfc
All checks were successful
continuous-integration/drone/push Build is passing
enhance vm trace page
2026-01-16 14:52:15 +11:00

228 lines
6.7 KiB
Go

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 := ""
// 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 {
creationLabel = time.Unix(lifecycle.CreationTime, 0).Local().Format("2006-01-02 15:04:05")
} else {
creationLabel = time.Unix(entries[0].RawTime, 0).Local().Format("2006-01-02 15:04:05")
}
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, 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")
}