diff --git a/db/queries/query.sql b/db/queries/query.sql index 89837ff..094ddf2 100644 --- a/db/queries/query.sql +++ b/db/queries/query.sql @@ -44,6 +44,10 @@ INSERT INTO "Updates" ( ) RETURNING *; +-- name: GetReportUpdates :many +SELECT * FROM "Updates" +ORDER BY "UpdateTime"; + -- name: CleanupUpdates :exec DELETE FROM "Updates" WHERE "UpdateType" = sqlc.arg('updateType') AND "UpdateTime" <= sqlc.arg('updateTime') diff --git a/db/queries/query.sql.go b/db/queries/query.sql.go index e2f7151..19c2368 100644 --- a/db/queries/query.sql.go +++ b/db/queries/query.sql.go @@ -379,6 +379,46 @@ func (q *Queries) GetReportInventory(ctx context.Context) ([]Inventory, error) { return items, nil } +const getReportUpdates = `-- name: GetReportUpdates :many +SELECT Uid, InventoryId, UpdateTime, UpdateType, NewVcpus, NewRam, NewResourcePool, EventKey, EventId, NewProvisionedDisk, UserName FROM "Updates" +ORDER BY "UpdateTime" +` + +func (q *Queries) GetReportUpdates(ctx context.Context) ([]Updates, error) { + rows, err := q.db.QueryContext(ctx, getReportUpdates) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Updates + for rows.Next() { + var i Updates + if err := rows.Scan( + &i.Uid, + &i.InventoryId, + &i.UpdateTime, + &i.UpdateType, + &i.NewVcpus, + &i.NewRam, + &i.NewResourcePool, + &i.EventKey, + &i.EventId, + &i.NewProvisionedDisk, + &i.UserName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const inventoryCleanup = `-- name: InventoryCleanup :exec DELETE FROM "Inventory" WHERE "VmId" = ?1 AND "Datacenter" = ?2 diff --git a/internal/report/create.go b/internal/report/create.go index ed13500..ccfe8f0 100644 --- a/internal/report/create.go +++ b/internal/report/create.go @@ -9,15 +9,17 @@ import ( "reflect" "strconv" "time" + "unicode/utf8" "vctp/db" "github.com/xuri/excelize/v2" ) -func CreateReport(logger *slog.Logger, Database db.Database, ctx context.Context) ([]byte, error) { +func CreateInventoryReport(logger *slog.Logger, Database db.Database, ctx context.Context) ([]byte, error) { //var xlsx *excelize.File sheetName := "Inventory Report" var buffer bytes.Buffer + var cell string logger.Debug("Querying inventory table") results, err := Database.Queries().GetReportInventory(ctx) @@ -31,7 +33,7 @@ func CreateReport(logger *slog.Logger, Database db.Database, ctx context.Context return nil, fmt.Errorf("Empty inventory results") } - // Create excek workbook + // Create excel workbook xlsx := excelize.NewFile() err = xlsx.SetSheetName("Sheet1", sheetName) if err != nil { @@ -59,6 +61,31 @@ func CreateReport(logger *slog.Logger, Database db.Database, ctx context.Context xlsx.SetCellValue(sheetName, column, typeOfItem.Field(i).Name) } + // Set autofilter on heading row + cell, _ = excelize.CoordinatesToCellName(v.NumField(), 1) + filterRange := "A1:" + cell + logger.Debug("Setting autofilter", "range", filterRange) + // As per docs any filters applied need to be manually processed by us (eg hiding rows with blanks) + err = xlsx.AutoFilter(sheetName, filterRange, nil) + if err != nil { + logger.Error("Error setting autofilter", "error", err) + } + + // Bold top row + headerStyle, err := xlsx.NewStyle(&excelize.Style{ + Font: &excelize.Font{ + Bold: true, + }, + }) + if err != nil { + logger.Error("Error generating header style", "error", err) + } else { + err = xlsx.SetRowStyle(sheetName, 1, 1, headerStyle) + if err != nil { + logger.Error("Error setting header style", "error", err) + } + } + // Populate the Excel file with data from the Inventory table for i, item := range results { v = reflect.ValueOf(item) @@ -69,6 +96,139 @@ func CreateReport(logger *slog.Logger, Database db.Database, ctx context.Context } } + // Freeze top row + err = xlsx.SetPanes(sheetName, &excelize.Panes{ + Freeze: true, + Split: false, + XSplit: 0, + YSplit: 1, + TopLeftCell: "A2", + ActivePane: "bottomLeft", + Selection: []excelize.Selection{ + {SQRef: "A2", ActiveCell: "A2", Pane: "bottomLeft"}, + }, + }) + if err != nil { + logger.Error("Error freezing top row", "error", err) + } + + // Set column autowidth + err = SetColAutoWidth(xlsx, sheetName) + if err != nil { + fmt.Printf("Error setting auto width : '%s'\n", err) + } + + // Save the Excel file into a byte buffer + if err := xlsx.Write(&buffer); err != nil { + return nil, err + } + + return buffer.Bytes(), nil +} + +func CreateUpdatesReport(logger *slog.Logger, Database db.Database, ctx context.Context) ([]byte, error) { + //var xlsx *excelize.File + sheetName := "Updates Report" + var buffer bytes.Buffer + var cell string + + logger.Debug("Querying updates table") + results, err := Database.Queries().GetReportUpdates(ctx) + if err != nil { + logger.Error("Unable to query updates table", "error", err) + return nil, err + } + + if len(results) == 0 { + logger.Error("Empty updates results") + return nil, fmt.Errorf("Empty updates results") + } + + // Create excel workbook + xlsx := excelize.NewFile() + err = xlsx.SetSheetName("Sheet1", sheetName) + if err != nil { + logger.Error("Error setting sheet name", "error", err, "sheet_name", sheetName) + return nil, err + } + + // Set the document properties + err = xlsx.SetDocProps(&excelize.DocProperties{ + Creator: "json2excel", + Created: time.Now().Format(time.RFC3339), + }) + if err != nil { + logger.Error("Error setting document properties", "error", err, "sheet_name", sheetName) + } + + // Use reflection to determine column headings from the first item + firstItem := results[0] + v := reflect.ValueOf(firstItem) + typeOfItem := v.Type() + + // Create column headers dynamically + for i := 0; i < v.NumField(); i++ { + column := string(rune('A'+i)) + "1" // A1, B1, C1, etc. + xlsx.SetCellValue(sheetName, column, typeOfItem.Field(i).Name) + } + + // Set autofilter on heading row + cell, _ = excelize.CoordinatesToCellName(v.NumField(), 1) + filterRange := "A1:" + cell + logger.Debug("Setting autofilter", "range", filterRange) + // As per docs any filters applied need to be manually processed by us (eg hiding rows with blanks) + err = xlsx.AutoFilter(sheetName, filterRange, nil) + if err != nil { + logger.Error("Error setting autofilter", "error", err) + } + + // Bold top row + headerStyle, err := xlsx.NewStyle(&excelize.Style{ + Font: &excelize.Font{ + Bold: true, + }, + }) + if err != nil { + logger.Error("Error generating header style", "error", err) + } else { + err = xlsx.SetRowStyle(sheetName, 1, 1, headerStyle) + if err != nil { + logger.Error("Error setting header style", "error", err) + } + } + + // Populate the Excel file with data from the Inventory table + for i, item := range results { + v = reflect.ValueOf(item) + for j := 0; j < v.NumField(); j++ { + column := string(rune('A'+j)) + strconv.Itoa(i+2) // Start from row 2 + value := getFieldValue(v.Field(j)) + xlsx.SetCellValue(sheetName, column, value) + } + } + + // Freeze top row + err = xlsx.SetPanes(sheetName, &excelize.Panes{ + Freeze: true, + Split: false, + XSplit: 0, + YSplit: 1, + TopLeftCell: "A2", + ActivePane: "bottomLeft", + Selection: []excelize.Selection{ + {SQRef: "A2", ActiveCell: "A2", Pane: "bottomLeft"}, + }, + }) + if err != nil { + logger.Error("Error freezing top row", "error", err) + } + + // Set column autowidth + err = SetColAutoWidth(xlsx, sheetName) + if err != nil { + fmt.Printf("Error setting auto width : '%s'\n", err) + } + // Save the Excel file into a byte buffer if err := xlsx.Write(&buffer); err != nil { return nil, err @@ -111,3 +271,29 @@ func getFieldValue(field reflect.Value) interface{} { } return field.Interface() // Return the value as-is for non-sql.Null types } + +// Taken from https://github.com/qax-os/excelize/issues/92#issuecomment-821578446 +func SetColAutoWidth(xlsx *excelize.File, sheetName string) error { + // Autofit all columns according to their text content + cols, err := xlsx.GetCols(sheetName) + if err != nil { + return err + } + for idx, col := range cols { + largestWidth := 0 + for _, rowCell := range col { + cellWidth := utf8.RuneCountInString(rowCell) + 2 // + 2 for margin + if cellWidth > largestWidth { + largestWidth = cellWidth + } + } + //fmt.Printf("SetColAutoWidth calculated largest width for column index '%d' is '%d'\n", idx, largestWidth) + name, err := excelize.ColumnNumberToName(idx + 1) + if err != nil { + return err + } + xlsx.SetColWidth(sheetName, name, name, float64(largestWidth)) + } + // No errors at this point + return nil +} diff --git a/server/handler/reportDownload.go b/server/handler/reportDownload.go index d946890..36f7623 100644 --- a/server/handler/reportDownload.go +++ b/server/handler/reportDownload.go @@ -8,12 +8,12 @@ import ( "vctp/internal/report" ) -func (h *Handler) ReportDownload(w http.ResponseWriter, r *http.Request) { +func (h *Handler) InventoryReportDownload(w http.ResponseWriter, r *http.Request) { ctx := context.Background() // Generate the XLSX report - reportData, err := report.CreateReport(h.Logger, h.Database, ctx) + reportData, err := report.CreateInventoryReport(h.Logger, h.Database, ctx) if err != nil { h.Logger.Error("Failed to create report", "error", err) w.Header().Set("Content-Type", "application/json") @@ -33,3 +33,29 @@ func (h *Handler) ReportDownload(w http.ResponseWriter, r *http.Request) { // Write the XLSX file to the HTTP response w.Write(reportData) } + +func (h *Handler) UpdateReportDownload(w http.ResponseWriter, r *http.Request) { + + ctx := context.Background() + + // Generate the XLSX report + reportData, err := report.CreateUpdatesReport(h.Logger, h.Database, ctx) + if err != nil { + h.Logger.Error("Failed to create report", "error", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "status": "ERROR", + "message": fmt.Sprintf("Unable to create xlsx report: '%s'", err), + }) + return + } + + // Set HTTP headers to indicate file download + w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + w.Header().Set("Content-Disposition", `attachment; filename="updates_report.xlsx"`) + w.Header().Set("File-Name", "updates_report.xlsx") + + // Write the XLSX file to the HTTP response + w.Write(reportData) +} diff --git a/server/router/router.go b/server/router/router.go index 552747c..6b8dfaa 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -31,7 +31,8 @@ func New(logger *slog.Logger, database db.Database, buildTime string, sha1ver st // temporary endpoint //mux.HandleFunc("/api/cleanup/updates", h.UpdateCleanup) - mux.HandleFunc("/api/report/download", h.ReportDownload) + mux.HandleFunc("/api/report/inventory", h.InventoryReportDownload) + mux.HandleFunc("/api/report/updates", h.UpdateReportDownload) return middleware.NewLoggingMiddleware(logger, mux) }