package handler import ( "context" "fmt" "net/http" "net/url" "strconv" "strings" "time" "vctp/components/views" "vctp/db" ) const ( vcenterHourlyDetailWindowDays = 45 vcenterDailyDefaultLimit = 400 vcenterMonthlyDefaultLimit = 200 ) // VcenterList renders a list of vCenters being monitored. // @Summary List vCenters // @Description Lists all vCenters with recorded snapshot totals, linking to the fast daily aggregated totals page. // @Tags vcenters // @Produce text/html // @Success 200 {string} string "HTML page" // @Router /vcenters [get] func (h *Handler) VcenterList(w http.ResponseWriter, r *http.Request) { ctx := r.Context() vcs, err := db.ListVcenters(ctx, h.Database.DB()) if err != nil { http.Error(w, fmt.Sprintf("failed to list vcenters: %v", err), http.StatusInternalServerError) return } links := make([]views.VcenterLink, 0, len(vcs)) for _, vc := range vcs { links = append(links, views.VcenterLink{ Name: vc, Link: "/vcenters/totals/daily?vcenter=" + url.QueryEscape(vc), }) } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := views.VcenterList(links).Render(ctx, w); err != nil { http.Error(w, "Failed to render template", http.StatusInternalServerError) } } // VcenterTotals keeps backward compatibility with the original endpoint and routes to the new pages. // @Summary vCenter totals // @Description Redirect-style handler for compatibility; use /vcenters/totals/daily or /vcenters/totals/hourly. // @Tags vcenters // @Produce text/html // @Param vcenter query string true "vCenter URL" // @Param type query string false "hourly|daily|monthly" // @Param limit query int false "Limit results" // @Success 200 {string} string "HTML page" // @Failure 400 {string} string "Missing vcenter" // @Router /vcenters/totals [get] func (h *Handler) VcenterTotals(w http.ResponseWriter, r *http.Request) { switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("type"))) { case "hourly", "hourly-detail", "detail", "detailed": h.VcenterTotalsHourlyDetailed(w, r) return case "monthly": h.vcenterTotalsLegacyMonthly(w, r) return default: h.VcenterTotalsDaily(w, r) return } } // VcenterTotalsDaily renders the daily-aggregation totals page for one vCenter. // @Summary vCenter daily totals // @Description Shows daily aggregated VM count/vCPU/RAM totals for a vCenter (cache-backed for fast loading). // @Tags vcenters // @Produce text/html // @Param vcenter query string true "vCenter URL" // @Param limit query int false "Limit results (default 400)" // @Success 200 {string} string "HTML page" // @Failure 400 {string} string "Missing vcenter" // @Router /vcenters/totals/daily [get] func (h *Handler) VcenterTotalsDaily(w http.ResponseWriter, r *http.Request) { ctx := r.Context() vc, ok := requiredVcenterParam(w, r) if !ok { return } limit := parsePositiveLimit(r, vcenterDailyDefaultLimit) rows, err := db.ListVcenterTotalsByType(ctx, h.Database.DB(), vc, "daily", limit) if err != nil { http.Error(w, fmt.Sprintf("failed to list daily totals: %v", err), http.StatusInternalServerError) return } h.renderVcenterTotalsPage(ctx, w, vc, "daily", rows) } // VcenterTotalsHourlyDetailed renders a detailed hourly page over the most recent 45 days. // @Summary vCenter hourly totals (45 days) // @Description Shows detailed hourly VM count/vCPU/RAM totals for the latest 45 days. // @Tags vcenters // @Produce text/html // @Param vcenter query string true "vCenter URL" // @Success 200 {string} string "HTML page" // @Failure 400 {string} string "Missing vcenter" // @Router /vcenters/totals/hourly [get] func (h *Handler) VcenterTotalsHourlyDetailed(w http.ResponseWriter, r *http.Request) { ctx := r.Context() vc, ok := requiredVcenterParam(w, r) if !ok { return } since := time.Now().AddDate(0, 0, -vcenterHourlyDetailWindowDays) rows, err := db.ListVcenterHourlyTotalsSince(ctx, h.Database.DB(), vc, since) if err != nil { http.Error(w, fmt.Sprintf("failed to list hourly totals: %v", err), http.StatusInternalServerError) return } h.renderVcenterTotalsPage(ctx, w, vc, "hourly45", rows) } func (h *Handler) vcenterTotalsLegacyMonthly(w http.ResponseWriter, r *http.Request) { ctx := r.Context() vc, ok := requiredVcenterParam(w, r) if !ok { return } limit := parsePositiveLimit(r, vcenterMonthlyDefaultLimit) rows, err := db.ListVcenterTotalsByType(ctx, h.Database.DB(), vc, "monthly", limit) if err != nil { http.Error(w, fmt.Sprintf("failed to list monthly totals: %v", err), http.StatusInternalServerError) return } h.renderVcenterTotalsPage(ctx, w, vc, "monthly", rows) } func requiredVcenterParam(w http.ResponseWriter, r *http.Request) (string, bool) { vc := strings.TrimSpace(r.URL.Query().Get("vcenter")) if vc == "" { http.Error(w, "vcenter is required", http.StatusBadRequest) return "", false } return vc, true } func parsePositiveLimit(r *http.Request, defaultLimit int) int { if defaultLimit <= 0 { defaultLimit = 200 } if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 { return parsed } } return defaultLimit } func (h *Handler) renderVcenterTotalsPage(ctx context.Context, w http.ResponseWriter, vc string, viewType string, rows []db.VcenterTotalRow) { entries := make([]views.VcenterTotalsEntry, 0, len(rows)) for _, row := range rows { entries = append(entries, views.VcenterTotalsEntry{ Snapshot: time.Unix(row.SnapshotTime, 0).Local().Format("2006-01-02 15:04:05"), RawTime: row.SnapshotTime, VmCount: row.VmCount, VcpuTotal: row.VcpuTotal, RamTotalGB: row.RamTotalGB, }) } chart := buildVcenterChart(entries) meta := buildVcenterMeta(vc, viewType) w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := views.VcenterTotalsPage(vc, entries, chart, meta).Render(ctx, w); err != nil { http.Error(w, "Failed to render template", http.StatusInternalServerError) } } func buildVcenterMeta(vcenter string, viewType string) views.VcenterTotalsMeta { active := viewType if active == "" { active = "daily" } meta := views.VcenterTotalsMeta{ ViewType: active, TypeLabel: "Daily", HourlyLink: "/vcenters/totals/hourly?vcenter=" + url.QueryEscape(vcenter), DailyLink: "/vcenters/totals/daily?vcenter=" + url.QueryEscape(vcenter), HourlyClass: "web3-button", DailyClass: "web3-button", } switch active { case "hourly45", "hourly": meta.ViewType = "hourly45" meta.TypeLabel = fmt.Sprintf("Hourly (last %d days)", vcenterHourlyDetailWindowDays) meta.HourlyClass = "web3-button active" case "monthly": meta.TypeLabel = "Monthly" default: meta.ViewType = "daily" meta.DailyClass = "web3-button active" } return meta } func buildVcenterChart(entries []views.VcenterTotalsEntry) views.VcenterChartData { if len(entries) == 0 { return views.VcenterChartData{} } // Plot oldest on the left, newest on the right. plot := make([]views.VcenterTotalsEntry, 0, len(entries)) for i := len(entries) - 1; i >= 0; i-- { plot = append(plot, entries[i]) } labels := make([]string, 0, len(plot)) tickLabels := make([]string, 0, len(plot)) vmValues := make([]float64, 0, len(plot)) vcpuValues := make([]float64, 0, len(plot)) ramValues := make([]float64, 0, len(plot)) for _, e := range plot { t := time.Unix(e.RawTime, 0).Local() labels = append(labels, t.Format("2006-01-02 15:04:05")) tickLabels = append(tickLabels, t.Format("01-02 15:04")) vmValues = append(vmValues, float64(e.VmCount)) vcpuValues = append(vcpuValues, float64(e.VcpuTotal)) ramValues = append(ramValues, float64(e.RamTotalGB)) } cfg := lineChartConfig{ Height: 360, XTicks: 6, YTicks: 5, YLabel: "Totals", XLabel: "Snapshots (oldest left, newest right)", Labels: labels, TickLabels: tickLabels, Series: []lineChartSeries{ { Name: "VMs", Color: "#2563eb", Values: vmValues, TooltipFormat: "int", LineWidth: 2.5, }, { Name: "vCPU", Color: "#16a34a", Values: vcpuValues, TooltipFormat: "int", LineWidth: 2.5, }, { Name: "RAM (GB)", Color: "#ea580c", Values: ramValues, TooltipFormat: "int", LineWidth: 2.5, }, }, } return views.VcenterChartData{ ConfigJSON: encodeLineChartConfig(cfg), } }