From aa4567d7c1834093c85d81ce866d44a259699e68 Mon Sep 17 00:00:00 2001 From: Nathan Coad Date: Wed, 14 Jan 2026 10:06:26 +1100 Subject: [PATCH] add regenerate endpoint --- internal/tasks/inventorySnapshots.go | 46 ++++++++++++++++ server/handler/snapshotAggregate.go | 79 ++++++++++++++++++++++++++++ server/router/router.go | 1 + 3 files changed, 126 insertions(+) create mode 100644 server/handler/snapshotAggregate.go diff --git a/internal/tasks/inventorySnapshots.go b/internal/tasks/inventorySnapshots.go index 39fc622..c2db192 100644 --- a/internal/tasks/inventorySnapshots.go +++ b/internal/tasks/inventorySnapshots.go @@ -102,6 +102,14 @@ func (c *CronTask) RunVcenterSnapshotHourly(ctx context.Context, logger *slog.Lo // RunVcenterDailyAggregate summarizes hourly snapshots into a daily summary table. func (c *CronTask) RunVcenterDailyAggregate(ctx context.Context, logger *slog.Logger) error { targetTime := time.Now().Add(-time.Minute) + return c.aggregateDailySummary(ctx, targetTime, false) +} + +func (c *CronTask) AggregateDailySummary(ctx context.Context, date time.Time, force bool) error { + return c.aggregateDailySummary(ctx, date, force) +} + +func (c *CronTask) aggregateDailySummary(ctx context.Context, targetTime time.Time, force bool) error { dayStart := time.Date(targetTime.Year(), targetTime.Month(), targetTime.Day(), 0, 0, 0, 0, targetTime.Location()) dayEnd := dayStart.AddDate(0, 0, 1) summaryTable, err := dailySummaryTableName(targetTime) @@ -116,6 +124,16 @@ func (c *CronTask) RunVcenterDailyAggregate(ctx context.Context, logger *slog.Lo if err := report.EnsureSnapshotRegistry(ctx, c.Database); err != nil { return err } + if rowsExist, err := tableHasRows(ctx, dbConn, summaryTable); err != nil { + return err + } else if rowsExist && !force { + c.Logger.Debug("Daily summary already exists, skipping aggregation", "summary_table", summaryTable) + return nil + } else if rowsExist && force { + if err := clearTable(ctx, dbConn, summaryTable); err != nil { + return err + } + } if rowsExist, err := tableHasRows(ctx, dbConn, summaryTable); err != nil { return err } else if rowsExist { @@ -235,7 +253,14 @@ func (c *CronTask) RunVcenterMonthlyAggregate(ctx context.Context, logger *slog. now := time.Now() firstOfThisMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) targetMonth := firstOfThisMonth.AddDate(0, -1, 0) + return c.aggregateMonthlySummary(ctx, targetMonth, false) +} +func (c *CronTask) AggregateMonthlySummary(ctx context.Context, month time.Time, force bool) error { + return c.aggregateMonthlySummary(ctx, month, force) +} + +func (c *CronTask) aggregateMonthlySummary(ctx context.Context, targetMonth time.Time, force bool) error { if err := report.EnsureSnapshotRegistry(ctx, c.Database); err != nil { return err } @@ -259,6 +284,16 @@ func (c *CronTask) RunVcenterMonthlyAggregate(ctx context.Context, logger *slog. if err := ensureMonthlySummaryTable(ctx, dbConn, monthlyTable); err != nil { return err } + if rowsExist, err := tableHasRows(ctx, dbConn, monthlyTable); err != nil { + return err + } else if rowsExist && !force { + c.Logger.Debug("Monthly summary already exists, skipping aggregation", "summary_table", monthlyTable) + return nil + } else if rowsExist && force { + if err := clearTable(ctx, dbConn, monthlyTable); err != nil { + return err + } + } if rowsExist, err := tableHasRows(ctx, dbConn, monthlyTable); err != nil { return err } else if rowsExist { @@ -604,6 +639,17 @@ func dropSnapshotTable(ctx context.Context, dbConn *sqlx.DB, table string) error return err } +func clearTable(ctx context.Context, dbConn *sqlx.DB, table string) error { + if _, err := safeTableName(table); err != nil { + return err + } + _, err := dbConn.ExecContext(ctx, fmt.Sprintf("DELETE FROM %s", table)) + if err != nil { + return fmt.Errorf("failed to clear table %s: %w", table, err) + } + return nil +} + func tableHasRows(ctx context.Context, dbConn *sqlx.DB, table string) (bool, error) { if _, err := safeTableName(table); err != nil { return false, err diff --git a/server/handler/snapshotAggregate.go b/server/handler/snapshotAggregate.go new file mode 100644 index 0000000..ac03209 --- /dev/null +++ b/server/handler/snapshotAggregate.go @@ -0,0 +1,79 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" + "vctp/internal/tasks" +) + +// SnapshotAggregateForce forces regeneration of a daily or monthly summary table. +// @Summary Force snapshot aggregation +// @Description Forces regeneration of a daily or monthly summary table for a specified date or month. +// @Tags snapshots +// @Produce json +// @Param type query string true "Aggregation type: daily or monthly" +// @Param date query string true "Daily date (YYYY-MM-DD) or monthly date (YYYY-MM)" +// @Success 200 {object} map[string]string "Aggregation complete" +// @Failure 400 {object} map[string]string "Invalid request" +// @Failure 500 {object} map[string]string "Server error" +// @Router /api/snapshots/aggregate [post] +func (h *Handler) SnapshotAggregateForce(w http.ResponseWriter, r *http.Request) { + snapshotType := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("type"))) + dateValue := strings.TrimSpace(r.URL.Query().Get("date")) + + if snapshotType == "" || dateValue == "" { + writeJSONError(w, http.StatusBadRequest, "type and date are required") + return + } + + ctx := context.Background() + ct := &tasks.CronTask{ + Logger: h.Logger, + Database: h.Database, + Settings: h.Settings, + } + + switch snapshotType { + case "daily": + parsed, err := time.Parse("2006-01-02", dateValue) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "date must be YYYY-MM-DD") + return + } + if err := ct.AggregateDailySummary(ctx, parsed, true); err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + case "monthly": + parsed, err := time.Parse("2006-01", dateValue) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "date must be YYYY-MM") + return + } + if err := ct.AggregateMonthlySummary(ctx, parsed, true); err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + default: + writeJSONError(w, http.StatusBadRequest, "type must be daily or monthly") + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{ + "status": "OK", + }) +} + +func writeJSONError(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{ + "status": "ERROR", + "message": message, + }) +} diff --git a/server/router/router.go b/server/router/router.go index 34c3a16..dae1177 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -51,6 +51,7 @@ func New(logger *slog.Logger, database db.Database, buildTime string, sha1ver st mux.HandleFunc("/api/report/inventory", h.InventoryReportDownload) mux.HandleFunc("/api/report/updates", h.UpdateReportDownload) mux.HandleFunc("/api/report/snapshot", h.SnapshotReportDownload) + mux.HandleFunc("/api/snapshots/aggregate", h.SnapshotAggregateForce) mux.HandleFunc("/snapshots/hourly", h.SnapshotHourlyList) mux.HandleFunc("/snapshots/daily", h.SnapshotDailyList)