Files
vctp2/server/handler/vmTrace.go
Nathan Coad 6dcbb9caef
All checks were successful
continuous-integration/drone/push Build is passing
lifecycle diagnostics
2026-02-09 14:27:41 +11:00

370 lines
11 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{}
diagnostics := views.VmTraceDiagnostics{}
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, lifecycleDiag, lifeErr := db.FetchVmLifecycleWithDiagnostics(ctx, h.Database.DB(), vmID, vmUUID, name)
if lifeErr != nil {
h.Logger.Warn("failed to fetch VM lifecycle", "error", lifeErr)
}
diagnostics = buildVmTraceDiagnostics(lifecycleDiag)
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, diagnostics).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 buildVmTraceDiagnostics(diag db.VmLifecycleDiagnostics) views.VmTraceDiagnostics {
lines := make([]views.VmTraceDiagnosticLine, 0, 24)
lookup := "-"
if diag.LookupField != "" {
lookup = diag.LookupField + "=" + diag.LookupValue
}
lines = append(lines, views.VmTraceDiagnosticLine{Label: "Lookup", Value: lookup})
lines = append(lines, views.VmTraceDiagnosticLine{Label: "Final First Seen", Value: formatDiagUnix(diag.FinalLifecycle.FirstSeen)})
lines = append(lines, views.VmTraceDiagnosticLine{Label: "Final Last Seen", Value: formatDiagUnix(diag.FinalLifecycle.LastSeen)})
lines = append(lines, views.VmTraceDiagnosticLine{Label: "Final Creation Time", Value: formatDiagUnix(diag.FinalLifecycle.CreationTime)})
lines = append(lines, views.VmTraceDiagnosticLine{Label: "Final Creation Approx", Value: fmt.Sprintf("%t", diag.FinalLifecycle.CreationApprox)})
lines = append(lines, views.VmTraceDiagnosticLine{Label: "Final Deletion Time", Value: formatDiagUnix(diag.FinalLifecycle.DeletionTime)})
lines = append(lines, summarizeLifecycleSource(diag.HourlyCache)...)
lines = append(lines, summarizeLifecycleSource(diag.LifecycleCache)...)
lines = append(lines, summarizeLifecycleSource(diag.SnapshotFallback)...)
return views.VmTraceDiagnostics{
Visible: len(lines) > 0,
Lines: lines,
}
}
func summarizeLifecycleSource(src db.VmLifecycleSourceDiagnostics) []views.VmTraceDiagnosticLine {
prefix := src.Source
if strings.TrimSpace(prefix) == "" {
prefix = "source"
}
out := []views.VmTraceDiagnosticLine{
{Label: prefix + " used", Value: fmt.Sprintf("%t", src.Used)},
{Label: prefix + " error", Value: defaultString(src.Error, "-")},
{Label: prefix + " matched rows", Value: fmt.Sprintf("%d", src.MatchedRows)},
{Label: prefix + " first seen", Value: formatDiagUnix(src.FirstSeen)},
{Label: prefix + " last seen", Value: formatDiagUnix(src.LastSeen)},
{Label: prefix + " creation", Value: formatDiagUnix(src.CreationTime)},
{Label: prefix + " creation approx", Value: fmt.Sprintf("%t", src.CreationApprox)},
{Label: prefix + " deletion rows", Value: fmt.Sprintf("%d", src.DeletionRows)},
{Label: prefix + " deletion min", Value: formatDiagUnix(src.DeletionMin)},
{Label: prefix + " deletion max", Value: formatDiagUnix(src.DeletionMax)},
{Label: prefix + " selected deletion", Value: formatDiagUnix(src.SelectedDeletionTime)},
{Label: prefix + " stale deletion ignored", Value: fmt.Sprintf("%t", src.StaleDeletionIgnored)},
}
return out
}
func formatDiagUnix(ts int64) string {
if ts <= 0 {
return "-"
}
return fmt.Sprintf("%s (%d)", time.Unix(ts, 0).Local().Format("2006-01-02 15:04:05"), ts)
}
func defaultString(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
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")
}