diff --git a/components/core/header.templ b/components/core/header.templ index db86675..979fcf4 100644 --- a/components/core/header.templ +++ b/components/core/header.templ @@ -13,124 +13,6 @@ templ Header() { - + } diff --git a/components/core/header_templ.go b/components/core/header_templ.go index d7f9f35..0d57eba 100644 --- a/components/core/header_templ.go +++ b/components/core/header_templ.go @@ -44,7 +44,7 @@ func Header() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" rel=\"stylesheet\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" rel=\"stylesheet\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/components/views/index.templ b/components/views/index.templ index 50d101c..b1b3756 100644 --- a/components/views/index.templ +++ b/components/views/index.templ @@ -27,6 +27,7 @@ templ Index(info BuildInfo) { Hourly Snapshots Daily Snapshots Monthly Snapshots + vCenters Swagger UI diff --git a/components/views/index_templ.go b/components/views/index_templ.go index 21ec093..d525221 100644 --- a/components/views/index_templ.go +++ b/components/views/index_templ.go @@ -47,14 +47,14 @@ func Index(info BuildInfo) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
vCTP Console

Chargeback Intelligence Dashboard

Point in time snapshots of consumption.

Build Time

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

vCTP Console

Chargeback Intelligence Dashboard

Point in time snapshots of consumption.

Build Time

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(info.BuildTime) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/index.templ`, Line: 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() + +

+
+
+
+
vCenter Inventory
+

Monitored vCenters

+

Select a vCenter to view snapshot totals over time.

+
+ Back to Dashboard +
+
+ +
+
+

vCenters

+ {len(links)} total +
+
+ + + + + + + + + for _, link := range links { + + + + + } + +
vCenterTotals
{link.Name} + View Totals +
+
+
+
+ + @core.Footer() + +} + +templ VcenterTotalsPage(vcenter string, entries []VcenterTotalsEntry, chart VcenterChartData, meta VcenterTotalsMeta) { + + + @core.Header() + +
+
+
+
+
vCenter Totals
+

Totals for {vcenter}

+

{meta.TypeLabel} snapshots of VM count, vCPU, and RAM over time.

+
+ +
+
+ Hourly + Daily + Monthly +
+
+ +
+
+

{meta.TypeLabel} Snapshots

+ {len(entries)} records +
+ if chart.PointsVm != "" { +
+ + + + + + + + + + for _, y := range chart.GridY { + + } + for _, x := range chart.GridX { + + } + + + + + + + + + + + for _, tick := range chart.YTicks { + {tick.Label} + } + + + for _, tick := range chart.XTicks { + {tick.Label} + } + + + + VMs + vCPU + RAM (GB) + + + Totals + Snapshot sequence (newest right) + +
+ } + +
+ + + + + + + + + + + for _, entry := range entries { + + + + + + + } + +
Snapshot TimeVMsvCPUsRAM (GB)
{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, "
vCenter Inventory

Monitored vCenters

Select a vCenter to view snapshot totals over time.

Back to Dashboard

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
") + 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 + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
vCenterTotals
") + 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 = 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, "
vCenter Totals

Totals for ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(vcenter) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 189, Col: 63} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(meta.TypeLabel) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 190, Col: 62} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " snapshots of VM count, vCPU, and RAM over time.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 = []any{meta.HourlyClass} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var19...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "Hourly ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var22 = []any{meta.DailyClass} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var22...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "Daily ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var25 = []any{meta.MonthlyClass} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var25...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "Monthly

") + 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 + } + for _, y := range chart.GridY { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + for _, x := range chart.GridX { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "") + 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 + } + for _, tick := range chart.YTicks { + 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(tick.Label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 237, Col: 70} + } + _, 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, 54, "") + 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 + } + for _, tick := range chart.XTicks { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var44 string + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(tick.Label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/snapshots.templ`, Line: 242, Col: 69} + } + _, 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, 58, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "VMs vCPU RAM (GB)Totals Snapshot sequence (newest right)
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") + 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 + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "
Snapshot TimeVMsvCPUsRAM (GB)
") + 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 = 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)