Files
vctp2/server/handler/vmTrace.go
Nathan Coad 59b16db04f
All checks were successful
continuous-integration/drone/push Build is passing
speed up vm trace pages
2026-02-09 14:19:24 +11:00

309 lines
8.4 KiB
Go

package handler
import (
"fmt"
"net/http"
"net/url"
"strings"
"time"
"vctp/components/views"
"vctp/db"
)
// VmTrace shows VM history in either hourly-detail or daily-aggregated mode.
// @Summary Trace VM history
// @Description Shows VM resource history with an hourly detail view and a daily aggregated view, with chart and table output.
// @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"
// @Param view query string false "hourly|daily (default: hourly)"
// @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")
viewType := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("view")))
if viewType != "daily" {
viewType = "hourly"
}
meta := buildVmTraceMeta(viewType, vmID, vmUUID, 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", "view", viewType, "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)
}
var (
rows []db.VmTraceRow
err error
)
if viewType == "daily" {
rows, err = db.FetchVmTraceDaily(ctx, h.Database.DB(), vmID, vmUUID, name)
} else {
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, meta).Render(ctx, w); err != nil {
http.Error(w, "Failed to render template", http.StatusInternalServerError)
}
}
func buildVmTraceMeta(viewType, vmID, vmUUID, name string) views.VmTraceMeta {
if viewType != "daily" {
viewType = "hourly"
}
addQuery := func(targetView string) string {
q := url.Values{}
q.Set("view", targetView)
if strings.TrimSpace(vmID) != "" {
q.Set("vm_id", vmID)
}
if strings.TrimSpace(vmUUID) != "" {
q.Set("vm_uuid", vmUUID)
}
if strings.TrimSpace(name) != "" {
q.Set("name", name)
}
return "/vm/trace?" + q.Encode()
}
meta := views.VmTraceMeta{
ViewType: viewType,
TypeLabel: "Hourly",
HourlyLink: addQuery("hourly"),
DailyLink: addQuery("daily"),
HourlyClass: "web3-button",
DailyClass: "web3-button",
}
if viewType == "daily" {
meta.TypeLabel = "Daily"
meta.DailyClass = "web3-button active"
} else {
meta.HourlyClass = "web3-button active"
}
return meta
}
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")
}