diff --git a/components/views/vm_trace.templ b/components/views/vm_trace.templ index 9e93df8..362cfa7 100644 --- a/components/views/vm_trace.templ +++ b/components/views/vm_trace.templ @@ -35,7 +35,7 @@ type VmTraceChart struct { YTicks []ChartTick } -templ VmTracePage(query string, display_query string, vm_id string, vm_uuid string, vm_name string, entries []VmTraceEntry, chart VmTraceChart) { +templ VmTracePage(query string, display_query string, vm_id string, vm_uuid string, vm_name string, creationLabel string, deletionLabel string, entries []VmTraceEntry, chart VmTraceChart) { @core.Header() @@ -120,6 +120,16 @@ templ VmTracePage(query string, display_query string, vm_id string, vm_uuid stri } +
+
+

Creation time

+

{creationLabel}

+
+
+

Deletion time

+

{deletionLabel}

+
+
@@ -133,8 +143,6 @@ templ VmTracePage(query string, display_query string, vm_id string, vm_uuid stri - - @@ -149,8 +157,6 @@ templ VmTracePage(query string, display_query string, vm_id string, vm_uuid stri - - } diff --git a/components/views/vm_trace_templ.go b/components/views/vm_trace_templ.go index 2db499c..2900849 100644 --- a/components/views/vm_trace_templ.go +++ b/components/views/vm_trace_templ.go @@ -43,7 +43,7 @@ type VmTraceChart struct { YTicks []ChartTick } -func VmTracePage(query string, display_query string, vm_id string, vm_uuid string, vm_name string, entries []VmTraceEntry, chart VmTraceChart) templ.Component { +func VmTracePage(query string, display_query string, vm_id string, vm_uuid string, vm_name string, creationLabel string, deletionLabel 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 { @@ -547,45 +547,45 @@ func VmTracePage(query string, display_query string, vm_id string, vm_uuid strin return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
vCPUs RAM (GB) DiskCreationDeletion
{e.VcpuCount} {e.RamGB} {fmt.Sprintf("%.1f", e.ProvisionedDisk)}{e.CreationTime}{e.DeletionTime}
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "

Creation time

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(creationLabel) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 126, Col: 76} + } + _, 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, 45, "

Deletion time

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var36 string + templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(deletionLabel) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 130, Col: 76} + } + _, 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, 46, "

SnapshotVM NameVmIdVmUuidVcenterResource PoolvCPUsRAM (GB)DiskCreationDeletion
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, e := range entries { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
SnapshotVM NameVmIdVmUuidVcenterResource PoolvCPUsRAM (GB)Disk
") - 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, "") + 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) + templ_7745c5c3_Var37, 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: 145, Col: 21} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 151, Col: 25} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) if templ_7745c5c3_Err != nil { @@ -596,9 +596,9 @@ func VmTracePage(query string, display_query string, vm_id string, vm_uuid strin return templ_7745c5c3_Err } var templ_7745c5c3_Var38 string - templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(e.VmUuid) + templ_7745c5c3_Var38, 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: 146, Col: 23} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 152, Col: 21} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) if templ_7745c5c3_Err != nil { @@ -609,9 +609,9 @@ func VmTracePage(query string, display_query string, vm_id string, vm_uuid strin return templ_7745c5c3_Err } var templ_7745c5c3_Var39 string - templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(e.Vcenter) + templ_7745c5c3_Var39, 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: 147, Col: 24} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 153, Col: 21} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) if templ_7745c5c3_Err != nil { @@ -622,35 +622,35 @@ func VmTracePage(query string, display_query string, vm_id string, vm_uuid strin return templ_7745c5c3_Err } var templ_7745c5c3_Var40 string - templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(e.ResourcePool) + templ_7745c5c3_Var40, 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: 148, Col: 29} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 154, Col: 23} } _, 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, "") + 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) + templ_7745c5c3_Var41, 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: 149, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 155, Col: 24} } _, 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, "") + 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) + templ_7745c5c3_Var42, 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: 150, Col: 41} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 156, Col: 29} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) if templ_7745c5c3_Err != nil { @@ -661,35 +661,35 @@ func VmTracePage(query string, display_query string, vm_id string, vm_uuid strin return templ_7745c5c3_Err } var templ_7745c5c3_Var43 string - templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", e.ProvisionedDisk)) + templ_7745c5c3_Var43, 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: 151, Col: 72} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 157, Col: 45} } _, 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, "") + 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) + templ_7745c5c3_Var44, 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: 152, Col: 29} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 158, Col: 41} } _, 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, "") + 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) + templ_7745c5c3_Var45, 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: 153, Col: 29} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/vm_trace.templ`, Line: 159, Col: 72} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) if templ_7745c5c3_Err != nil { diff --git a/db/helpers.go b/db/helpers.go index 1356c80..a26121b 100644 --- a/db/helpers.go +++ b/db/helpers.go @@ -717,6 +717,14 @@ type VmTraceRow struct { DeletionTime sql.NullInt64 `db:"DeletionTime"` } +// VmLifecycle captures observed lifecycle times from hourly snapshots. +type VmLifecycle struct { + CreationTime int64 + FirstSeen int64 + LastSeen int64 + DeletionTime int64 +} + // 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) { @@ -777,6 +785,70 @@ WHERE ("VmId" = ? OR "VmUuid" = ? OR lower("Name") = lower(?)) return rows, nil } +// FetchVmLifecycle walks hourly snapshots to determine lifecycle bounds for a VM. +func FetchVmLifecycle(ctx context.Context, dbConn *sqlx.DB, vmID, vmUUID, name string) (VmLifecycle, error) { + var lifecycle VmLifecycle + 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 lifecycle, err + } + driver := strings.ToLower(dbConn.DriverName()) + + minCreation := int64(0) + for _, t := range tables { + if err := ValidateTableName(t.TableName); err != nil { + continue + } + // Probe this table for the VM. + query := fmt.Sprintf(` +SELECT MIN(NULLIF("CreationTime",0)) AS min_creation, COUNT(1) AS cnt +FROM %s +WHERE ("VmId" = ? OR "VmUuid" = ? OR lower("Name") = lower(?)) +`, t.TableName) + args := []interface{}{vmID, vmUUID, name} + if driver != "sqlite" { + query = strings.Replace(query, "?", "$1", 1) + query = strings.Replace(query, "?", "$2", 1) + query = strings.Replace(query, "?", "$3", 1) + } + var probe struct { + MinCreation sql.NullInt64 `db:"min_creation"` + Cnt int64 `db:"cnt"` + } + if err := getLog(ctx, dbConn, &probe, query, args...); err != nil { + continue + } + if probe.Cnt > 0 { + if lifecycle.FirstSeen == 0 { + lifecycle.FirstSeen = t.SnapshotTime + } + lifecycle.LastSeen = t.SnapshotTime + if probe.MinCreation.Valid { + if minCreation == 0 || probe.MinCreation.Int64 < minCreation { + minCreation = probe.MinCreation.Int64 + } + } + } else if lifecycle.LastSeen > 0 && lifecycle.DeletionTime == 0 && t.SnapshotTime > lifecycle.LastSeen { + lifecycle.DeletionTime = t.SnapshotTime + break + } + } + if minCreation > 0 { + lifecycle.CreationTime = minCreation + } else if lifecycle.FirstSeen > 0 { + lifecycle.CreationTime = lifecycle.FirstSeen + } + return lifecycle, 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 { diff --git a/server/handler/vmTrace.go b/server/handler/vmTrace.go index 89feea9..010f9e7 100644 --- a/server/handler/vmTrace.go +++ b/server/handler/vmTrace.go @@ -33,10 +33,16 @@ func (h *Handler) VmTrace(w http.ResponseWriter, r *http.Request) { if queryLabel != "" { displayQuery = " for " + queryLabel } + creationLabel := "" + deletionLabel := "" // 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) + lifecycle, lifeErr := db.FetchVmLifecycle(ctx, h.Database.DB(), vmID, vmUUID, name) + if lifeErr != nil { + h.Logger.Warn("failed to fetch VM lifecycle", "error", lifeErr) + } rows, err := db.FetchVmTrace(ctx, h.Database.DB(), vmID, vmUUID, name) if err != nil { h.Logger.Error("failed to fetch VM trace", "error", err) @@ -70,10 +76,21 @@ func (h *Handler) VmTrace(w http.ResponseWriter, r *http.Request) { }) } chart = buildVmTraceChart(entries) + + if len(entries) > 0 { + if lifecycle.CreationTime > 0 { + creationLabel = time.Unix(lifecycle.CreationTime, 0).Local().Format("2006-01-02 15:04:05") + } else { + creationLabel = time.Unix(entries[0].RawTime, 0).Local().Format("2006-01-02 15:04:05") + } + if lifecycle.DeletionTime > 0 { + deletionLabel = time.Unix(lifecycle.DeletionTime, 0).Local().Format("2006-01-02 15:04:05") + } + } } w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := views.VmTracePage(queryLabel, displayQuery, vmID, vmUUID, name, entries, chart).Render(ctx, w); err != nil { + if err := views.VmTracePage(queryLabel, displayQuery, vmID, vmUUID, name, creationLabel, deletionLabel, entries, chart).Render(ctx, w); err != nil { http.Error(w, "Failed to render template", http.StatusInternalServerError) } }