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()
+
+
+
+
+
+
+
Snapshot Timeline
+ {len(entries)} samples
+
+ if chart.PointsVcpu != "" {
+
+
+
+ }
+
+
+
+
+ | Snapshot |
+ VM Name |
+ VmId |
+ VmUuid |
+ Vcenter |
+ Resource Pool |
+ vCPUs |
+ RAM (GB) |
+ Disk |
+ Creation |
+ Deletion |
+
+
+
+ for _, e := range entries {
+
+ | {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, "
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
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "| Snapshot | VM Name | VmId | VmUuid | Vcenter | Resource Pool | vCPUs | RAM (GB) | Disk | Creation | Deletion |
")
+ 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
+ }
+ 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 = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
")
+ 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)