package handler import ( "fmt" "net/http" "net/url" "strconv" "strings" "time" "vctp/components/views" "vctp/db" ) // VcenterList renders a list of vCenters being monitored. // @Summary List vCenters // @Description Lists all vCenters with recorded snapshot totals. // @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() if err := db.SyncVcenterTotalsFromSnapshots(ctx, h.Database.DB()); err != nil { h.Logger.Warn("failed to sync vcenter totals", "error", err) } 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?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 renders totals for a vCenter. // @Summary vCenter totals // @Description Shows per-snapshot totals for a vCenter. // @Tags vcenters // @Produce text/html // @Param vcenter query string true "vCenter URL" // @Param type query string false "hourly|daily|monthly (default: hourly)" // @Param limit query int false "Limit results (default 200)" // @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) { ctx := r.Context() vc := r.URL.Query().Get("vcenter") if vc == "" { http.Error(w, "vcenter is required", http.StatusBadRequest) return } viewType := strings.ToLower(r.URL.Query().Get("type")) if viewType == "" { viewType = "hourly" } switch viewType { case "hourly", "daily", "monthly": default: viewType = "hourly" } if viewType == "hourly" { if err := db.SyncVcenterTotalsFromSnapshots(ctx, h.Database.DB()); err != nil { h.Logger.Warn("failed to sync vcenter totals", "error", err) } } limit := 200 if l := r.URL.Query().Get("limit"); l != "" { if v, err := strconv.Atoi(l); err == nil && v > 0 { limit = v } } rows, err := db.ListVcenterTotalsByType(ctx, h.Database.DB(), vc, viewType, limit) if err != nil { http.Error(w, fmt.Sprintf("failed to list totals: %v", err), http.StatusInternalServerError) return } 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 = "hourly" } meta := views.VcenterTotalsMeta{ ViewType: active, TypeLabel: "Hourly", HourlyLink: "/vcenters/totals?vcenter=" + url.QueryEscape(vcenter) + "&type=hourly", DailyLink: "/vcenters/totals?vcenter=" + url.QueryEscape(vcenter) + "&type=daily", MonthlyLink: "/vcenters/totals?vcenter=" + url.QueryEscape(vcenter) + "&type=monthly", HourlyClass: "web3-button", DailyClass: "web3-button", MonthlyClass: "web3-button", } switch active { case "daily": meta.TypeLabel = "Daily" meta.DailyClass = "web3-button active" case "monthly": meta.TypeLabel = "Monthly" meta.MonthlyClass = "web3-button active" default: meta.ViewType = "hourly" meta.HourlyClass = "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), } }