All checks were successful
continuous-integration/drone/push Build is passing
237 lines
7.0 KiB
Go
237 lines
7.0 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 := ""
|
|
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")
|
|
}
|