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