add vcenter totals line graph
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-01-16 12:36:53 +11:00
parent 268919219e
commit 871904f63e
14 changed files with 1841 additions and 132 deletions

View File

@@ -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, &regRows, 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 {