From ab01c0fc4d64720740b26bbc81dc384881c12e16 Mon Sep 17 00:00:00 2001 From: Nathan Coad Date: Fri, 16 Jan 2026 14:28:26 +1100 Subject: [PATCH] enhance database logging --- components/views/index.templ | 1 + components/views/index_templ.go | 8 +- components/views/vm_trace.templ | 164 ++++++ components/views/vm_trace_templ.go | 719 +++++++++++++++++++++++++++ db/helpers.go | 188 +++++-- internal/report/snapshots.go | 18 +- internal/tasks/inventorySnapshots.go | 24 +- server/handler/handler.go | 11 - server/handler/vcenters.go | 8 +- server/handler/vmTrace.go | 210 ++++++++ server/router/docs/docs.go | 46 ++ server/router/docs/swagger.json | 46 ++ server/router/docs/swagger.yaml | 30 ++ server/router/router.go | 1 + 14 files changed, 1406 insertions(+), 68 deletions(-) create mode 100644 components/views/vm_trace.templ create mode 100644 components/views/vm_trace_templ.go create mode 100644 server/handler/vmTrace.go diff --git a/components/views/index.templ b/components/views/index.templ index b1b3756..65261bf 100644 --- a/components/views/index.templ +++ b/components/views/index.templ @@ -27,6 +27,7 @@ templ Index(info BuildInfo) { Hourly Snapshots Daily Snapshots Monthly Snapshots + VM Trace vCenters Swagger UI diff --git a/components/views/index_templ.go b/components/views/index_templ.go index d525221..d1d1ec7 100644 --- a/components/views/index_templ.go +++ b/components/views/index_templ.go @@ -47,14 +47,14 @@ func Index(info BuildInfo) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
vCTP Console

Chargeback Intelligence Dashboard

Point in time snapshots of consumption.

Build Time

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

vCTP Console

Chargeback Intelligence Dashboard

Point in time snapshots of consumption.

Build Time

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(info.BuildTime) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/index.templ`, Line: 39, Col: 59} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/index.templ`, Line: 40, Col: 59} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -67,7 +67,7 @@ func Index(info BuildInfo) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(info.SHA1Ver) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/index.templ`, Line: 43, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/index.templ`, Line: 44, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -80,7 +80,7 @@ func Index(info BuildInfo) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(info.GoVersion) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/index.templ`, Line: 47, Col: 59} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/index.templ`, Line: 48, Col: 59} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { diff --git a/components/views/vm_trace.templ b/components/views/vm_trace.templ new file mode 100644 index 0000000..9e93df8 --- /dev/null +++ b/components/views/vm_trace.templ @@ -0,0 +1,164 @@ +package views + +import ( + "fmt" + "vctp/components/core" +) + +type VmTraceEntry struct { + Snapshot string + RawTime int64 + Name string + VmId string + VmUuid string + Vcenter string + ResourcePool string + VcpuCount int64 + RamGB int64 + ProvisionedDisk float64 + CreationTime string + DeletionTime string +} + +type VmTraceChart struct { + PointsVcpu string + PointsRam string + PointsTin string + PointsBronze string + PointsSilver string + PointsGold string + Width int + Height int + GridX []float64 + GridY []float64 + XTicks []ChartTick + YTicks []ChartTick +} + +templ VmTracePage(query string, display_query string, vm_id string, vm_uuid string, vm_name string, entries []VmTraceEntry, chart VmTraceChart) { + + + @core.Header() + +

+
+
+
+
VM Trace
+

Snapshot history{display_query}

+

Timeline of vCPU, RAM, and resource pool changes across snapshots.

+
+
+ Dashboard +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + Clear +
+
+
+ +
+
+

Snapshot Timeline

+ {len(entries)} samples +
+ if chart.PointsVcpu != "" { +
+ + + + for _, y := range chart.GridY { + + } + for _, x := range chart.GridX { + + } + + + + + + + + + + + for _, tick := range chart.YTicks { + {tick.Label} + } + + + for _, tick := range chart.XTicks { + {tick.Label} + } + + + vCPU + RAM (GB) + Tin + Bronze + Silver + Gold + + Resources / Pool + Snapshots (oldest left, newest right) + +
+ } +
+ + + + + + + + + + + + + + + + + + for _, e := range entries { + + + + + + + + + + + + + + } + +
SnapshotVM NameVmIdVmUuidVcenterResource PoolvCPUsRAM (GB)DiskCreationDeletion
{e.Snapshot}{e.Name}{e.VmId}{e.VmUuid}{e.Vcenter}{e.ResourcePool}{e.VcpuCount}{e.RamGB}{fmt.Sprintf("%.1f", e.ProvisionedDisk)}{e.CreationTime}{e.DeletionTime}
+
+
+
+ + @core.Footer() + +} diff --git a/components/views/vm_trace_templ.go b/components/views/vm_trace_templ.go new file mode 100644 index 0000000..2db499c --- /dev/null +++ b/components/views/vm_trace_templ.go @@ -0,0 +1,719 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +package views + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + "vctp/components/core" +) + +type VmTraceEntry struct { + Snapshot string + RawTime int64 + Name string + VmId string + VmUuid string + Vcenter string + ResourcePool string + VcpuCount int64 + RamGB int64 + ProvisionedDisk float64 + CreationTime string + DeletionTime string +} + +type VmTraceChart struct { + PointsVcpu string + PointsRam string + PointsTin string + PointsBronze string + PointsSilver string + PointsGold string + Width int + Height int + GridX []float64 + GridY []float64 + XTicks []ChartTick + YTicks []ChartTick +} + +func VmTracePage(query string, display_query string, vm_id string, vm_uuid string, vm_name string, entries []VmTraceEntry, chart VmTraceChart) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = core.Header().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
VM Trace

