Files
vctp2/server/handler/vcenters.go
Nathan Coad 59b16db04f
All checks were successful
continuous-integration/drone/push Build is passing
speed up vm trace pages
2026-02-09 14:19:24 +11:00

265 lines
8.3 KiB
Go

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),
}
}