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: 38, Col: 59}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/index.templ`, Line: 39, 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: 42, Col: 57}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/index.templ`, Line: 43, 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: 46, Col: 59}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/index.templ`, Line: 47, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
diff --git a/components/views/snapshots.templ b/components/views/snapshots.templ
index 322b44c..699b233 100644
--- a/components/views/snapshots.templ
+++ b/components/views/snapshots.templ
@@ -1,6 +1,7 @@
package views
import (
+ "fmt"
"vctp/components/core"
)
@@ -11,6 +12,47 @@ type SnapshotEntry struct {
Group string
}
+type VcenterLink struct {
+ Name string
+ Link string
+}
+
+type VcenterTotalsEntry struct {
+ Snapshot string
+ RawTime int64
+ VmCount int64
+ VcpuTotal int64
+ RamTotalGB int64
+}
+
+type VcenterTotalsMeta struct {
+ ViewType string
+ TypeLabel string
+ HourlyLink string
+ DailyLink string
+ MonthlyLink string
+ HourlyClass string
+ DailyClass string
+ MonthlyClass string
+}
+
+type VcenterChartData struct {
+ PointsVm string
+ PointsVcpu string
+ PointsRam string
+ Width int
+ Height int
+ GridX []float64
+ GridY []float64
+ YTicks []ChartTick
+ XTicks []ChartTick
+}
+
+type ChartTick struct {
+ Pos float64
+ Label string
+}
+
templ SnapshotHourlyList(entries []SnapshotEntry) {
@SnapshotListPage("Hourly Inventory Snapshots", "inventory snapshots captured hourly", entries)
}
@@ -84,3 +126,160 @@ templ SnapshotListPage(title string, subtitle string, entries []SnapshotEntry) {
@core.Footer()
}
+
+templ VcenterList(links []VcenterLink) {
+
+
+ @core.Header()
+
+
+
+
+
+
+
vCenters
+ {len(links)} total
+
+
+
+
+
+ | vCenter |
+ Totals |
+
+
+
+ for _, link := range links {
+
+ | {link.Name} |
+
+ View Totals
+ |
+
+ }
+
+
+
+
+
+
+ @core.Footer()
+
+}
+
+templ VcenterTotalsPage(vcenter string, entries []VcenterTotalsEntry, chart VcenterChartData, meta VcenterTotalsMeta) {
+
+
+ @core.Header()
+
+
+
+
+
+
+
{meta.TypeLabel} Snapshots
+ {len(entries)} records
+
+ if chart.PointsVm != "" {
+
+
+
+ }
+
+
+
+
+
+ | Snapshot Time |
+ VMs |
+ vCPUs |
+ RAM (GB) |
+
+
+
+ for _, entry := range entries {
+
+ | {entry.Snapshot} |
+ {entry.VmCount} |
+ {entry.VcpuTotal} |
+ {entry.RamTotalGB} |
+
+ }
+
+
+
+
+
+
+ @core.Footer()
+
+}
diff --git a/components/views/snapshots_templ.go b/components/views/snapshots_templ.go
index f93a643..196d3c5 100644
--- a/components/views/snapshots_templ.go
+++ b/components/views/snapshots_templ.go
@@ -9,6 +9,7 @@ import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
+ "fmt"
"vctp/components/core"
)
@@ -19,6 +20,47 @@ type SnapshotEntry struct {
Group string
}
+type VcenterLink struct {
+ Name string
+ Link string
+}
+
+type VcenterTotalsEntry struct {
+ Snapshot string
+ RawTime int64
+ VmCount int64
+ VcpuTotal int64
+ RamTotalGB int64
+}
+
+type VcenterTotalsMeta struct {
+ ViewType string
+ TypeLabel string
+ HourlyLink string
+ DailyLink string
+ MonthlyLink string
+ HourlyClass string
+ DailyClass string
+ MonthlyClass string
+}
+
+type VcenterChartData struct {
+ PointsVm string
+ PointsVcpu string
+ PointsRam string
+ Width int
+ Height int
+ GridX []float64
+ GridY []float64
+ YTicks []ChartTick
+ XTicks []ChartTick
+}
+
+type ChartTick struct {
+ Pos float64
+ Label string
+}
+
func SnapshotHourlyList(entries []SnapshotEntry) 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
@@ -142,7 +184,7 @@ func SnapshotListPage(title string, subtitle string, entries []SnapshotEntry) te
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 36, Col: 49}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 78, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -155,7 +197,7 @@ func SnapshotListPage(title string, subtitle string, entries []SnapshotEntry) te
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(subtitle)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 37, Col: 55}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 79, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -168,7 +210,7 @@ func SnapshotListPage(title string, subtitle string, entries []SnapshotEntry) te
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(len(entries))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 46, Col: 44}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 88, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -187,7 +229,7 @@ func SnapshotListPage(title string, subtitle string, entries []SnapshotEntry) te
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Group)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 61, Col: 76}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 103, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -205,7 +247,7 @@ func SnapshotListPage(title string, subtitle string, entries []SnapshotEntry) te
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Label)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 67, Col: 75}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 109, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -218,7 +260,7 @@ func SnapshotListPage(title string, subtitle string, entries []SnapshotEntry) te
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Count)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 71, Col: 48}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 113, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -231,7 +273,7 @@ func SnapshotListPage(title string, subtitle string, entries []SnapshotEntry) te
var templ_7745c5c3_Var11 templ.SafeURL
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(entry.Link)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 74, Col: 48}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 116, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -258,4 +300,642 @@ func SnapshotListPage(title string, subtitle string, entries []SnapshotEntry) te
})
}
+func VcenterList(links []VcenterLink) 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_Var12 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var12 == nil {
+ templ_7745c5c3_Var12 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "")
+ 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, 15, "
vCenters
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var13 string
+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(len(links))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 150, Col: 42}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " total| vCenter | Totals |
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, link := range links {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "| ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var14 string
+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(link.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 163, Col: 61}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " | View Totals |
")
+ 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
+ }
+ 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, 21, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func VcenterTotalsPage(vcenter string, entries []VcenterTotalsEntry, chart VcenterChartData, meta VcenterTotalsMeta) 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_Var16 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var16 == nil {
+ templ_7745c5c3_Var16 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "")
+ 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, 23, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var28 string
+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(meta.TypeLabel)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 206, Col: 56}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " Snapshots
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var29 string
+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(len(entries))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 207, Col: 45}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " records")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if chart.PointsVm != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "| Snapshot Time | VMs | vCPUs | RAM (GB) |
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, entry := range entries {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "| ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var48 string
+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Snapshot)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 271, Col: 29}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var49 string
+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(entry.VmCount)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 272, Col: 47}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var50 string
+ templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(entry.VcpuTotal)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 273, Col: 49}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var51 string
+ templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(entry.RamTotalGB)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 274, Col: 50}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " |
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "
")
+ 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, 70, "")
+ 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 90c7965..c0f90b3 100644
--- a/db/helpers.go
+++ b/db/helpers.go
@@ -354,6 +354,388 @@ func ApplySQLiteTuning(ctx context.Context, dbConn *sqlx.DB) {
}
}
+// EnsureVmIdentityTables creates the identity and rename audit tables.
+func EnsureVmIdentityTables(ctx context.Context, dbConn *sqlx.DB) error {
+ driver := strings.ToLower(dbConn.DriverName())
+ var identityDDL, renameDDL string
+ switch driver {
+ case "pgx", "postgres":
+ identityDDL = `
+CREATE TABLE IF NOT EXISTS vm_identity (
+ "VmId" TEXT NOT NULL,
+ "VmUuid" TEXT NOT NULL,
+ "Vcenter" TEXT NOT NULL,
+ "Name" TEXT NOT NULL,
+ "Cluster" TEXT,
+ "FirstSeen" BIGINT NOT NULL,
+ "LastSeen" BIGINT NOT NULL,
+ PRIMARY KEY ("VmId","VmUuid","Vcenter")
+)`
+ renameDDL = `
+CREATE TABLE IF NOT EXISTS vm_renames (
+ "RowId" BIGSERIAL PRIMARY KEY,
+ "VmId" TEXT NOT NULL,
+ "VmUuid" TEXT NOT NULL,
+ "Vcenter" TEXT NOT NULL,
+ "OldName" TEXT,
+ "NewName" TEXT,
+ "OldCluster" TEXT,
+ "NewCluster" TEXT,
+ "SnapshotTime" BIGINT NOT NULL
+)`
+ default:
+ identityDDL = `
+CREATE TABLE IF NOT EXISTS vm_identity (
+ "VmId" TEXT NOT NULL,
+ "VmUuid" TEXT NOT NULL,
+ "Vcenter" TEXT NOT NULL,
+ "Name" TEXT NOT NULL,
+ "Cluster" TEXT,
+ "FirstSeen" BIGINT NOT NULL,
+ "LastSeen" BIGINT NOT NULL,
+ PRIMARY KEY ("VmId","VmUuid","Vcenter")
+)`
+ renameDDL = `
+CREATE TABLE IF NOT EXISTS vm_renames (
+ "RowId" INTEGER PRIMARY KEY AUTOINCREMENT,
+ "VmId" TEXT NOT NULL,
+ "VmUuid" TEXT NOT NULL,
+ "Vcenter" TEXT NOT NULL,
+ "OldName" TEXT,
+ "NewName" TEXT,
+ "OldCluster" TEXT,
+ "NewCluster" TEXT,
+ "SnapshotTime" BIGINT NOT NULL
+)`
+ }
+ if _, err := dbConn.ExecContext(ctx, identityDDL); err != nil {
+ return err
+ }
+ if _, err := dbConn.ExecContext(ctx, renameDDL); err != nil {
+ return err
+ }
+ indexes := []string{
+ `CREATE INDEX IF NOT EXISTS vm_identity_vcenter_idx ON vm_identity ("Vcenter")`,
+ `CREATE INDEX IF NOT EXISTS vm_identity_uuid_idx ON vm_identity ("VmUuid","Vcenter")`,
+ `CREATE INDEX IF NOT EXISTS vm_identity_name_idx ON vm_identity ("Name","Vcenter")`,
+ `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 {
+ return err
+ }
+ }
+ return nil
+}
+
+// UpsertVmIdentity updates/creates the identity record and records rename events.
+func UpsertVmIdentity(ctx context.Context, dbConn *sqlx.DB, vcenter string, vmId, vmUuid sql.NullString, name string, cluster sql.NullString, snapshotTime time.Time) error {
+ keyVmID := strings.TrimSpace(vmId.String)
+ keyUuid := strings.TrimSpace(vmUuid.String)
+ if keyVmID == "" || keyUuid == "" || strings.TrimSpace(vcenter) == "" {
+ return nil
+ }
+ if err := EnsureVmIdentityTables(ctx, dbConn); err != nil {
+ return err
+ }
+
+ type identityRow struct {
+ Name string `db:"Name"`
+ Cluster sql.NullString `db:"Cluster"`
+ FirstSeen sql.NullInt64 `db:"FirstSeen"`
+ LastSeen sql.NullInt64 `db:"LastSeen"`
+ }
+ var existing identityRow
+ err := dbConn.GetContext(ctx, &existing, `
+SELECT "Name","Cluster","FirstSeen","LastSeen"
+FROM vm_identity
+WHERE "Vcenter" = $1 AND "VmId" = $2 AND "VmUuid" = $3
+`, vcenter, keyVmID, keyUuid)
+
+ if err != nil {
+ if strings.Contains(strings.ToLower(err.Error()), "no rows") {
+ _, err = dbConn.ExecContext(ctx, `
+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())
+ return err
+ }
+ return err
+ }
+
+ renamed := !strings.EqualFold(existing.Name, name) || !strings.EqualFold(strings.TrimSpace(existing.Cluster.String), strings.TrimSpace(cluster.String))
+ if renamed {
+ _, _ = dbConn.ExecContext(ctx, `
+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, `
+UPDATE vm_identity
+SET "Name" = $1, "Cluster" = $2, "LastSeen" = $3
+WHERE "Vcenter" = $4 AND "VmId" = $5 AND "VmUuid" = $6
+`, name, nullString(cluster), snapshotTime.Unix(), vcenter, keyVmID, keyUuid)
+ return err
+}
+
+func nullString(val sql.NullString) interface{} {
+ if val.Valid {
+ return val.String
+ }
+ return nil
+}
+
+// EnsureVcenterTotalsTable creates the vcenter_totals table if missing.
+func EnsureVcenterTotalsTable(ctx context.Context, dbConn *sqlx.DB) error {
+ driver := strings.ToLower(dbConn.DriverName())
+ var ddl string
+ switch driver {
+ case "pgx", "postgres":
+ ddl = `
+CREATE TABLE IF NOT EXISTS vcenter_totals (
+ "RowId" BIGSERIAL PRIMARY KEY,
+ "Vcenter" TEXT NOT NULL,
+ "SnapshotTime" BIGINT NOT NULL,
+ "VmCount" BIGINT NOT NULL,
+ "VcpuTotal" BIGINT NOT NULL,
+ "RamTotalGB" BIGINT NOT NULL
+);`
+ default:
+ ddl = `
+CREATE TABLE IF NOT EXISTS vcenter_totals (
+ "RowId" INTEGER PRIMARY KEY AUTOINCREMENT,
+ "Vcenter" TEXT NOT NULL,
+ "SnapshotTime" BIGINT NOT NULL,
+ "VmCount" BIGINT NOT NULL,
+ "VcpuTotal" BIGINT NOT NULL,
+ "RamTotalGB" BIGINT NOT NULL
+);`
+ }
+ if _, err := dbConn.ExecContext(ctx, 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 {
+ return err
+ }
+ }
+ return nil
+}
+
+// InsertVcenterTotals records totals for a vcenter at a snapshot time.
+func InsertVcenterTotals(ctx context.Context, dbConn *sqlx.DB, vcenter string, snapshotTime time.Time, vmCount, vcpuTotal, ramTotal int64) error {
+ if strings.TrimSpace(vcenter) == "" {
+ return fmt.Errorf("vcenter is empty")
+ }
+ if err := EnsureVcenterTotalsTable(ctx, dbConn); err != nil {
+ return err
+ }
+ _, err := dbConn.ExecContext(ctx, `
+INSERT INTO vcenter_totals ("Vcenter","SnapshotTime","VmCount","VcpuTotal","RamTotalGB")
+VALUES ($1,$2,$3,$4,$5)
+`, vcenter, snapshotTime.Unix(), vmCount, vcpuTotal, ramTotal)
+ return err
+}
+
+// ListVcenters returns distinct vcenter URLs tracked.
+func ListVcenters(ctx context.Context, dbConn *sqlx.DB) ([]string, error) {
+ if err := EnsureVcenterTotalsTable(ctx, dbConn); err != nil {
+ return nil, err
+ }
+ rows, err := dbConn.QueryxContext(ctx, `SELECT DISTINCT "Vcenter" FROM vcenter_totals ORDER BY "Vcenter"`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []string
+ for rows.Next() {
+ var v string
+ if err := rows.Scan(&v); err != nil {
+ return nil, err
+ }
+ out = append(out, v)
+ }
+ return out, rows.Err()
+}
+
+// VcenterTotalRow holds per-snapshot totals for a vcenter.
+type VcenterTotalRow struct {
+ SnapshotTime int64 `db:"SnapshotTime"`
+ Vcenter string `db:"Vcenter"`
+ VmCount int64 `db:"VmCount"`
+ VcpuTotal int64 `db:"VcpuTotal"`
+ RamTotalGB int64 `db:"RamTotalGB"`
+}
+
+// ListVcenterTotals lists totals for a vcenter sorted by snapshot_time desc, limited.
+func ListVcenterTotals(ctx context.Context, dbConn *sqlx.DB, vcenter string, limit int) ([]VcenterTotalRow, error) {
+ if err := EnsureVcenterTotalsTable(ctx, dbConn); err != nil {
+ return nil, err
+ }
+ if limit <= 0 {
+ limit = 200
+ }
+ rows := make([]VcenterTotalRow, 0, limit)
+ query := `
+SELECT "Vcenter","SnapshotTime","VmCount","VcpuTotal","RamTotalGB"
+FROM vcenter_totals
+WHERE "Vcenter" = $1
+ORDER BY "SnapshotTime" DESC
+LIMIT $2`
+ if err := dbConn.SelectContext(ctx, &rows, query, vcenter, limit); err != nil {
+ return nil, err
+ }
+ return rows, nil
+}
+
+// ListVcenterTotalsByType returns totals for a vcenter for the requested snapshot type (hourly, daily, monthly).
+// Hourly values come from vcenter_totals; daily/monthly are derived from the summary tables referenced in snapshot_registry.
+func ListVcenterTotalsByType(ctx context.Context, dbConn *sqlx.DB, vcenter string, snapshotType string, limit int) ([]VcenterTotalRow, error) {
+ snapshotType = strings.ToLower(snapshotType)
+ if snapshotType == "" {
+ snapshotType = "hourly"
+ }
+ if snapshotType == "hourly" {
+ return ListVcenterTotals(ctx, dbConn, vcenter, limit)
+ }
+
+ if limit <= 0 {
+ limit = 200
+ }
+
+ driver := strings.ToLower(dbConn.DriverName())
+ query := `
+SELECT table_name, snapshot_time
+FROM snapshot_registry
+WHERE snapshot_type = $1
+ORDER BY snapshot_time DESC
+LIMIT $2
+`
+ if driver == "sqlite" {
+ query = strings.ReplaceAll(query, "$1", "?")
+ query = strings.ReplaceAll(query, "$2", "?")
+ }
+
+ var regRows []struct {
+ TableName string `db:"table_name"`
+ SnapshotTime int64 `db:"snapshot_time"`
+ }
+ if err := dbConn.SelectContext(ctx, ®Rows, query, snapshotType, limit); err != nil {
+ return nil, err
+ }
+
+ out := make([]VcenterTotalRow, 0, len(regRows))
+ for _, r := range regRows {
+ if err := ValidateTableName(r.TableName); err != nil {
+ continue
+ }
+ agg, err := aggregateSummaryTotals(ctx, dbConn, r.TableName, vcenter)
+ if err != nil {
+ continue
+ }
+ out = append(out, VcenterTotalRow{
+ SnapshotTime: r.SnapshotTime,
+ Vcenter: vcenter,
+ VmCount: agg.VmCount,
+ VcpuTotal: agg.VcpuTotal,
+ RamTotalGB: agg.RamTotalGB,
+ })
+ }
+ return out, nil
+}
+
+type summaryAgg struct {
+ VmCount int64 `db:"vm_count"`
+ VcpuTotal int64 `db:"vcpu_total"`
+ RamTotalGB int64 `db:"ram_total"`
+}
+
+// aggregateSummaryTotals computes totals for a single summary table (daily/monthly) for a given vcenter.
+func aggregateSummaryTotals(ctx context.Context, dbConn *sqlx.DB, tableName string, vcenter string) (summaryAgg, error) {
+ if _, err := SafeTableName(tableName); err != nil {
+ return summaryAgg{}, err
+ }
+ driver := strings.ToLower(dbConn.DriverName())
+ query := fmt.Sprintf(`
+SELECT
+ COUNT(1) AS vm_count,
+ COALESCE(SUM(COALESCE("AvgVcpuCount","VcpuCount")),0) AS vcpu_total,
+ COALESCE(SUM(COALESCE("AvgRamGB","RamGB")),0) AS ram_total
+FROM %s
+WHERE "Vcenter" = $1
+`, tableName)
+ if driver == "sqlite" {
+ query = strings.ReplaceAll(query, "$1", "?")
+ }
+ var agg summaryAgg
+ if err := dbConn.GetContext(ctx, &agg, query, vcenter); err != nil {
+ return summaryAgg{}, err
+ }
+ return agg, 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 {
+ return err
+ }
+ driver := strings.ToLower(dbConn.DriverName())
+ var hourlyTables []struct {
+ TableName string `db:"table_name"`
+ SnapshotTime int64 `db:"snapshot_time"`
+ }
+ if err := dbConn.SelectContext(ctx, &hourlyTables, `
+SELECT table_name, snapshot_time
+FROM snapshot_registry
+WHERE snapshot_type = 'hourly'
+ORDER BY snapshot_time
+`); err != nil {
+ return err
+ }
+ for _, ht := range hourlyTables {
+ if err := ValidateTableName(ht.TableName); err != nil {
+ continue
+ }
+ // Aggregate per vcenter from the snapshot table.
+ query := fmt.Sprintf(`
+SELECT "Vcenter" AS vcenter,
+ COUNT(1) AS vm_count,
+ COALESCE(SUM(CASE WHEN "VcpuCount" IS NOT NULL THEN "VcpuCount" ELSE 0 END), 0) AS vcpu_total,
+ COALESCE(SUM(CASE WHEN "RamGB" IS NOT NULL THEN "RamGB" ELSE 0 END), 0) AS ram_total
+FROM %s
+GROUP BY "Vcenter"
+`, ht.TableName)
+ type aggRow struct {
+ Vcenter string `db:"vcenter"`
+ VmCount int64 `db:"vm_count"`
+ VcpuTotal int64 `db:"vcpu_total"`
+ RamTotal int64 `db:"ram_total"`
+ }
+ var aggs []aggRow
+ if err := dbConn.SelectContext(ctx, &aggs, query); err != nil {
+ continue
+ }
+ for _, a := range aggs {
+ // Insert if missing.
+ insert := `
+INSERT INTO vcenter_totals ("Vcenter","SnapshotTime","VmCount","VcpuTotal","RamTotalGB")
+SELECT $1,$2,$3,$4,$5
+WHERE NOT EXISTS (
+ SELECT 1 FROM vcenter_totals WHERE "Vcenter" = $1 AND "SnapshotTime" = $2
+)
+`
+ if driver == "sqlite" {
+ insert = strings.ReplaceAll(insert, "$", "?")
+ }
+ _, _ = dbConn.ExecContext(ctx, insert, a.Vcenter, ht.SnapshotTime, a.VmCount, a.VcpuTotal, a.RamTotal)
+ }
+ }
+ return nil
+}
+
// AnalyzeTableIfPostgres runs ANALYZE on a table to refresh planner stats.
func AnalyzeTableIfPostgres(ctx context.Context, dbConn *sqlx.DB, tableName string) {
if _, err := SafeTableName(tableName); err != nil {
diff --git a/dist/assets/css/web3.css b/dist/assets/css/web3.css
new file mode 100644
index 0000000..f6d106d
--- /dev/null
+++ b/dist/assets/css/web3.css
@@ -0,0 +1,146 @@
+:root {
+ --web2-blue: #1d9bf0;
+ --web2-slate: #0f172a;
+ --web2-muted: #64748b;
+ --web2-card: #ffffff;
+ --web2-border: #e5e7eb;
+}
+body {
+ font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
+ color: var(--web2-slate);
+}
+.web2-bg {
+ background: #ffffff;
+}
+.web2-shell {
+ max-width: 1100px;
+ margin: 0 auto;
+ padding: 2rem 1.5rem 4rem;
+}
+.web2-header {
+ background: var(--web2-card);
+ border: 1px solid var(--web2-border);
+ border-radius: 4px;
+ padding: 1.5rem 2rem;
+}
+.web2-card {
+ background: var(--web2-card);
+ border: 1px solid var(--web2-border);
+ border-radius: 4px;
+ padding: 1.5rem 1.75rem;
+}
+.web2-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ background: #f8fafc;
+ border: 1px solid var(--web2-border);
+ color: var(--web2-muted);
+ padding: 0.2rem 0.6rem;
+ border-radius: 3px;
+ font-size: 0.85rem;
+ letter-spacing: 0.02em;
+}
+.web2-link {
+ color: var(--web2-blue);
+ text-decoration: none;
+ font-weight: 600;
+}
+.web2-link:hover {
+ text-decoration: underline;
+}
+.web2-button {
+ background: var(--web2-blue);
+ color: #fff;
+ padding: 0.45rem 0.9rem;
+ border-radius: 3px;
+ border: 1px solid #1482d0;
+ box-shadow: none;
+ font-weight: 600;
+ text-decoration: none;
+}
+.web2-button:hover {
+ background: #1787d4;
+}
+.web2-button-group {
+ display: flex;
+ flex-wrap: wrap;
+}
+.web2-button-group .web2-button {
+ margin: 0 0.5rem 0.5rem 0;
+}
+.web3-button {
+ background: #f3f4f6;
+ color: #0f172a;
+ padding: 0.5rem 1rem;
+ border-radius: 6px;
+ border: 1px solid #e5e7eb;
+ text-decoration: none;
+ font-weight: 600;
+ transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+.web3-button:hover {
+ background: #e2e8f0;
+ border-color: #cbd5e1;
+}
+.web3-button.active {
+ background: #dbeafe;
+ border-color: #93c5fd;
+ color: #1d4ed8;
+ box-shadow: 0 0 0 2px rgba(147, 197, 253, 0.35);
+}
+.web3-button-group {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ margin-top: 4px;
+}
+.web2-list li {
+ background: #ffffff;
+ border: 1px solid var(--web2-border);
+ border-radius: 3px;
+ padding: 0.75rem 1rem;
+ box-shadow: none;
+}
+.web2-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.95rem;
+}
+.web2-table thead th {
+ text-align: left;
+ padding: 0.75rem 0.5rem;
+ font-weight: 700;
+ color: var(--web2-muted);
+ border-bottom: 1px solid var(--web2-border);
+}
+.web2-table tbody td {
+ padding: 0.9rem 0.5rem;
+ border-bottom: 1px solid var(--web2-border);
+}
+.web2-table tbody tr:nth-child(odd) {
+ background: #f8fafc;
+}
+.web2-table tbody tr:nth-child(even) {
+ background: #ffffff;
+}
+.web2-group-row td {
+ background: #e8eef5;
+ color: #0f172a;
+ border-bottom: 1px solid var(--web2-border);
+ padding: 0.65rem 0.5rem;
+}
+.web2-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ border: 1px solid var(--web2-border);
+ padding: 0.15rem 0.45rem;
+ border-radius: 3px;
+ font-size: 0.8rem;
+ color: var(--web2-muted);
+ background: #f8fafc;
+}
diff --git a/internal/tasks/inventorySnapshots.go b/internal/tasks/inventorySnapshots.go
index e554312..bbaa230 100644
--- a/internal/tasks/inventorySnapshots.go
+++ b/internal/tasks/inventorySnapshots.go
@@ -824,6 +824,9 @@ func (c *CronTask) captureHourlySnapshotForVcenter(ctx context.Context, startTim
return fmt.Errorf("unable to get VMs from vcenter: %w", err)
}
c.Logger.Debug("retrieved VMs from vcenter", "url", url, "vm_count", len(vcVms))
+ if err := db.EnsureVmIdentityTables(ctx, c.Database.DB()); err != nil {
+ c.Logger.Warn("failed to ensure vm identity tables", "error", err)
+ }
hostLookup, err := vc.BuildHostLookup()
if err != nil {
c.Logger.Warn("failed to build host lookup", "url", url, "error", err)
@@ -892,6 +895,7 @@ 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)
presentSnapshots[vm.Reference().Value] = row
if row.VmUuid.Valid {
presentByUuid[row.VmUuid.String] = struct{}{}
@@ -971,6 +975,8 @@ func (c *CronTask) captureHourlySnapshotForVcenter(ctx context.Context, startTim
_ = db.UpsertSnapshotRun(ctx, c.Database.DB(), url, startTime, false, err.Error())
return err
}
+ // Record per-vCenter totals snapshot.
+ _ = db.InsertVcenterTotals(ctx, dbConn, url, startTime, totals.VmCount, totals.VcpuTotal, totals.RamTotal)
// Compare with previous snapshot for this vcenter to mark deletions at snapshot time.
if prevTable, err := latestHourlySnapshotBefore(ctx, dbConn, startTime); err == nil && prevTable != "" {
@@ -1084,13 +1090,14 @@ func (c *CronTask) markMissingFromPrevious(ctx context.Context, dbConn *sqlx.DB,
return 0
}
- query := fmt.Sprintf(`SELECT "VmId","VmUuid","Name","Datacenter","DeletionTime" FROM %s WHERE "Vcenter" = ?`, prevTable)
+ query := fmt.Sprintf(`SELECT "VmId","VmUuid","Name","Cluster","Datacenter","DeletionTime" FROM %s WHERE "Vcenter" = ?`, prevTable)
query = sqlx.Rebind(sqlx.BindType(dbConn.DriverName()), query)
type prevRow struct {
VmId sql.NullString `db:"VmId"`
VmUuid sql.NullString `db:"VmUuid"`
Name string `db:"Name"`
+ Cluster sql.NullString `db:"Cluster"`
Datacenter sql.NullString `db:"Datacenter"`
DeletionTime sql.NullInt64 `db:"DeletionTime"`
}
@@ -1111,6 +1118,7 @@ func (c *CronTask) markMissingFromPrevious(ctx context.Context, dbConn *sqlx.DB,
vmID := r.VmId.String
uuid := r.VmUuid.String
name := r.Name
+ cluster := r.Cluster.String
found := false
if vmID != "" {
@@ -1128,6 +1136,12 @@ func (c *CronTask) markMissingFromPrevious(ctx context.Context, dbConn *sqlx.DB,
found = true
}
}
+ // If the name is missing but UUID+Cluster still exists in inventory/current, treat it as present (rename, not delete).
+ if !found && uuid != "" && cluster != "" {
+ if inv, ok := invByUuid[uuid]; ok && strings.EqualFold(inv.Cluster.String, cluster) {
+ found = true
+ }
+ }
if found {
continue
}
diff --git a/server/handler/vcenters.go b/server/handler/vcenters.go
new file mode 100644
index 0000000..c2dbee3
--- /dev/null
+++ b/server/handler/vcenters.go
@@ -0,0 +1,225 @@
+package handler
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+ "vctp/components/views"
+ "vctp/db"
+)
+
+// VcenterList renders a list of vCenters being monitored.
+// @Summary List vCenters
+// @Description Lists all vCenters with recorded snapshot totals.
+// @Tags vcenters
+// @Produce text/html
+// @Success 200 {string} string "HTML page"
+// @Router /vcenters [get]
+func (h *Handler) VcenterList(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ _ = db.SyncVcenterTotalsFromSnapshots(ctx, h.Database.DB())
+ vcs, err := db.ListVcenters(ctx, h.Database.DB())
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to list vcenters: %v", err), http.StatusInternalServerError)
+ return
+ }
+ links := make([]views.VcenterLink, 0, len(vcs))
+ for _, vc := range vcs {
+ links = append(links, views.VcenterLink{
+ Name: vc,
+ Link: "/vcenters/totals?vcenter=" + url.QueryEscape(vc),
+ })
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := views.VcenterList(links).Render(ctx, w); err != nil {
+ http.Error(w, "Failed to render template", http.StatusInternalServerError)
+ }
+}
+
+// VcenterTotals renders totals for a vCenter.
+// @Summary vCenter totals
+// @Description Shows per-snapshot totals for a vCenter.
+// @Tags vcenters
+// @Produce text/html
+// @Param vcenter query string true "vCenter URL"
+// @Param type query string false "hourly|daily|monthly (default: hourly)"
+// @Param limit query int false "Limit results (default 200)"
+// @Success 200 {string} string "HTML page"
+// @Failure 400 {string} string "Missing vcenter"
+// @Router /vcenters/totals [get]
+func (h *Handler) VcenterTotals(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ vc := r.URL.Query().Get("vcenter")
+ if vc == "" {
+ http.Error(w, "vcenter is required", http.StatusBadRequest)
+ return
+ }
+ viewType := strings.ToLower(r.URL.Query().Get("type"))
+ if viewType == "" {
+ viewType = "hourly"
+ }
+ switch viewType {
+ case "hourly", "daily", "monthly":
+ default:
+ viewType = "hourly"
+ }
+ if viewType == "hourly" {
+ _ = db.SyncVcenterTotalsFromSnapshots(ctx, h.Database.DB())
+ }
+ limit := 200
+ if l := r.URL.Query().Get("limit"); l != "" {
+ if v, err := strconv.Atoi(l); err == nil && v > 0 {
+ limit = v
+ }
+ }
+ rows, err := db.ListVcenterTotalsByType(ctx, h.Database.DB(), vc, viewType, limit)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to list totals: %v", err), http.StatusInternalServerError)
+ return
+ }
+ entries := make([]views.VcenterTotalsEntry, 0, len(rows))
+ for _, row := range rows {
+ entries = append(entries, views.VcenterTotalsEntry{
+ Snapshot: time.Unix(row.SnapshotTime, 0).Local().Format("2006-01-02 15:04:05"),
+ RawTime: row.SnapshotTime,
+ VmCount: row.VmCount,
+ VcpuTotal: row.VcpuTotal,
+ RamTotalGB: row.RamTotalGB,
+ })
+ }
+ chart := buildVcenterChart(entries)
+ meta := buildVcenterMeta(vc, viewType)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := views.VcenterTotalsPage(vc, entries, chart, meta).Render(ctx, w); err != nil {
+ http.Error(w, "Failed to render template", http.StatusInternalServerError)
+ }
+}
+
+func buildVcenterMeta(vcenter string, viewType string) views.VcenterTotalsMeta {
+ active := viewType
+ if active == "" {
+ active = "hourly"
+ }
+ meta := views.VcenterTotalsMeta{
+ ViewType: active,
+ TypeLabel: "Hourly",
+ HourlyLink: "/vcenters/totals?vcenter=" + url.QueryEscape(vcenter) + "&type=hourly",
+ DailyLink: "/vcenters/totals?vcenter=" + url.QueryEscape(vcenter) + "&type=daily",
+ MonthlyLink: "/vcenters/totals?vcenter=" + url.QueryEscape(vcenter) + "&type=monthly",
+ HourlyClass: "web3-button",
+ DailyClass: "web3-button",
+ MonthlyClass: "web3-button",
+ }
+ switch active {
+ case "daily":
+ meta.TypeLabel = "Daily"
+ meta.DailyClass = "web3-button active"
+ case "monthly":
+ meta.TypeLabel = "Monthly"
+ meta.MonthlyClass = "web3-button active"
+ default:
+ meta.ViewType = "hourly"
+ meta.HourlyClass = "web3-button active"
+ }
+ return meta
+}
+
+func buildVcenterChart(entries []views.VcenterTotalsEntry) views.VcenterChartData {
+ if len(entries) == 0 {
+ return views.VcenterChartData{}
+ }
+ // Plot oldest on the left, newest on the right.
+ plot := make([]views.VcenterTotalsEntry, 0, len(entries))
+ for i := len(entries) - 1; i >= 0; i-- {
+ plot = append(plot, entries[i])
+ }
+
+ width := 1200.0
+ height := 260.0
+ maxVal := float64(0)
+ for _, e := range plot {
+ if float64(e.VmCount) > maxVal {
+ maxVal = float64(e.VmCount)
+ }
+ if float64(e.VcpuTotal) > maxVal {
+ maxVal = float64(e.VcpuTotal)
+ }
+ if float64(e.RamTotalGB) > maxVal {
+ maxVal = float64(e.RamTotalGB)
+ }
+ }
+ if maxVal == 0 {
+ maxVal = 1
+ }
+ stepX := width
+ if len(plot) > 1 {
+ stepX = width / float64(len(plot)-1)
+ }
+ pointsVm := ""
+ pointsVcpu := ""
+ pointsRam := ""
+ for i, e := range plot {
+ x := 40 + float64(i)*stepX
+ yVm := 10 + (1-(float64(e.VmCount)/maxVal))*height
+ yVcpu := 10 + (1-(float64(e.VcpuTotal)/maxVal))*height
+ yRam := 10 + (1-(float64(e.RamTotalGB)/maxVal))*height
+ if i == 0 {
+ pointsVm = fmt.Sprintf("%.1f,%.1f", x, yVm)
+ pointsVcpu = fmt.Sprintf("%.1f,%.1f", x, yVcpu)
+ pointsRam = fmt.Sprintf("%.1f,%.1f", x, yRam)
+ } else {
+ pointsVm = pointsVm + " " + fmt.Sprintf("%.1f,%.1f", x, yVm)
+ pointsVcpu = pointsVcpu + " " + fmt.Sprintf("%.1f,%.1f", x, yVcpu)
+ pointsRam = pointsRam + " " + fmt.Sprintf("%.1f,%.1f", x, yRam)
+ }
+ }
+ gridX := []float64{}
+ if len(plot) > 1 {
+ for i := 0; i < len(plot); i++ {
+ gridX = append(gridX, 40+float64(i)*stepX)
+ }
+ }
+ gridY := []float64{}
+ for i := 0; i <= 4; i++ {
+ gridY = append(gridY, 10+float64(i)*(height/4))
+ }
+ 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 := 6
+ stepIdx := 1
+ if len(plot) > 1 {
+ stepIdx = (len(plot)-1)/maxTicks + 1
+ }
+ for idx := 0; idx < len(plot); idx += stepIdx {
+ x := 40 + float64(idx)*stepX
+ label := time.Unix(plot[idx].RawTime, 0).Local().Format("01-02 15:04")
+ xTicks = append(xTicks, views.ChartTick{Pos: x, Label: label})
+ }
+ if len(plot) > 1 {
+ lastIdx := len(plot) - 1
+ if (lastIdx % stepIdx) != 0 {
+ x := 40 + float64(lastIdx)*stepX
+ label := time.Unix(plot[lastIdx].RawTime, 0).Local().Format("01-02 15:04")
+ xTicks = append(xTicks, views.ChartTick{Pos: x, Label: label})
+ }
+ }
+ return views.VcenterChartData{
+ PointsVm: pointsVm,
+ PointsVcpu: pointsVcpu,
+ PointsRam: pointsRam,
+ Width: int(width),
+ Height: int(height),
+ GridX: gridX,
+ GridY: gridY,
+ YTicks: yTicks,
+ XTicks: xTicks,
+ }
+}
diff --git a/server/router/docs/docs.go b/server/router/docs/docs.go
index 8ade03d..3ff3968 100644
--- a/server/router/docs/docs.go
+++ b/server/router/docs/docs.go
@@ -854,6 +854,73 @@ const docTemplate = `{
}
}
}
+ },
+ "/vcenters": {
+ "get": {
+ "description": "Lists all vCenters with recorded snapshot totals.",
+ "produces": [
+ "text/html"
+ ],
+ "tags": [
+ "vcenters"
+ ],
+ "summary": "List vCenters",
+ "responses": {
+ "200": {
+ "description": "HTML page",
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "/vcenters/totals": {
+ "get": {
+ "description": "Shows per-snapshot totals for a vCenter.",
+ "produces": [
+ "text/html"
+ ],
+ "tags": [
+ "vcenters"
+ ],
+ "summary": "vCenter totals",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "vCenter URL",
+ "name": "vcenter",
+ "in": "query",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "hourly|daily|monthly (default: hourly)",
+ "name": "type",
+ "in": "query"
+ },
+ {
+ "type": "integer",
+ "description": "Limit results (default 200)",
+ "name": "limit",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "HTML page",
+ "schema": {
+ "type": "string"
+ }
+ },
+ "400": {
+ "description": "Missing vcenter",
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ }
}
},
"definitions": {
diff --git a/server/router/docs/swagger.json b/server/router/docs/swagger.json
index 5ea2f1f..ecc5560 100644
--- a/server/router/docs/swagger.json
+++ b/server/router/docs/swagger.json
@@ -843,6 +843,73 @@
}
}
}
+ },
+ "/vcenters": {
+ "get": {
+ "description": "Lists all vCenters with recorded snapshot totals.",
+ "produces": [
+ "text/html"
+ ],
+ "tags": [
+ "vcenters"
+ ],
+ "summary": "List vCenters",
+ "responses": {
+ "200": {
+ "description": "HTML page",
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "/vcenters/totals": {
+ "get": {
+ "description": "Shows per-snapshot totals for a vCenter.",
+ "produces": [
+ "text/html"
+ ],
+ "tags": [
+ "vcenters"
+ ],
+ "summary": "vCenter totals",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "vCenter URL",
+ "name": "vcenter",
+ "in": "query",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "hourly|daily|monthly (default: hourly)",
+ "name": "type",
+ "in": "query"
+ },
+ {
+ "type": "integer",
+ "description": "Limit results (default 200)",
+ "name": "limit",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "HTML page",
+ "schema": {
+ "type": "string"
+ }
+ },
+ "400": {
+ "description": "Missing vcenter",
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ }
}
},
"definitions": {
diff --git a/server/router/docs/swagger.yaml b/server/router/docs/swagger.yaml
index 91dd989..93fa38b 100644
--- a/server/router/docs/swagger.yaml
+++ b/server/router/docs/swagger.yaml
@@ -720,4 +720,48 @@ paths:
summary: List monthly snapshots
tags:
- snapshots
+ /vcenters:
+ get:
+ description: Lists all vCenters with recorded snapshot totals.
+ produces:
+ - text/html
+ responses:
+ "200":
+ description: HTML page
+ schema:
+ type: string
+ summary: List vCenters
+ tags:
+ - vcenters
+ /vcenters/totals:
+ get:
+ description: Shows per-snapshot totals for a vCenter.
+ parameters:
+ - description: vCenter URL
+ in: query
+ name: vcenter
+ required: true
+ type: string
+ - description: 'hourly|daily|monthly (default: hourly)'
+ in: query
+ name: type
+ type: string
+ - description: Limit results (default 200)
+ in: query
+ name: limit
+ type: integer
+ produces:
+ - text/html
+ responses:
+ "200":
+ description: HTML page
+ schema:
+ type: string
+ "400":
+ description: Missing vcenter
+ schema:
+ type: string
+ summary: vCenter totals
+ tags:
+ - vcenters
swagger: "2.0"
diff --git a/server/router/router.go b/server/router/router.go
index ba34ac6..1728b95 100644
--- a/server/router/router.go
+++ b/server/router/router.go
@@ -66,6 +66,8 @@ 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("/vcenters", h.VcenterList)
+ mux.HandleFunc("/vcenters/totals", h.VcenterTotals)
mux.HandleFunc("/metrics", h.Metrics)
mux.HandleFunc("/snapshots/hourly", h.SnapshotHourlyList)