bugfix reports
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-01-14 13:51:30 +11:00
parent 013ae4568e
commit b9ab34db0a
13 changed files with 404 additions and 115 deletions

View File

@@ -113,12 +113,10 @@ func CreateInventoryReport(logger *slog.Logger, Database db.Database, ctx contex
}
// Set column autowidth
/*
err = SetColAutoWidth(xlsx, sheetName)
if err != nil {
fmt.Printf("Error setting auto width : '%s'\n", err)
}
*/
err = SetColAutoWidth(xlsx, sheetName)
if err != nil {
logger.Error("Error setting auto width", "error", err)
}
// Save the Excel file into a byte buffer
if err := xlsx.Write(&buffer); err != nil {
@@ -226,12 +224,10 @@ func CreateUpdatesReport(logger *slog.Logger, Database db.Database, ctx context.
}
// Set column autowidth
/*
err = SetColAutoWidth(xlsx, sheetName)
if err != nil {
fmt.Printf("Error setting auto width : '%s'\n", err)
}
*/
err = SetColAutoWidth(xlsx, sheetName)
if err != nil {
logger.Error("Error setting auto width", "error", err)
}
// Save the Excel file into a byte buffer
if err := xlsx.Write(&buffer); err != nil {

View File

@@ -412,6 +412,11 @@ func CreateTableReport(logger *slog.Logger, Database db.Database, ctx context.Co
}
dbConn := Database.DB()
if strings.HasPrefix(tableName, "inventory_daily_summary_") || strings.HasPrefix(tableName, "inventory_monthly_summary_") {
if err := ensureSummaryReportColumns(ctx, dbConn, tableName); err != nil {
logger.Warn("Unable to ensure summary columns for report", "error", err, "table", tableName)
}
}
columns, err := tableColumns(ctx, dbConn, tableName)
if err != nil {
return nil, err
@@ -424,27 +429,68 @@ func CreateTableReport(logger *slog.Logger, Database db.Database, ctx context.Co
isDailySummary := strings.HasPrefix(tableName, "inventory_daily_summary_")
isMonthlySummary := strings.HasPrefix(tableName, "inventory_monthly_summary_")
hideInventoryID := isHourlySnapshot || isDailySummary || isMonthlySummary
hideRowID := isHourlySnapshot || isDailySummary || isMonthlySummary
humanizeTimes := isDailySummary || isMonthlySummary
applyTemplateFilter := isHourlySnapshot || isDailySummary || isMonthlySummary
type columnSpec struct {
Name string
SourceIndex int
Humanize bool
}
specs := make([]columnSpec, 0, len(columns)+2)
columnIndex := make(map[string]int, len(columns))
for i, columnName := range columns {
columnIndex[strings.ToLower(columnName)] = i
}
used := make(map[string]struct{}, len(columns))
addSpec := func(columnName string, sourceIndex int) {
if hideInventoryID && strings.EqualFold(columnName, "InventoryId") {
continue
return
}
specs = append(specs, columnSpec{Name: columnName, SourceIndex: i})
if hideRowID && strings.EqualFold(columnName, "RowId") {
return
}
if strings.EqualFold(columnName, "VmType") {
return
}
if (isDailySummary || isMonthlySummary) && (strings.EqualFold(columnName, "EventKey") || strings.EqualFold(columnName, "CloudId")) {
return
}
if (isDailySummary || isMonthlySummary) && strings.EqualFold(columnName, "Gold") {
return
}
specs = append(specs, columnSpec{Name: columnName, SourceIndex: sourceIndex})
if humanizeTimes && columnName == "CreationTime" {
specs = append(specs, columnSpec{Name: "CreationTimeReadable", SourceIndex: i, Humanize: true})
specs = append(specs, columnSpec{Name: "CreationTimeReadable", SourceIndex: sourceIndex, Humanize: true})
}
if humanizeTimes && columnName == "DeletionTime" {
specs = append(specs, columnSpec{Name: "DeletionTimeReadable", SourceIndex: i, Humanize: true})
specs = append(specs, columnSpec{Name: "DeletionTimeReadable", SourceIndex: sourceIndex, Humanize: true})
}
}
if isDailySummary || isMonthlySummary {
for _, columnName := range summaryReportOrder() {
if idx, ok := columnIndex[strings.ToLower(columnName)]; ok {
addSpec(columnName, idx)
used[strings.ToLower(columnName)] = struct{}{}
} else {
logger.Warn("Summary report column missing from table", "table", tableName, "column", columnName)
addSpec(columnName, -1)
}
}
} else {
for i, columnName := range columns {
if _, ok := used[strings.ToLower(columnName)]; ok {
continue
}
addSpec(columnName, i)
}
}
query := fmt.Sprintf(`SELECT * FROM %s`, tableName)
if applyTemplateFilter && hasColumn(columns, "IsTemplate") {
query = fmt.Sprintf(`%s WHERE %s`, query, templateExclusionFilter())
}
orderBy := snapshotOrderBy(columns)
if orderBy != "" {
query = fmt.Sprintf(`%s ORDER BY "%s" DESC`, query, orderBy)
@@ -471,7 +517,11 @@ func CreateTableReport(logger *slog.Logger, Database db.Database, ctx context.Co
}
for i, spec := range specs {
cell := fmt.Sprintf("%s1", string(rune('A'+i)))
cell, err := excelize.CoordinatesToCellName(i+1, 1)
if err != nil {
logger.Error("Error determining header cell", "error", err, "column", i+1)
continue
}
xlsx.SetCellValue(sheetName, cell, spec.Name)
}
@@ -500,7 +550,15 @@ func CreateTableReport(logger *slog.Logger, Database db.Database, ctx context.Co
return nil, err
}
for colIndex, spec := range specs {
cell := fmt.Sprintf("%s%d", string(rune('A'+colIndex)), rowIndex)
cell, err := excelize.CoordinatesToCellName(colIndex+1, rowIndex)
if err != nil {
logger.Error("Error determining data cell", "error", err, "column", colIndex+1, "row", rowIndex)
continue
}
if spec.SourceIndex < 0 || spec.SourceIndex >= len(values) {
xlsx.SetCellValue(sheetName, cell, "")
continue
}
value := values[spec.SourceIndex]
if spec.Humanize {
xlsx.SetCellValue(sheetName, cell, formatEpochHuman(value))
@@ -528,8 +586,20 @@ func CreateTableReport(logger *slog.Logger, Database db.Database, ctx context.Co
logger.Error("Error freezing top row", "error", err)
}
if err := SetColAutoWidth(xlsx, sheetName); err != nil {
logger.Error("Error setting auto width", "error", err)
}
if isDailySummary || isMonthlySummary {
addReportMetadataSheet(logger, xlsx)
}
addTotalsChartSheet(logger, Database, ctx, xlsx, tableName)
if index, err := xlsx.GetSheetIndex(sheetName); err == nil {
xlsx.SetActiveSheet(index)
}
if err := xlsx.Write(&buffer); err != nil {
return nil, err
}
@@ -668,6 +738,133 @@ func snapshotOrderBy(columns []string) string {
return ""
}
func hasColumn(columns []string, name string) bool {
for _, col := range columns {
if strings.EqualFold(col, name) {
return true
}
}
return false
}
func templateExclusionFilter() string {
return `COALESCE(CAST("IsTemplate" AS TEXT), '') NOT IN ('TRUE', 'true', '1')`
}
func quoteSheetName(name string) string {
escaped := strings.ReplaceAll(name, "'", "''")
return fmt.Sprintf("'%s'", escaped)
}
type columnDef struct {
Name string
Type string
}
func ensureSummaryReportColumns(ctx context.Context, dbConn *sqlx.DB, tableName string) error {
if err := validateTableName(tableName); err != nil {
return err
}
columns, err := tableColumns(ctx, dbConn, tableName)
if err != nil {
return err
}
existing := make(map[string]struct{}, len(columns))
for _, col := range columns {
existing[strings.ToLower(col)] = struct{}{}
}
for _, column := range summaryReportColumns() {
if _, ok := existing[strings.ToLower(column.Name)]; ok {
continue
}
query := fmt.Sprintf(`ALTER TABLE %s ADD COLUMN "%s" %s`, tableName, column.Name, column.Type)
if _, err := dbConn.ExecContext(ctx, query); err != nil {
return err
}
}
return nil
}
func summaryReportColumns() []columnDef {
return []columnDef{
{Name: "InventoryId", Type: "BIGINT"},
{Name: "Name", Type: "TEXT"},
{Name: "Vcenter", Type: "TEXT"},
{Name: "VmId", Type: "TEXT"},
{Name: "EventKey", Type: "TEXT"},
{Name: "CloudId", Type: "TEXT"},
{Name: "CreationTime", Type: "BIGINT"},
{Name: "DeletionTime", Type: "BIGINT"},
{Name: "ResourcePool", Type: "TEXT"},
{Name: "Datacenter", Type: "TEXT"},
{Name: "Cluster", Type: "TEXT"},
{Name: "Folder", Type: "TEXT"},
{Name: "ProvisionedDisk", Type: "REAL"},
{Name: "VcpuCount", Type: "BIGINT"},
{Name: "RamGB", Type: "BIGINT"},
{Name: "IsTemplate", Type: "TEXT"},
{Name: "PoweredOn", Type: "TEXT"},
{Name: "SrmPlaceholder", Type: "TEXT"},
{Name: "VmUuid", Type: "TEXT"},
{Name: "SamplesPresent", Type: "BIGINT"},
{Name: "AvgVcpuCount", Type: "REAL"},
{Name: "AvgRamGB", Type: "REAL"},
{Name: "AvgProvisionedDisk", Type: "REAL"},
{Name: "AvgIsPresent", Type: "REAL"},
{Name: "PoolTinPct", Type: "REAL"},
{Name: "PoolBronzePct", Type: "REAL"},
{Name: "PoolSilverPct", Type: "REAL"},
{Name: "PoolGoldPct", Type: "REAL"},
{Name: "Tin", Type: "REAL"},
{Name: "Bronze", Type: "REAL"},
{Name: "Silver", Type: "REAL"},
{Name: "Gold", Type: "REAL"},
}
}
func summaryReportOrder() []string {
return []string{
"Name",
"Vcenter",
"VmId",
"CreationTime",
"DeletionTime",
"ResourcePool",
"Datacenter",
"Cluster",
"Folder",
"ProvisionedDisk",
"VcpuCount",
"RamGB",
"IsTemplate",
"PoweredOn",
"SrmPlaceholder",
"VmUuid",
"SamplesPresent",
"AvgVcpuCount",
"AvgRamGB",
"AvgProvisionedDisk",
"AvgIsPresent",
"PoolTinPct",
"PoolBronzePct",
"PoolSilverPct",
"PoolGoldPct",
}
}
func addReportMetadataSheet(logger *slog.Logger, xlsx *excelize.File) {
sheetName := "Metadata"
if _, err := xlsx.NewSheet(sheetName); err != nil {
logger.Error("Error creating metadata sheet", "error", err)
return
}
xlsx.SetCellValue(sheetName, "A1", "ReportGeneratedAt")
xlsx.SetCellValue(sheetName, "B1", time.Now().Format(time.RFC3339))
if err := SetColAutoWidth(xlsx, sheetName); err != nil {
logger.Error("Error setting metadata auto width", "error", err)
}
}
func scanRowValues(rows *sqlx.Rows, columnCount int) ([]interface{}, error) {
rawValues := make([]interface{}, columnCount)
scanArgs := make([]interface{}, columnCount)
@@ -722,7 +919,8 @@ SELECT
COALESCE(SUM(CASE WHEN "IsPresent" = 'TRUE' AND LOWER("ResourcePool") = 'silver' THEN 1 ELSE 0 END), 0) AS silver_total,
COALESCE(SUM(CASE WHEN "IsPresent" = 'TRUE' AND LOWER("ResourcePool") = 'gold' THEN 1 ELSE 0 END), 0) AS gold_total
FROM %s
`, record.TableName)
WHERE %s
`, record.TableName, templateExclusionFilter())
var row struct {
VmCount int64 `db:"vm_count"`
VcpuTotal int64 `db:"vcpu_total"`
@@ -768,7 +966,8 @@ SELECT
COALESCE(SUM(CASE WHEN "Silver" IS NOT NULL THEN "Silver" ELSE 0 END) / 100.0, 0) AS silver_total,
COALESCE(SUM(CASE WHEN "Gold" IS NOT NULL THEN "Gold" ELSE 0 END) / 100.0, 0) AS gold_total
FROM %s
`, record.TableName)
WHERE %s
`, record.TableName, templateExclusionFilter())
var row struct {
VmCount int64 `db:"vm_count"`
VcpuTotal float64 `db:"vcpu_total"`
@@ -808,7 +1007,7 @@ func writeTotalsChart(logger *slog.Logger, xlsx *excelize.File, sheetName string
}
xlsx.SetActiveSheet(index)
headers := []string{"Label", "VmCount", "VcpuCount", "RamGB", "PresenceRatio", "Tin", "Bronze", "Silver", "Gold"}
headers := []string{"Label", "VmCount", "VcpuCount", "RamGB", "ProratedVmCount", "Tin", "Bronze", "Silver", "Gold"}
for i, header := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
xlsx.SetCellValue(sheetName, cell, header)
@@ -826,26 +1025,47 @@ func writeTotalsChart(logger *slog.Logger, xlsx *excelize.File, sheetName string
xlsx.SetCellValue(sheetName, fmt.Sprintf("I%d", row), point.GoldTotal)
}
lastRow := len(points) + 1
series := []excelize.ChartSeries{
{Name: fmt.Sprintf("%s!$B$1", sheetName), Categories: fmt.Sprintf("%s!$A$2:$A$%d", sheetName, lastRow), Values: fmt.Sprintf("%s!$B$2:$B$%d", sheetName, lastRow)},
{Name: fmt.Sprintf("%s!$C$1", sheetName), Categories: fmt.Sprintf("%s!$A$2:$A$%d", sheetName, lastRow), Values: fmt.Sprintf("%s!$C$2:$C$%d", sheetName, lastRow)},
{Name: fmt.Sprintf("%s!$D$1", sheetName), Categories: fmt.Sprintf("%s!$A$2:$A$%d", sheetName, lastRow), Values: fmt.Sprintf("%s!$D$2:$D$%d", sheetName, lastRow)},
{Name: fmt.Sprintf("%s!$E$1", sheetName), Categories: fmt.Sprintf("%s!$A$2:$A$%d", sheetName, lastRow), Values: fmt.Sprintf("%s!$E$2:$E$%d", sheetName, lastRow)},
{Name: fmt.Sprintf("%s!$F$1", sheetName), Categories: fmt.Sprintf("%s!$A$2:$A$%d", sheetName, lastRow), Values: fmt.Sprintf("%s!$F$2:$F$%d", sheetName, lastRow)},
{Name: fmt.Sprintf("%s!$G$1", sheetName), Categories: fmt.Sprintf("%s!$A$2:$A$%d", sheetName, lastRow), Values: fmt.Sprintf("%s!$G$2:$G$%d", sheetName, lastRow)},
{Name: fmt.Sprintf("%s!$H$1", sheetName), Categories: fmt.Sprintf("%s!$A$2:$A$%d", sheetName, lastRow), Values: fmt.Sprintf("%s!$H$2:$H$%d", sheetName, lastRow)},
{Name: fmt.Sprintf("%s!$I$1", sheetName), Categories: fmt.Sprintf("%s!$A$2:$A$%d", sheetName, lastRow), Values: fmt.Sprintf("%s!$I$2:$I$%d", sheetName, lastRow)},
if endCell, err := excelize.CoordinatesToCellName(len(headers), 1); err == nil {
filterRange := "A1:" + endCell
if err := xlsx.AutoFilter(sheetName, filterRange, nil); err != nil {
logger.Error("Error setting totals autofilter", "error", err)
}
}
chart := excelize.Chart{
Type: excelize.Line,
Series: series,
Legend: excelize.ChartLegend{Position: "bottom"},
if err := SetColAutoWidth(xlsx, sheetName); err != nil {
logger.Error("Error setting totals auto width", "error", err)
}
if err := xlsx.AddChart(sheetName, "G2", &chart); err != nil {
logger.Error("Error adding totals chart", "error", err)
lastRow := len(points) + 1
sheetRef := quoteSheetName(sheetName)
categories := fmt.Sprintf("%s!$A$2:$A$%d", sheetRef, lastRow)
buildSeries := func(col string) excelize.ChartSeries {
return excelize.ChartSeries{
Name: fmt.Sprintf("%s!$%s$1", sheetRef, col),
Categories: categories,
Values: fmt.Sprintf("%s!$%s$2:$%s$%d", sheetRef, col, col, lastRow),
}
}
makeChart := func(anchor string, cols ...string) {
series := make([]excelize.ChartSeries, 0, len(cols))
for _, col := range cols {
series = append(series, buildSeries(col))
}
chart := excelize.Chart{
Type: excelize.Line,
Series: series,
Legend: excelize.ChartLegend{Position: "bottom"},
XAxis: excelize.ChartAxis{MajorGridLines: true},
YAxis: excelize.ChartAxis{MajorGridLines: true},
}
if err := xlsx.AddChart(sheetName, anchor, &chart); err != nil {
logger.Error("Error adding totals chart", "error", err, "anchor", anchor)
}
}
makeChart("K2", "B", "E")
makeChart("K18", "C", "D")
makeChart("K34", "F", "G", "H", "I")
}
func formatEpochHuman(value interface{}) string {