Snapshot history") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(display_query) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 48, Col: 74} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

Timeline of vCPU, RAM, and resource pool changes across snapshots.

Clear

Snapshot Timeline

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(len(entries)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 78, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " samples
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if chart.PointsVcpu != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, y := range chart.GridY { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + for _, x := range chart.GridX { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, tick := range chart.YTicks { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var27 string + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(tick.Label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 102, Col: 70} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, tick := range chart.XTicks { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var30 string + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(tick.Label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 107, Col: 100} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " vCPU RAM (GB) Tin Bronze Silver Gold Resources / Pool Snapshots (oldest left, newest right)
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, e := range entries { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
SnapshotVM NameVmIdVmUuidVcenterResource PoolvCPUsRAM (GB)DiskCreationDeletion
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(e.Snapshot) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 143, Col: 25} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var36 string + templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(e.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 144, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var37 string + templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(e.VmId) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 145, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var38 string + templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(e.VmUuid) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 146, Col: 23} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var39 string + templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(e.Vcenter) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 147, Col: 24} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var40 string + templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(e.ResourcePool) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 148, Col: 29} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var41 string + templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(e.VcpuCount) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 149, Col: 45} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var42 string + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(e.RamGB) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 150, Col: 41} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var43 string + templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", e.ProvisionedDisk)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 151, Col: 72} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var44 string + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(e.CreationTime) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 152, Col: 29} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var45 string + templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(e.DeletionTime) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 153, Col: 29} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = core.Footer().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/db/helpers.go b/db/helpers.go index c0f90b3..1356c80 100644 --- a/db/helpers.go +++ b/db/helpers.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" "log/slog" + "sort" "strings" "time" @@ -33,7 +34,7 @@ func TableRowCount(ctx context.Context, dbConn *sqlx.DB, table string) (int64, e } var count int64 query := fmt.Sprintf(`SELECT COUNT(*) FROM %s`, table) - if err := dbConn.GetContext(ctx, &count, query); err != nil { + if err := getLog(ctx, dbConn, &count, query); err != nil { return 0, err } return count, nil @@ -52,13 +53,37 @@ func EnsureColumns(ctx context.Context, dbConn *sqlx.DB, tableName string, colum return nil } +func execLog(ctx context.Context, dbConn *sqlx.DB, query string, args ...interface{}) (sql.Result, error) { + res, err := dbConn.ExecContext(ctx, query, args...) + if err != nil { + slog.Warn("db exec failed", "query", strings.TrimSpace(query), "error", err) + } + return res, err +} + +func getLog(ctx context.Context, dbConn *sqlx.DB, dest interface{}, query string, args ...interface{}) error { + err := dbConn.GetContext(ctx, dest, query, args...) + if err != nil { + slog.Warn("db get failed", "query", strings.TrimSpace(query), "error", err) + } + return err +} + +func selectLog(ctx context.Context, dbConn *sqlx.DB, dest interface{}, query string, args ...interface{}) error { + err := dbConn.SelectContext(ctx, dest, query, args...) + if err != nil { + slog.Warn("db select failed", "query", strings.TrimSpace(query), "error", err) + } + return err +} + // AddColumnIfMissing performs a best-effort ALTER TABLE to add a column, ignoring "already exists". func AddColumnIfMissing(ctx context.Context, dbConn *sqlx.DB, tableName string, column ColumnDef) error { if _, err := SafeTableName(tableName); err != nil { return err } query := fmt.Sprintf(`ALTER TABLE %s ADD COLUMN "%s" %s`, tableName, column.Name, column.Type) - if _, err := dbConn.ExecContext(ctx, query); err != nil { + if _, err := execLog(ctx, dbConn, query); err != nil { errText := strings.ToLower(err.Error()) if strings.Contains(errText, "duplicate column") || strings.Contains(errText, "already exists") { return nil @@ -97,7 +122,7 @@ func TableHasRows(ctx context.Context, dbConn *sqlx.DB, table string) (bool, err } query := fmt.Sprintf(`SELECT 1 FROM %s LIMIT 1`, table) var exists int - if err := dbConn.GetContext(ctx, &exists, query); err != nil { + if err := getLog(ctx, dbConn, &exists, query); err != nil { if err == sql.ErrNoRows { return false, nil } @@ -116,7 +141,7 @@ func TableExists(ctx context.Context, dbConn *sqlx.DB, table string) bool { return err == nil && count > 0 case "pgx", "postgres": var count int - err := dbConn.GetContext(ctx, &count, ` + err := getLog(ctx, dbConn, &count, ` SELECT COUNT(1) FROM pg_catalog.pg_tables WHERE schemaname = 'public' AND tablename = $1 @@ -160,7 +185,7 @@ func ColumnExists(ctx context.Context, dbConn *sqlx.DB, tableName string, column return false, rows.Err() case "pgx", "postgres": var count int - err := dbConn.GetContext(ctx, &count, ` + err := getLog(ctx, dbConn, &count, ` SELECT COUNT(1) FROM information_schema.columns WHERE table_name = $1 AND column_name = $2 @@ -189,7 +214,7 @@ FROM %s `, table) var totals SnapshotTotals - if err := dbConn.GetContext(ctx, &totals, query); err != nil { + if err := getLog(ctx, dbConn, &totals, query); err != nil { return SnapshotTotals{}, err } return totals, nil @@ -209,7 +234,7 @@ FROM ( `, unionQuery) var totals SnapshotTotals - if err := dbConn.GetContext(ctx, &totals, query); err != nil { + if err := getLog(ctx, dbConn, &totals, query); err != nil { return SnapshotTotals{}, err } return totals, nil @@ -274,7 +299,7 @@ func EnsureSnapshotTable(ctx context.Context, dbConn *sqlx.DB, tableName string) );`, tableName) } - _, err := dbConn.ExecContext(ctx, ddl) + _, err := execLog(ctx, dbConn, ddl) if err != nil { return err } @@ -303,7 +328,7 @@ func EnsureSnapshotIndexes(ctx context.Context, dbConn *sqlx.DB, tableName strin ) } for _, idx := range indexes { - if _, err := dbConn.ExecContext(ctx, idx); err != nil { + if _, err := execLog(ctx, dbConn, idx); err != nil { return err } } @@ -322,7 +347,7 @@ func BackfillSerialColumn(ctx context.Context, dbConn *sqlx.DB, tableName, colum `UPDATE %s SET "%s" = nextval(pg_get_serial_sequence('%s','%s')) WHERE "%s" IS NULL`, tableName, columnName, tableName, columnName, columnName, ) - _, err := dbConn.ExecContext(ctx, query) + _, err := execLog(ctx, dbConn, query) if err != nil { errText := strings.ToLower(err.Error()) if strings.Contains(errText, "pg_get_serial_sequence") || strings.Contains(errText, "sequence") { @@ -347,7 +372,7 @@ func ApplySQLiteTuning(ctx context.Context, dbConn *sqlx.DB) { `PRAGMA optimize;`, } for _, pragma := range pragmas { - _, err = dbConn.ExecContext(ctx, pragma) + _, err = execLog(ctx, dbConn, pragma) if logger, ok := ctx.Value("logger").(*slog.Logger); ok && logger != nil { logger.Debug("Applied SQLite tuning pragma", "pragma", pragma, "error", err) } @@ -408,10 +433,10 @@ CREATE TABLE IF NOT EXISTS vm_renames ( "SnapshotTime" BIGINT NOT NULL )` } - if _, err := dbConn.ExecContext(ctx, identityDDL); err != nil { + if _, err := execLog(ctx, dbConn, identityDDL); err != nil { return err } - if _, err := dbConn.ExecContext(ctx, renameDDL); err != nil { + if _, err := execLog(ctx, dbConn, renameDDL); err != nil { return err } indexes := []string{ @@ -421,7 +446,7 @@ CREATE TABLE IF NOT EXISTS vm_renames ( `CREATE INDEX IF NOT EXISTS vm_renames_vcenter_idx ON vm_renames ("Vcenter","SnapshotTime")`, } for _, idx := range indexes { - if _, err := dbConn.ExecContext(ctx, idx); err != nil { + if _, err := execLog(ctx, dbConn, idx); err != nil { return err } } @@ -446,7 +471,7 @@ func UpsertVmIdentity(ctx context.Context, dbConn *sqlx.DB, vcenter string, vmId LastSeen sql.NullInt64 `db:"LastSeen"` } var existing identityRow - err := dbConn.GetContext(ctx, &existing, ` + err := getLog(ctx, dbConn, &existing, ` SELECT "Name","Cluster","FirstSeen","LastSeen" FROM vm_identity WHERE "Vcenter" = $1 AND "VmId" = $2 AND "VmUuid" = $3 @@ -454,7 +479,7 @@ WHERE "Vcenter" = $1 AND "VmId" = $2 AND "VmUuid" = $3 if err != nil { if strings.Contains(strings.ToLower(err.Error()), "no rows") { - _, err = dbConn.ExecContext(ctx, ` + _, err = execLog(ctx, dbConn, ` INSERT INTO vm_identity ("VmId","VmUuid","Vcenter","Name","Cluster","FirstSeen","LastSeen") VALUES ($1,$2,$3,$4,$5,$6,$6) `, keyVmID, keyUuid, vcenter, name, nullString(cluster), snapshotTime.Unix()) @@ -465,12 +490,12 @@ VALUES ($1,$2,$3,$4,$5,$6,$6) renamed := !strings.EqualFold(existing.Name, name) || !strings.EqualFold(strings.TrimSpace(existing.Cluster.String), strings.TrimSpace(cluster.String)) if renamed { - _, _ = dbConn.ExecContext(ctx, ` + _, _ = execLog(ctx, dbConn, ` INSERT INTO vm_renames ("VmId","VmUuid","Vcenter","OldName","NewName","OldCluster","NewCluster","SnapshotTime") VALUES ($1,$2,$3,$4,$5,$6,$7,$8) `, keyVmID, keyUuid, vcenter, existing.Name, name, existing.Cluster.String, cluster.String, snapshotTime.Unix()) } - _, err = dbConn.ExecContext(ctx, ` + _, err = execLog(ctx, dbConn, ` UPDATE vm_identity SET "Name" = $1, "Cluster" = $2, "LastSeen" = $3 WHERE "Vcenter" = $4 AND "VmId" = $5 AND "VmUuid" = $6 @@ -511,14 +536,14 @@ CREATE TABLE IF NOT EXISTS vcenter_totals ( "RamTotalGB" BIGINT NOT NULL );` } - if _, err := dbConn.ExecContext(ctx, ddl); err != nil { + if _, err := execLog(ctx, dbConn, ddl); err != nil { return err } indexes := []string{ `CREATE INDEX IF NOT EXISTS vcenter_totals_vc_time_idx ON vcenter_totals ("Vcenter","SnapshotTime" DESC)`, } for _, idx := range indexes { - if _, err := dbConn.ExecContext(ctx, idx); err != nil { + if _, err := execLog(ctx, dbConn, idx); err != nil { return err } } @@ -533,7 +558,7 @@ func InsertVcenterTotals(ctx context.Context, dbConn *sqlx.DB, vcenter string, s if err := EnsureVcenterTotalsTable(ctx, dbConn); err != nil { return err } - _, err := dbConn.ExecContext(ctx, ` + _, err := execLog(ctx, dbConn, ` INSERT INTO vcenter_totals ("Vcenter","SnapshotTime","VmCount","VcpuTotal","RamTotalGB") VALUES ($1,$2,$3,$4,$5) `, vcenter, snapshotTime.Unix(), vmCount, vcpuTotal, ramTotal) @@ -585,7 +610,7 @@ FROM vcenter_totals WHERE "Vcenter" = $1 ORDER BY "SnapshotTime" DESC LIMIT $2` - if err := dbConn.SelectContext(ctx, &rows, query, vcenter, limit); err != nil { + if err := selectLog(ctx, dbConn, &rows, query, vcenter, limit); err != nil { return nil, err } return rows, nil @@ -623,7 +648,7 @@ LIMIT $2 TableName string `db:"table_name"` SnapshotTime int64 `db:"snapshot_time"` } - if err := dbConn.SelectContext(ctx, ®Rows, query, snapshotType, limit); err != nil { + if err := selectLog(ctx, dbConn, ®Rows, query, snapshotType, limit); err != nil { return nil, err } @@ -671,12 +696,87 @@ WHERE "Vcenter" = $1 query = strings.ReplaceAll(query, "$1", "?") } var agg summaryAgg - if err := dbConn.GetContext(ctx, &agg, query, vcenter); err != nil { + if err := getLog(ctx, dbConn, &agg, query, vcenter); err != nil { return summaryAgg{}, err } return agg, nil } +// VmTraceRow holds snapshot data for a single VM across tables. +type VmTraceRow struct { + SnapshotTime int64 `db:"SnapshotTime"` + Name string `db:"Name"` + Vcenter string `db:"Vcenter"` + VmId string `db:"VmId"` + VmUuid string `db:"VmUuid"` + ResourcePool string `db:"ResourcePool"` + VcpuCount int64 `db:"VcpuCount"` + RamGB int64 `db:"RamGB"` + ProvisionedDisk float64 `db:"ProvisionedDisk"` + CreationTime sql.NullInt64 `db:"CreationTime"` + DeletionTime sql.NullInt64 `db:"DeletionTime"` +} + +// FetchVmTrace returns combined hourly snapshot records for a VM (by id/uuid/name) ordered by snapshot time. +// To avoid SQLite's UNION term limits, this iterates tables one by one and merges in-memory. +func FetchVmTrace(ctx context.Context, dbConn *sqlx.DB, vmID, vmUUID, name string) ([]VmTraceRow, error) { + var tables []struct { + TableName string `db:"table_name"` + SnapshotTime int64 `db:"snapshot_time"` + } + if err := selectLog(ctx, dbConn, &tables, ` +SELECT table_name, snapshot_time +FROM snapshot_registry +WHERE snapshot_type = 'hourly' +ORDER BY snapshot_time +`); err != nil { + return nil, err + } + if len(tables) == 0 { + return nil, nil + } + + rows := make([]VmTraceRow, 0, len(tables)) + driver := strings.ToLower(dbConn.DriverName()) + + slog.Debug("vm trace scanning tables", "table_count", len(tables), "vm_id", vmID, "vm_uuid", vmUUID, "name", name) + + for _, t := range tables { + if err := ValidateTableName(t.TableName); err != nil { + slog.Warn("vm trace skipping table (invalid name)", "table", t.TableName, "error", err) + continue + } + query := fmt.Sprintf(` +SELECT %d AS "SnapshotTime", + "Name","Vcenter","VmId","VmUuid","ResourcePool","VcpuCount","RamGB","ProvisionedDisk", + COALESCE("CreationTime",0) AS "CreationTime", + COALESCE("DeletionTime",0) AS "DeletionTime" +FROM %s +WHERE ("VmId" = ? OR "VmUuid" = ? OR lower("Name") = lower(?)) +`, t.SnapshotTime, t.TableName) + args := []interface{}{vmID, vmUUID, name} + if driver != "sqlite" { + // convert ? to $1 style for postgres/pgx + query = strings.Replace(query, "?", "$1", 1) + query = strings.Replace(query, "?", "$2", 1) + query = strings.Replace(query, "?", "$3", 1) + } + var tmp []VmTraceRow + if err := selectLog(ctx, dbConn, &tmp, query, args...); err != nil { + slog.Warn("vm trace query failed for table", "table", t.TableName, "error", err) + continue + } + slog.Debug("vm trace table rows", "table", t.TableName, "snapshot_time", t.SnapshotTime, "rows", len(tmp)) + rows = append(rows, tmp...) + } + + sort.Slice(rows, func(i, j int) bool { + return rows[i].SnapshotTime < rows[j].SnapshotTime + }) + slog.Info("vm trace combined rows", "total_rows", len(rows)) + return rows, nil +} + // SyncVcenterTotalsFromSnapshots backfills vcenter_totals using hourly snapshot tables in snapshot_registry. func SyncVcenterTotalsFromSnapshots(ctx context.Context, dbConn *sqlx.DB) error { if err := EnsureVcenterTotalsTable(ctx, dbConn); err != nil { @@ -687,7 +787,7 @@ func SyncVcenterTotalsFromSnapshots(ctx context.Context, dbConn *sqlx.DB) error TableName string `db:"table_name"` SnapshotTime int64 `db:"snapshot_time"` } - if err := dbConn.SelectContext(ctx, &hourlyTables, ` + if err := selectLog(ctx, dbConn, &hourlyTables, ` SELECT table_name, snapshot_time FROM snapshot_registry WHERE snapshot_type = 'hourly' @@ -715,7 +815,7 @@ GROUP BY "Vcenter" RamTotal int64 `db:"ram_total"` } var aggs []aggRow - if err := dbConn.SelectContext(ctx, &aggs, query); err != nil { + if err := selectLog(ctx, dbConn, &aggs, query); err != nil { continue } for _, a := range aggs { @@ -730,7 +830,9 @@ WHERE NOT EXISTS ( if driver == "sqlite" { insert = strings.ReplaceAll(insert, "$", "?") } - _, _ = dbConn.ExecContext(ctx, insert, a.Vcenter, ht.SnapshotTime, a.VmCount, a.VcpuTotal, a.RamTotal) + if _, err := execLog(ctx, dbConn, insert, a.Vcenter, ht.SnapshotTime, a.VmCount, a.VcpuTotal, a.RamTotal); err != nil { + slog.Warn("failed to backfill vcenter_totals", "table", ht.TableName, "vcenter", a.Vcenter, "snapshot_time", ht.SnapshotTime, "error", err) + } } } return nil @@ -745,7 +847,9 @@ func AnalyzeTableIfPostgres(ctx context.Context, dbConn *sqlx.DB, tableName stri if driver != "pgx" && driver != "postgres" { return } - _, _ = dbConn.ExecContext(ctx, fmt.Sprintf(`ANALYZE %s`, tableName)) + if _, err := execLog(ctx, dbConn, fmt.Sprintf(`ANALYZE %s`, tableName)); err != nil { + slog.Warn("failed to ANALYZE table", "table", tableName, "error", err) + } } // SetPostgresWorkMem sets a per-session work_mem for heavy aggregations; no-op for other drivers. @@ -757,7 +861,9 @@ func SetPostgresWorkMem(ctx context.Context, dbConn *sqlx.DB, workMemMB int) { if driver != "pgx" && driver != "postgres" { return } - _, _ = dbConn.ExecContext(ctx, fmt.Sprintf(`SET LOCAL work_mem = '%dMB'`, workMemMB)) + if _, err := execLog(ctx, dbConn, fmt.Sprintf(`SET LOCAL work_mem = '%dMB'`, workMemMB)); err != nil { + slog.Warn("failed to set work_mem", "work_mem_mb", workMemMB, "error", err) + } } // CheckMigrationState ensures goose migrations are present and not dirty. @@ -766,14 +872,14 @@ func CheckMigrationState(ctx context.Context, dbConn *sqlx.DB) error { var tableExists bool switch driver { case "sqlite": - err := dbConn.GetContext(ctx, &tableExists, ` + err := getLog(ctx, dbConn, &tableExists, ` SELECT COUNT(1) > 0 FROM sqlite_master WHERE type='table' AND name='goose_db_version' `) if err != nil { return err } case "pgx", "postgres": - err := dbConn.GetContext(ctx, &tableExists, ` + err := getLog(ctx, dbConn, &tableExists, ` SELECT EXISTS ( SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'goose_db_version' ) @@ -790,7 +896,7 @@ SELECT EXISTS ( } var dirty bool - err := dbConn.GetContext(ctx, &dirty, ` + err := getLog(ctx, dbConn, &dirty, ` SELECT NOT is_applied FROM goose_db_version ORDER BY id DESC @@ -1061,7 +1167,7 @@ WHERE EXISTS ( `, unionQuery, summaryTable) } - _, err := dbConn.ExecContext(ctx, sql) + _, err := execLog(ctx, dbConn, sql) return err } @@ -1230,13 +1336,13 @@ func EnsureSummaryTable(ctx context.Context, dbConn *sqlx.DB, tableName string) );`, tableName) } - if _, err := dbConn.ExecContext(ctx, ddl); err != nil { + if _, err := execLog(ctx, dbConn, ddl); err != nil { return err } // Best-effort: drop legacy IsPresent column if it exists. if hasIsPresent, err := ColumnExists(ctx, dbConn, tableName, "IsPresent"); err == nil && hasIsPresent { - _, _ = dbConn.ExecContext(ctx, fmt.Sprintf(`ALTER TABLE %s DROP COLUMN "IsPresent"`, tableName)) + _, _ = execLog(ctx, dbConn, fmt.Sprintf(`ALTER TABLE %s DROP COLUMN "IsPresent"`, tableName)) } indexes := []string{ @@ -1250,7 +1356,7 @@ func EnsureSummaryTable(ctx context.Context, dbConn *sqlx.DB, tableName string) ) } for _, idx := range indexes { - if _, err := dbConn.ExecContext(ctx, idx); err != nil { + if _, err := execLog(ctx, dbConn, idx); err != nil { return err } } @@ -1283,7 +1389,7 @@ CREATE TABLE IF NOT EXISTS snapshot_runs ( ); ` } - if _, err := dbConn.ExecContext(ctx, ddl); err != nil { + if _, err := execLog(ctx, dbConn, ddl); err != nil { return err } indexes := []string{ @@ -1291,7 +1397,7 @@ CREATE TABLE IF NOT EXISTS snapshot_runs ( `CREATE INDEX IF NOT EXISTS snapshot_runs_success_idx ON snapshot_runs ("Success")`, } for _, idx := range indexes { - if _, err := dbConn.ExecContext(ctx, idx); err != nil { + if _, err := execLog(ctx, dbConn, idx); err != nil { return err } } @@ -1311,7 +1417,7 @@ func UpsertSnapshotRun(ctx context.Context, dbConn *sqlx.DB, vcenter string, sna driver := strings.ToLower(dbConn.DriverName()) switch driver { case "sqlite": - _, err := dbConn.ExecContext(ctx, ` + _, err := execLog(ctx, dbConn, ` INSERT INTO snapshot_runs ("Vcenter","SnapshotTime","Attempts","Success","LastError","LastAttempt") VALUES (?, ?, 1, ?, ?, ?) ON CONFLICT("Vcenter","SnapshotTime") DO UPDATE SET @@ -1322,7 +1428,7 @@ ON CONFLICT("Vcenter","SnapshotTime") DO UPDATE SET `, vcenter, snapshotTime.Unix(), successStr, errMsg, now) return err case "pgx", "postgres": - _, err := dbConn.ExecContext(ctx, ` + _, err := execLog(ctx, dbConn, ` INSERT INTO snapshot_runs ("Vcenter","SnapshotTime","Attempts","Success","LastError","LastAttempt") VALUES ($1, $2, 1, $3, $4, $5) ON CONFLICT("Vcenter","SnapshotTime") DO UPDATE SET @@ -1368,7 +1474,7 @@ ORDER BY "LastAttempt" ASC Attempts int `db:"Attempts"` } rows := []row{} - if err := dbConn.SelectContext(ctx, &rows, query, args...); err != nil { + if err := selectLog(ctx, dbConn, &rows, query, args...); err != nil { return nil, err } results := make([]struct { diff --git a/internal/report/snapshots.go b/internal/report/snapshots.go index f2fd0c0..24fff19 100644 --- a/internal/report/snapshots.go +++ b/internal/report/snapshots.go @@ -97,10 +97,15 @@ CREATE TABLE IF NOT EXISTS snapshot_registry ( } _, err = dbConn.ExecContext(ctx, `ALTER TABLE snapshot_registry ADD COLUMN snapshot_count BIGINT NOT NULL DEFAULT 0`) if err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { + slog.Warn("failed to add snapshot_count column", "error", err) return err } - _, _ = dbConn.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_snapshot_registry_type_time ON snapshot_registry (snapshot_type, snapshot_time)`) - _, _ = dbConn.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_snapshot_registry_table_name ON snapshot_registry (table_name)`) + if _, err := dbConn.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_snapshot_registry_type_time ON snapshot_registry (snapshot_type, snapshot_time)`); err != nil { + slog.Warn("failed to create snapshot_registry index", "error", err) + } + if _, err := dbConn.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_snapshot_registry_table_name ON snapshot_registry (table_name)`); err != nil { + slog.Warn("failed to create snapshot_registry index", "error", err) + } return nil case "pgx", "postgres": _, err := dbConn.ExecContext(ctx, ` @@ -117,10 +122,15 @@ CREATE TABLE IF NOT EXISTS snapshot_registry ( } _, err = dbConn.ExecContext(ctx, `ALTER TABLE snapshot_registry ADD COLUMN snapshot_count BIGINT NOT NULL DEFAULT 0`) if err != nil && !strings.Contains(strings.ToLower(err.Error()), "column \"snapshot_count\" of relation \"snapshot_registry\" already exists") { + slog.Warn("failed to add snapshot_count column", "error", err) return err } - _, _ = dbConn.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_snapshot_registry_type_time ON snapshot_registry (snapshot_type, snapshot_time DESC)`) - _, _ = dbConn.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_snapshot_registry_table_name ON snapshot_registry (table_name)`) + if _, err := dbConn.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_snapshot_registry_type_time ON snapshot_registry (snapshot_type, snapshot_time DESC)`); err != nil { + slog.Warn("failed to create snapshot_registry index", "error", err) + } + if _, err := dbConn.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_snapshot_registry_table_name ON snapshot_registry (table_name)`); err != nil { + slog.Warn("failed to create snapshot_registry index", "error", err) + } return nil default: return fmt.Errorf("unsupported driver for snapshot registry: %s", driver) diff --git a/internal/tasks/inventorySnapshots.go b/internal/tasks/inventorySnapshots.go index bbaa230..f52b877 100644 --- a/internal/tasks/inventorySnapshots.go +++ b/internal/tasks/inventorySnapshots.go @@ -808,7 +808,9 @@ func (c *CronTask) captureHourlySnapshotForVcenter(ctx context.Context, startTim vc := vcenter.New(c.Logger, c.VcCreds) if err := vc.Login(url); err != nil { metrics.RecordVcenterSnapshot(url, time.Since(started), 0, err) - _ = db.UpsertSnapshotRun(ctx, c.Database.DB(), url, startTime, false, err.Error()) + if upErr := db.UpsertSnapshotRun(ctx, c.Database.DB(), url, startTime, false, err.Error()); upErr != nil { + c.Logger.Warn("failed to record snapshot run", "url", url, "error", upErr) + } return fmt.Errorf("unable to connect to vcenter: %w", err) } defer func() { @@ -820,7 +822,9 @@ func (c *CronTask) captureHourlySnapshotForVcenter(ctx context.Context, startTim vcVms, err := vc.GetAllVMsWithProps() if err != nil { metrics.RecordVcenterSnapshot(url, time.Since(started), 0, err) - _ = db.UpsertSnapshotRun(ctx, c.Database.DB(), url, startTime, false, err.Error()) + if upErr := db.UpsertSnapshotRun(ctx, c.Database.DB(), url, startTime, false, err.Error()); upErr != nil { + c.Logger.Warn("failed to record snapshot run", "url", url, "error", upErr) + } return fmt.Errorf("unable to get VMs from vcenter: %w", err) } c.Logger.Debug("retrieved VMs from vcenter", "url", url, "vm_count", len(vcVms)) @@ -895,7 +899,9 @@ func (c *CronTask) captureHourlySnapshotForVcenter(ctx context.Context, startTim c.Logger.Error("unable to build snapshot for VM", "vm_id", vm.Reference().Value, "error", err) continue } - _ = db.UpsertVmIdentity(ctx, dbConn, url, row.VmId, row.VmUuid, row.Name, row.Cluster, startTime) + if err := db.UpsertVmIdentity(ctx, dbConn, url, row.VmId, row.VmUuid, row.Name, row.Cluster, startTime); err != nil { + c.Logger.Warn("failed to upsert vm identity", "vcenter", url, "vm_id", row.VmId, "vm_uuid", row.VmUuid, "name", row.Name, "error", err) + } presentSnapshots[vm.Reference().Value] = row if row.VmUuid.Valid { presentByUuid[row.VmUuid.String] = struct{}{} @@ -972,11 +978,15 @@ func (c *CronTask) captureHourlySnapshotForVcenter(ctx context.Context, startTim if err := insertHourlyBatch(ctx, dbConn, tableName, batch); err != nil { metrics.RecordVcenterSnapshot(url, time.Since(started), totals.VmCount, err) - _ = db.UpsertSnapshotRun(ctx, c.Database.DB(), url, startTime, false, err.Error()) + if upErr := db.UpsertSnapshotRun(ctx, c.Database.DB(), url, startTime, false, err.Error()); upErr != nil { + c.Logger.Warn("failed to record snapshot run", "url", url, "error", upErr) + } return err } // Record per-vCenter totals snapshot. - _ = db.InsertVcenterTotals(ctx, dbConn, url, startTime, totals.VmCount, totals.VcpuTotal, totals.RamTotal) + if err := db.InsertVcenterTotals(ctx, dbConn, url, startTime, totals.VmCount, totals.VcpuTotal, totals.RamTotal); err != nil { + slog.Warn("failed to insert vcenter totals", "vcenter", url, "snapshot_time", startTime.Unix(), "error", err) + } // Compare with previous snapshot for this vcenter to mark deletions at snapshot time. if prevTable, err := latestHourlySnapshotBefore(ctx, dbConn, startTime); err == nil && prevTable != "" { @@ -995,7 +1005,9 @@ func (c *CronTask) captureHourlySnapshotForVcenter(ctx context.Context, startTim "missing_marked", missingCount, ) metrics.RecordVcenterSnapshot(url, time.Since(started), totals.VmCount, nil) - _ = db.UpsertSnapshotRun(ctx, c.Database.DB(), url, startTime, true, "") + if upErr := db.UpsertSnapshotRun(ctx, c.Database.DB(), url, startTime, true, ""); upErr != nil { + c.Logger.Warn("failed to record snapshot run", "url", url, "error", upErr) + } if deletionsMarked { if err := c.generateReport(ctx, tableName); err != nil { c.Logger.Warn("failed to regenerate hourly report after deletions", "error", err, "table", tableName) diff --git a/server/handler/handler.go b/server/handler/handler.go index 049d9d8..675e8c4 100644 --- a/server/handler/handler.go +++ b/server/handler/handler.go @@ -19,14 +19,3 @@ type Handler struct { Secret *secrets.Secrets Settings *settings.Settings } - -/* -func (h *Handler) html(ctx context.Context, w http.ResponseWriter, status int, t templ.Component) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(status) - - if err := t.Render(ctx, w); err != nil { - h.Logger.Error("Failed to render component", "error", err) - } -} -*/ diff --git a/server/handler/vcenters.go b/server/handler/vcenters.go index 9c0641a..ad5ab78 100644 --- a/server/handler/vcenters.go +++ b/server/handler/vcenters.go @@ -20,7 +20,9 @@ import ( // @Router /vcenters [get] func (h *Handler) VcenterList(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - _ = db.SyncVcenterTotalsFromSnapshots(ctx, h.Database.DB()) + if err := db.SyncVcenterTotalsFromSnapshots(ctx, h.Database.DB()); err != nil { + h.Logger.Warn("failed to sync vcenter totals", "error", err) + } vcs, err := db.ListVcenters(ctx, h.Database.DB()) if err != nil { http.Error(w, fmt.Sprintf("failed to list vcenters: %v", err), http.StatusInternalServerError) @@ -67,7 +69,9 @@ func (h *Handler) VcenterTotals(w http.ResponseWriter, r *http.Request) { viewType = "hourly" } if viewType == "hourly" { - _ = db.SyncVcenterTotalsFromSnapshots(ctx, h.Database.DB()) + if err := db.SyncVcenterTotalsFromSnapshots(ctx, h.Database.DB()); err != nil { + h.Logger.Warn("failed to sync vcenter totals", "error", err) + } } limit := 200 if l := r.URL.Query().Get("limit"); l != "" { diff --git a/server/handler/vmTrace.go b/server/handler/vmTrace.go new file mode 100644 index 0000000..89feea9 --- /dev/null +++ b/server/handler/vmTrace.go @@ -0,0 +1,210 @@ +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 + } + + // 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) + 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) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := views.VmTracePage(queryLabel, displayQuery, vmID, vmUUID, name, 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") +} diff --git a/server/router/docs/docs.go b/server/router/docs/docs.go index 3ff3968..5e1d525 100644 --- a/server/router/docs/docs.go +++ b/server/router/docs/docs.go @@ -921,6 +921,52 @@ const docTemplate = `{ } } } + }, + "/vm/trace": { + "get": { + "description": "Shows VM resource history across snapshots, with chart and table.", + "produces": [ + "text/html" + ], + "tags": [ + "vm" + ], + "summary": "Trace VM history", + "parameters": [ + { + "type": "string", + "description": "VM ID", + "name": "vm_id", + "in": "query" + }, + { + "type": "string", + "description": "VM UUID", + "name": "vm_uuid", + "in": "query" + }, + { + "type": "string", + "description": "VM name", + "name": "name", + "in": "query" + } + ], + "responses": { + "200": { + "description": "HTML page", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Missing identifier", + "schema": { + "type": "string" + } + } + } + } } }, "definitions": { diff --git a/server/router/docs/swagger.json b/server/router/docs/swagger.json index ecc5560..f0aa868 100644 --- a/server/router/docs/swagger.json +++ b/server/router/docs/swagger.json @@ -910,6 +910,52 @@ } } } + }, + "/vm/trace": { + "get": { + "description": "Shows VM resource history across snapshots, with chart and table.", + "produces": [ + "text/html" + ], + "tags": [ + "vm" + ], + "summary": "Trace VM history", + "parameters": [ + { + "type": "string", + "description": "VM ID", + "name": "vm_id", + "in": "query" + }, + { + "type": "string", + "description": "VM UUID", + "name": "vm_uuid", + "in": "query" + }, + { + "type": "string", + "description": "VM name", + "name": "name", + "in": "query" + } + ], + "responses": { + "200": { + "description": "HTML page", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Missing identifier", + "schema": { + "type": "string" + } + } + } + } } }, "definitions": { diff --git a/server/router/docs/swagger.yaml b/server/router/docs/swagger.yaml index 93fa38b..4e30b53 100644 --- a/server/router/docs/swagger.yaml +++ b/server/router/docs/swagger.yaml @@ -764,4 +764,34 @@ paths: summary: vCenter totals tags: - vcenters + /vm/trace: + get: + description: Shows VM resource history across snapshots, with chart and table. + parameters: + - description: VM ID + in: query + name: vm_id + type: string + - description: VM UUID + in: query + name: vm_uuid + type: string + - description: VM name + in: query + name: name + type: string + produces: + - text/html + responses: + "200": + description: HTML page + schema: + type: string + "400": + description: Missing identifier + schema: + type: string + summary: Trace VM history + tags: + - vm swagger: "2.0" diff --git a/server/router/router.go b/server/router/router.go index 2415cdc..8033259 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -66,6 +66,7 @@ func New(logger *slog.Logger, database db.Database, buildTime string, sha1ver st mux.HandleFunc("/api/snapshots/hourly/force", h.SnapshotForceHourly) mux.HandleFunc("/api/snapshots/migrate", h.SnapshotMigrate) mux.HandleFunc("/api/snapshots/regenerate-hourly-reports", h.SnapshotRegenerateHourlyReports) + mux.HandleFunc("/vm/trace", h.VmTrace) mux.HandleFunc("/vcenters", h.VcenterList) mux.HandleFunc("/vcenters/totals", h.VcenterTotals) mux.HandleFunc("/metrics", h.Metrics)