From 8b2c8ae85d041db5e0c00aafcd210a2cd0e71fde Mon Sep 17 00:00:00 2001 From: Nathan Coad Date: Thu, 15 Jan 2026 08:43:31 +1100 Subject: [PATCH] generate excel worksheets when data is available instead of on-demand --- internal/report/snapshots.go | 25 +++++++++++++++++++++++++ internal/settings/settings.go | 1 + internal/tasks/inventorySnapshots.go | 27 +++++++++++++++++++++++++++ server/handler/snapshots.go | 2 +- server/router/router.go | 11 +++++++++++ src/preinstall.sh | 1 + src/vctp.yml | 1 + 7 files changed, 67 insertions(+), 1 deletion(-) diff --git a/internal/report/snapshots.go b/internal/report/snapshots.go index 8c09abf..b3c632b 100644 --- a/internal/report/snapshots.go +++ b/internal/report/snapshots.go @@ -6,6 +6,8 @@ import ( "database/sql" "fmt" "log/slog" + "os" + "path/filepath" "strconv" "strings" "time" @@ -637,6 +639,29 @@ func CreateTableReport(logger *slog.Logger, Database db.Database, ctx context.Co return buffer.Bytes(), nil } +// SaveTableReport renders a table report and writes it to the destination directory with a .xlsx extension. +func SaveTableReport(logger *slog.Logger, Database db.Database, ctx context.Context, tableName, destDir string) (string, error) { + if err := db.ValidateTableName(tableName); err != nil { + return "", err + } + if strings.TrimSpace(destDir) == "" { + return "", fmt.Errorf("destination directory is empty") + } + if err := os.MkdirAll(destDir, 0o755); err != nil { + return "", fmt.Errorf("failed to create reports directory: %w", err) + } + + data, err := CreateTableReport(logger, Database, ctx, tableName) + if err != nil { + return "", err + } + filename := filepath.Join(destDir, fmt.Sprintf("%s.xlsx", tableName)) + if err := os.WriteFile(filename, data, 0o644); err != nil { + return "", err + } + return filename, nil +} + func addTotalsChartSheet(logger *slog.Logger, database db.Database, ctx context.Context, xlsx *excelize.File, tableName string) { if strings.HasPrefix(tableName, "inventory_daily_summary_") { suffix := strings.TrimPrefix(tableName, "inventory_daily_summary_") diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 1e9ecf3..92bd362 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -40,6 +40,7 @@ type SettingsYML struct { HourlySnapshotMaxAgeDays int `yaml:"hourly_snapshot_max_age_days"` DailySnapshotMaxAgeMonths int `yaml:"daily_snapshot_max_age_months"` SnapshotCleanupCron string `yaml:"snapshot_cleanup_cron"` + ReportsDir string `yaml:"reports_dir"` HourlyJobTimeoutSeconds int `yaml:"hourly_job_timeout_seconds"` HourlySnapshotTimeoutSeconds int `yaml:"hourly_snapshot_timeout_seconds"` DailyJobTimeoutSeconds int `yaml:"daily_job_timeout_seconds"` diff --git a/internal/tasks/inventorySnapshots.go b/internal/tasks/inventorySnapshots.go index b079db2..908ba1f 100644 --- a/internal/tasks/inventorySnapshots.go +++ b/internal/tasks/inventorySnapshots.go @@ -168,6 +168,10 @@ func (c *CronTask) RunVcenterSnapshotHourly(ctx context.Context, logger *slog.Lo c.Logger.Warn("failed to register hourly snapshot", "error", err, "table", tableName) } + if err := c.generateReport(ctx, tableName); err != nil { + c.Logger.Warn("failed to generate hourly report", "error", err, "table", tableName) + } + c.Logger.Debug("Finished hourly vcenter snapshot", "vcenter_count", len(c.Settings.Values.Settings.VcenterAddresses), "table", tableName, "row_count", rowCount) return nil } @@ -312,6 +316,10 @@ func (c *CronTask) aggregateDailySummary(ctx context.Context, targetTime time.Ti c.Logger.Warn("failed to register daily snapshot", "error", err, "table", summaryTable) } + if err := c.generateReport(ctx, summaryTable); err != nil { + c.Logger.Warn("failed to generate daily report", "error", err, "table", summaryTable) + } + c.Logger.Debug("Finished daily inventory aggregation", "summary_table", summaryTable) return nil } @@ -431,6 +439,10 @@ func (c *CronTask) aggregateMonthlySummary(ctx context.Context, targetMonth time c.Logger.Warn("failed to register monthly snapshot", "error", err, "table", monthlyTable) } + if err := c.generateReport(ctx, monthlyTable); err != nil { + c.Logger.Warn("failed to generate monthly report", "error", err, "table", monthlyTable) + } + c.Logger.Debug("Finished monthly inventory aggregation", "summary_table", monthlyTable) return nil } @@ -718,6 +730,21 @@ func normalizeResourcePool(value string) string { } } +func (c *CronTask) reportsDir() string { + if c.Settings != nil && c.Settings.Values != nil { + if dir := strings.TrimSpace(c.Settings.Values.Settings.ReportsDir); dir != "" { + return dir + } + } + return "/var/lib/vctp/reports" +} + +func (c *CronTask) generateReport(ctx context.Context, tableName string) error { + dest := c.reportsDir() + _, err := report.SaveTableReport(c.Logger, c.Database, ctx, tableName, dest) + return err +} + func snapshotFromVM(vmObject *mo.VirtualMachine, vc *vcenter.Vcenter, snapshotTime time.Time, inv *queries.Inventory, hostLookup map[string]vcenter.HostLookup, folderLookup vcenter.FolderLookup, rpLookup map[string]string) (inventorySnapshotRow, error) { if vmObject == nil { return inventorySnapshotRow{}, fmt.Errorf("missing VM object") diff --git a/server/handler/snapshots.go b/server/handler/snapshots.go index 1ec38a5..63c5d1d 100644 --- a/server/handler/snapshots.go +++ b/server/handler/snapshots.go @@ -111,7 +111,7 @@ func (h *Handler) renderSnapshotList(w http.ResponseWriter, r *http.Request, sna label := report.FormatSnapshotLabel(snapshotType, record.SnapshotTime, record.TableName) entries = append(entries, views.SnapshotEntry{ Label: label, - Link: "/api/report/snapshot?table=" + url.QueryEscape(record.TableName), + Link: "/reports/" + url.PathEscape(record.TableName) + ".xlsx", Count: record.SnapshotCount, }) } diff --git a/server/router/router.go b/server/router/router.go index 08bd429..94e0bf7 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -5,6 +5,8 @@ import ( "log/slog" "net/http" "net/http/pprof" + "os" + "path/filepath" "vctp/db" "vctp/dist" "vctp/internal/secrets" @@ -28,10 +30,19 @@ func New(logger *slog.Logger, database db.Database, buildTime string, sha1ver st mux := http.NewServeMux() + reportsDir := settings.Values.Settings.ReportsDir + if reportsDir == "" { + reportsDir = "/var/lib/vctp/reports" + } + if err := os.MkdirAll(reportsDir, 0o755); err != nil { + logger.Warn("failed to create reports directory", "error", err, "path", reportsDir) + } + mux.Handle("/assets/", middleware.CacheMiddleware(http.FileServer(http.FS(dist.AssetsDir)))) mux.Handle("/favicon.ico", middleware.CacheMiddleware(http.FileServer(http.FS(dist.AssetsDir)))) mux.Handle("/favicon-16x16.png", middleware.CacheMiddleware(http.FileServer(http.FS(dist.AssetsDir)))) mux.Handle("/favicon-32x32.png", middleware.CacheMiddleware(http.FileServer(http.FS(dist.AssetsDir)))) + mux.Handle("/reports/", http.StripPrefix("/reports/", http.FileServer(http.Dir(filepath.Clean(reportsDir))))) mux.HandleFunc("/", h.Home) mux.HandleFunc("/api/event/vm/create", h.VmCreateEvent) mux.HandleFunc("/api/event/vm/modify", h.VmModifyEvent) diff --git a/src/preinstall.sh b/src/preinstall.sh index 42fd327..01fd55e 100644 --- a/src/preinstall.sh +++ b/src/preinstall.sh @@ -20,6 +20,7 @@ getent passwd "$USER" >/dev/null || useradd -r -g "$GROUP" -m -s /bin/bash -c "v # create vctp data directory if it doesn't exist [ -d /var/lib/vctp ] || mkdir -p /var/lib/vctp +[ -d /var/lib/vctp/reports ] || mkdir -p /var/lib/vctp/reports # set user ownership on vctp data directory if not already done [ "$(stat -c "%U" /var/lib/vctp)" = "$USER" ] || chown -R "$USER" /var/lib/vctp diff --git a/src/vctp.yml b/src/vctp.yml index 82bbc12..dae84d4 100644 --- a/src/vctp.yml +++ b/src/vctp.yml @@ -3,6 +3,7 @@ settings: log_output: "text" database_driver: "sqlite" database_url: "/var/lib/vctp/db.sqlite3" + reports_dir: /var/lib/vctp/reports bind_ip: bind_port: 9443 bind_disable_tls: false