mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
a21e0ef8f8
Follows #2591. A node alert that moves to its linked Pulse agent closes with a `moved_to_agent` resolution (#2580). Two readers still presented that close as a recovery. **Performance reports.** The node, VM and container enrichers copied recently resolved alerts into `reporting.AlertInfo` without the resolution, so a report generated in the five-minute window after a handover read HEALTHY, "All systems operating normally", counted the alert as "triggered and resolved", and sent the AI narrator `resolved: true`. - Report rows now carry the alert's resource and resolution (`reportActiveAlertInfo` / `reportResolvedAlertInfo`). - `pkg/reporting` has one rule (`unresolvedAlerts`): an alert that closed without recovering stays live unless the report also shows its successor's reading for that metric, meaning an open alert or a recovery since the handover. That way the condition is counted once, and fleets match handovers across all their resources. - The single-resource PDF card reads WARNING (or CRITICAL at the alert's level) with "Alert moved to <agent> - check the agent for the current reading". The active list names the agent, the alerts table shows "Moved" with the engine's summary as a note, and recommendations say to check the agent. - Fleets count the alert as active and name the agent. Their all-clear pattern and "No fleet-wide action required" no longer appear while any alert is active. - The narrator payload carries `resolved: false`, the summary and `successor_alert_listed`, with prompt rules for both. **Resource history.** `alert_resolved` changes were labelled by kind alone ("Alert resolved", green) with that label prefixed to the headline. `getResourceChangePresentation` now reads the `alert_resolution` metadata: the change reads "Alert moved" in the neutral blue of the history badge, and its summary is the whole headline in the drawer, `ResourceChangeSummary` and Patrol's assessment handoff. **Verification** - A private mock stack running this branch, with a local-only overlay that closes a real pve1 memory alert through `clearAlertWithResolution`. Within the window, `/api/admin/reports/generate?resourceType=node` rendered CRITICAL with the handover message and a "Moved" row, while the ordinary CPU recovery stayed green. - In the browser, a full drawer (Availability checks, Fleet view) showed the producer-made rows as "Alert moved" next to "Alert resolved" at 1280 and 375. A Proxmox node's own drawer uses the `table-row` presentation, which fetches no change history (see below). - Tests in registered proof files: `pkg/reporting/alert_state_test.go` (verdict, successor timing, fleet pool, PDF text and width), `internal/api/contract_test.go`, `internal/ai/report_narrator_test.go`, `resourceChangePresentation.test.ts`, `ResourceDetailDrawer.history.test.tsx` and `patrolInvestigationContextModel.test.ts`. Each failed with the fix reverted. - Second opinion: two read-only review rounds. The temporal match, fleet-wide pool, narrator dedupe signal, fleet all-clear fallbacks and contract wording came from them. **Not fixed here (separate changes)** - Reports requested as `agent` carry no alert rows at all. That is how the Reports page addresses every Proxmox node in v6, so such a report reads HEALTHY even with active critical alerts. This change makes the shared rule correct for when those reports attach the machine's alerts. - Node and guest alert lifecycle changes are recorded under legacy alert resource IDs, which canonical history queries never match. Facet kind counts still group moved closes under "Alert resolved".
2848 lines
91 KiB
Go
2848 lines
91 KiB
Go
package reporting
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-pdf/fpdf"
|
|
)
|
|
|
|
// Color scheme - professional dark blue theme
|
|
var (
|
|
colorPrimary = [3]int{30, 58, 95} // Dark navy
|
|
colorSecondary = [3]int{52, 152, 219} // Bright blue
|
|
colorAccent = [3]int{46, 204, 113} // Green
|
|
colorWarning = [3]int{241, 196, 15} // Yellow
|
|
colorDanger = [3]int{231, 76, 60} // Red
|
|
colorTextDark = [3]int{44, 62, 80} // Dark text
|
|
colorTextMuted = [3]int{127, 140, 141} // Muted text
|
|
colorBackground = [3]int{248, 249, 250} // Light gray bg
|
|
colorTableHeader = [3]int{30, 58, 95} // Navy header
|
|
colorTableAlt = [3]int{241, 245, 249} // Alternating row
|
|
colorGridLine = [3]int{220, 220, 220} // Chart grid
|
|
)
|
|
|
|
// PDFGenerator handles PDF report generation.
|
|
type PDFGenerator struct{}
|
|
|
|
// NewPDFGenerator creates a new PDF generator.
|
|
func NewPDFGenerator() *PDFGenerator {
|
|
return &PDFGenerator{}
|
|
}
|
|
|
|
func reportCoverBrandName(brand *ReportBrand) string {
|
|
if brand != nil && strings.TrimSpace(brand.DisplayName) != "" {
|
|
return strings.TrimSpace(brand.DisplayName)
|
|
}
|
|
return "PULSE"
|
|
}
|
|
|
|
// setCoverFontToFit sets an Arial font at baseSize, then shrinks it until
|
|
// text fits within maxWidth or minSize is reached. Cover title and brand
|
|
// lines carry operator-controlled text, so they cannot assume a width.
|
|
func setCoverFontToFit(pdf *fpdf.Fpdf, style, text string, baseSize, minSize, maxWidth float64) {
|
|
size := baseSize
|
|
pdf.SetFont("Arial", style, size)
|
|
for size > minSize && pdf.GetStringWidth(text) > maxWidth {
|
|
size--
|
|
pdf.SetFontSize(size)
|
|
}
|
|
}
|
|
|
|
// writeCoverLine draws one centered cover line, shrinking the font to fit
|
|
// and wrapping onto extra lines when the minimum size still overflows.
|
|
func writeCoverLine(pdf *fpdf.Fpdf, text string, lineHeight, baseSize, minSize float64, style string) {
|
|
pageWidth, _ := pdf.GetPageSize()
|
|
left, _, right, _ := pdf.GetMargins()
|
|
maxWidth := pageWidth - left - right
|
|
setCoverFontToFit(pdf, style, text, baseSize, minSize, maxWidth)
|
|
if pdf.GetStringWidth(text) > maxWidth {
|
|
pdf.MultiCell(0, lineHeight*0.8, text, "", "C", false)
|
|
return
|
|
}
|
|
pdf.CellFormat(0, lineHeight, text, "", 1, "C", false, 0, "")
|
|
}
|
|
|
|
func reportHeaderBrandName(brand *ReportBrand, fallback string) string {
|
|
if brand != nil && strings.TrimSpace(brand.DisplayName) != "" {
|
|
return strings.ToUpper(strings.TrimSpace(brand.DisplayName))
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func reportLogoTypeFromData(data []byte, configured string) string {
|
|
if normalized := normalizeLogoFormat(configured); normalized != "" {
|
|
return normalized
|
|
}
|
|
switch {
|
|
case len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}):
|
|
return "png"
|
|
case len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff:
|
|
return "jpg"
|
|
case len(data) >= 6 && (bytes.Equal(data[:6], []byte("GIF87a")) || bytes.Equal(data[:6], []byte("GIF89a"))):
|
|
return "gif"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func reportLogoTypeFromPath(path string, configured string) string {
|
|
if normalized := normalizeLogoFormat(configured); normalized != "" {
|
|
return normalized
|
|
}
|
|
switch strings.ToLower(strings.TrimPrefix(filepath.Ext(path), ".")) {
|
|
case "jpg", "jpeg":
|
|
return "jpg"
|
|
case "png":
|
|
return "png"
|
|
case "gif":
|
|
return "gif"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func scaledLogoSize(info *fpdf.ImageInfoType, maxW, maxH float64) (float64, float64) {
|
|
if info == nil {
|
|
return 0, 0
|
|
}
|
|
w := info.Width()
|
|
h := info.Height()
|
|
if w <= 0 || h <= 0 {
|
|
return 0, 0
|
|
}
|
|
scale := math.Min(maxW/w, maxH/h)
|
|
if scale <= 0 {
|
|
return 0, 0
|
|
}
|
|
return w * scale, h * scale
|
|
}
|
|
|
|
func (g *PDFGenerator) drawBrandLogo(pdf *fpdf.Fpdf, brand *ReportBrand, x, y, maxW, maxH float64) bool {
|
|
if brand == nil || maxW <= 0 || maxH <= 0 {
|
|
return false
|
|
}
|
|
|
|
options := fpdf.ImageOptions{ReadDpi: true}
|
|
var imageName string
|
|
var info *fpdf.ImageInfoType
|
|
|
|
if len(brand.LogoData) > 0 {
|
|
imageType := reportLogoTypeFromData(brand.LogoData, brand.LogoFormat)
|
|
if imageType == "" {
|
|
return false
|
|
}
|
|
sum := sha256.Sum256(brand.LogoData)
|
|
imageName = "report-brand-logo-" + hex.EncodeToString(sum[:8])
|
|
options.ImageType = imageType
|
|
info = pdf.RegisterImageOptionsReader(imageName, options, bytes.NewReader(brand.LogoData))
|
|
} else if strings.TrimSpace(brand.LogoPath) != "" {
|
|
path := strings.TrimSpace(brand.LogoPath)
|
|
imageType := reportLogoTypeFromPath(path, brand.LogoFormat)
|
|
if imageType == "" {
|
|
return false
|
|
}
|
|
stat, err := os.Stat(path)
|
|
if err != nil || stat.IsDir() {
|
|
return false
|
|
}
|
|
imageName = path
|
|
options.ImageType = imageType
|
|
info = pdf.RegisterImageOptions(imageName, options)
|
|
}
|
|
|
|
if pdf.Err() || info == nil {
|
|
pdf.ClearError()
|
|
return false
|
|
}
|
|
w, h := scaledLogoSize(info, maxW, maxH)
|
|
if w <= 0 || h <= 0 {
|
|
return false
|
|
}
|
|
pdf.ImageOptions(imageName, x+(maxW-w)/2, y+(maxH-h)/2, w, h, false, options, 0, "")
|
|
if pdf.Err() {
|
|
pdf.ClearError()
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Generate creates a PDF report from the provided data.
|
|
func (g *PDFGenerator) Generate(data *ReportData) ([]byte, error) {
|
|
pdf := fpdf.New("P", "mm", "A4", "")
|
|
pdf.SetMargins(20, 20, 20)
|
|
pdf.SetAutoPageBreak(true, 25)
|
|
|
|
// Core fonts are cp1252: translate the UTF-8 report strings (AI
|
|
// narrative prose, resource names, alert messages) once up front so
|
|
// em dashes and curly quotes render as glyphs instead of mojibake.
|
|
// The translator is built per call because fpdf's closure reuses an
|
|
// internal buffer and this generator is shared across concurrent
|
|
// requests; see pdf_codepage.go.
|
|
translateReportStrings(data, pdf.UnicodeTranslatorFromDescriptor(""))
|
|
|
|
// Cover page
|
|
g.writeCoverPage(pdf, data)
|
|
|
|
// Executive Summary page
|
|
pdf.AddPage()
|
|
g.addPageHeader(pdf, data, "Executive Summary")
|
|
g.writeExecutiveSummary(pdf, data)
|
|
|
|
// Resource details (if enrichment data available). The details grid is
|
|
// usually a third of a page; giving it a page of its own left the
|
|
// report mostly whitespace, so it shares the executive summary page
|
|
// whenever there is room.
|
|
if data.Resource != nil {
|
|
if pdf.GetY() > 160 {
|
|
pdf.AddPage()
|
|
g.addPageHeader(pdf, data, "Resource Details")
|
|
} else {
|
|
pdf.Ln(4)
|
|
pdf.SetFont("Arial", "B", 14)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 9, "Resource Details", "", 1, "L", false, 0, "")
|
|
pdf.Ln(2)
|
|
}
|
|
g.writeResourceDetails(pdf, data)
|
|
|
|
// Storage section for nodes
|
|
if len(data.Storage) > 0 {
|
|
g.writeStorageSection(pdf, data)
|
|
}
|
|
|
|
// Physical disks section for nodes
|
|
if len(data.Disks) > 0 {
|
|
g.writeDisksSection(pdf, data)
|
|
}
|
|
|
|
// Backups section for VMs/containers
|
|
if len(data.Backups) > 0 {
|
|
g.writeBackupsSection(pdf, data)
|
|
}
|
|
}
|
|
|
|
// Alerts section (if any)
|
|
if len(data.Alerts) > 0 {
|
|
if pdf.GetY() > 180 {
|
|
pdf.AddPage()
|
|
g.addPageHeader(pdf, data, "Alerts")
|
|
}
|
|
g.writeAlertsSection(pdf, data)
|
|
}
|
|
|
|
// Summary with metrics. The details grid above is usually short, so
|
|
// the performance summary continues on the same page when it fits
|
|
// instead of stranding the details on a nearly blank page.
|
|
if pdf.GetY() > 110 {
|
|
pdf.AddPage()
|
|
g.addPageHeader(pdf, data, "Performance Summary")
|
|
} else {
|
|
pdf.Ln(6)
|
|
pdf.SetFont("Arial", "B", 14)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 9, "Performance Summary", "", 1, "L", false, 0, "")
|
|
pdf.Ln(2)
|
|
}
|
|
g.writeSummarySection(pdf, data)
|
|
|
|
// Charts page(s)
|
|
g.writeChartsSection(pdf, data)
|
|
|
|
// Data table page(s)
|
|
g.writeDataSection(pdf, data)
|
|
|
|
// Add page numbers to all pages except cover
|
|
g.addPageNumbers(pdf)
|
|
|
|
// Output to buffer
|
|
var buf bytes.Buffer
|
|
if err := pdf.Output(&buf); err != nil {
|
|
return nil, fmt.Errorf("PDF output error: %w", err)
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// formatOutageDuration renders a downtime duration for report copy,
|
|
// never collapsing a real outage to "0 minutes".
|
|
func formatOutageDuration(d time.Duration) string {
|
|
if d > 0 && d < time.Minute {
|
|
return "under a minute"
|
|
}
|
|
return formatDuration(d)
|
|
}
|
|
|
|
// availabilityUptimeLabel renders an uptime percentage for report copy.
|
|
// Two decimal places keep "99.97%" distinguishable from "100%"; values
|
|
// that round up to a clean boundary are clamped so the label never
|
|
// overstates availability.
|
|
func availabilityUptimeLabel(percent float64) string {
|
|
if percent >= 100 {
|
|
return "100%"
|
|
}
|
|
label := fmt.Sprintf("%.2f%%", percent)
|
|
if label == "100.00%" {
|
|
return "99.99%"
|
|
}
|
|
return label
|
|
}
|
|
|
|
// writeAvailabilitySection renders the observed-availability block in the
|
|
// executive summary. This is the number a managed-service client reads the
|
|
// report for: uptime over the period, outage count, and total downtime.
|
|
// Skipped entirely when no availability data was attached (no resource
|
|
// timeline available); renders a muted note when the resource was not
|
|
// observed during the window.
|
|
func (g *PDFGenerator) writeAvailabilitySection(pdf *fpdf.Fpdf, availability *AvailabilityInfo) {
|
|
if availability == nil {
|
|
return
|
|
}
|
|
|
|
pdf.SetFont("Arial", "B", 11)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 8, "Availability", "", 1, "L", false, 0, "")
|
|
pdf.Ln(1)
|
|
|
|
if !availability.Observed() {
|
|
pdf.SetFont("Arial", "", 10)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.CellFormat(0, 6, "Not observed during this window - no state history was recorded for this resource.", "", 1, "L", false, 0, "")
|
|
pdf.Ln(4)
|
|
return
|
|
}
|
|
|
|
uptimeColor := colorAccent
|
|
if availability.TotalDowntime > 0 {
|
|
uptimeColor = colorWarning
|
|
}
|
|
if availability.UptimePercent < 99 {
|
|
uptimeColor = colorDanger
|
|
}
|
|
|
|
pdf.SetFont("Arial", "B", 20)
|
|
pdf.SetTextColor(uptimeColor[0], uptimeColor[1], uptimeColor[2])
|
|
pdf.CellFormat(50, 10, availabilityUptimeLabel(availability.UptimePercent), "", 0, "L", false, 0, "")
|
|
|
|
detail := "No outages observed"
|
|
if availability.DownIncidents > 0 {
|
|
outageWord := "outages"
|
|
if availability.DownIncidents == 1 {
|
|
outageWord = "outage"
|
|
}
|
|
detail = fmt.Sprintf("%d %s, %s total downtime (longest %s)",
|
|
availability.DownIncidents, outageWord,
|
|
formatOutageDuration(availability.TotalDowntime),
|
|
formatOutageDuration(availability.LongestOutage))
|
|
}
|
|
pdf.SetFont("Arial", "", 10)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 10, detail, "", 1, "L", false, 0, "")
|
|
|
|
// Disclose partial observation instead of letting a monitoring gap
|
|
// silently inflate (or deflate) the headline number.
|
|
if availability.ObservedPercent < 99.5 {
|
|
pdf.SetFont("Arial", "", 8)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.CellFormat(0, 5, fmt.Sprintf("Based on the %.1f%% of this period Pulse was observing the resource; monitoring gaps are excluded, not counted as downtime.", availability.ObservedPercent), "", 1, "L", false, 0, "")
|
|
}
|
|
pdf.Ln(4)
|
|
}
|
|
|
|
// reportSubjectDisplayName returns the human-readable name for the report
|
|
// subject, falling back to the raw resource ID when enrichment did not
|
|
// resolve one.
|
|
func reportSubjectDisplayName(data *ReportData) string {
|
|
if data == nil {
|
|
return ""
|
|
}
|
|
if data.Resource != nil {
|
|
if name := strings.TrimSpace(data.Resource.Name); name != "" {
|
|
return name
|
|
}
|
|
}
|
|
return data.ResourceID
|
|
}
|
|
|
|
// writeCoverPage creates a professional cover page.
|
|
func (g *PDFGenerator) writeCoverPage(pdf *fpdf.Fpdf, data *ReportData) {
|
|
pdf.AddPage()
|
|
|
|
pageWidth, pageHeight := pdf.GetPageSize()
|
|
|
|
// Top accent bar
|
|
pdf.SetFillColor(colorPrimary[0], colorPrimary[1], colorPrimary[2])
|
|
pdf.Rect(0, 0, pageWidth, 8, "F")
|
|
|
|
// Branding area
|
|
if g.drawBrandLogo(pdf, data.Brand, pageWidth/2-20, 34, 40, 18) {
|
|
pdf.SetY(58)
|
|
} else {
|
|
pdf.SetY(50)
|
|
}
|
|
pdf.SetTextColor(colorPrimary[0], colorPrimary[1], colorPrimary[2])
|
|
writeCoverLine(pdf, reportCoverBrandName(data.Brand), 15, 32, 20, "B")
|
|
|
|
pdf.SetFont("Arial", "", 12)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.CellFormat(0, 8, "Infrastructure Monitoring", "", 1, "C", false, 0, "")
|
|
|
|
// Main title
|
|
pdf.SetY(100)
|
|
pdf.SetFont("Arial", "B", 28)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 12, "Performance Report", "", 1, "C", false, 0, "")
|
|
|
|
// Resource info box
|
|
pdf.SetY(130)
|
|
boxX := 40.0
|
|
boxWidth := pageWidth - 80
|
|
boxHeight := 50.0
|
|
|
|
pdf.SetFillColor(colorBackground[0], colorBackground[1], colorBackground[2])
|
|
pdf.SetDrawColor(colorGridLine[0], colorGridLine[1], colorGridLine[2])
|
|
pdf.RoundedRect(boxX, pdf.GetY(), boxWidth, boxHeight, 3, "1234", "FD")
|
|
|
|
// Resource details inside box. Lead with the human-readable resource
|
|
// name when enrichment resolved one; the raw resource ID is operator
|
|
// plumbing, not something a report reader can act on.
|
|
pdf.SetY(pdf.GetY() + 10)
|
|
pdf.SetFont("Arial", "B", 11)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.CellFormat(0, 7, "RESOURCE", "", 1, "C", false, 0, "")
|
|
|
|
pdf.SetFont("Arial", "B", 16)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 10, reportSubjectDisplayName(data), "", 1, "C", false, 0, "")
|
|
|
|
pdf.SetFont("Arial", "", 11)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.CellFormat(0, 7, GetResourceTypeDisplayName(data.ResourceType), "", 1, "C", false, 0, "")
|
|
|
|
// Time period
|
|
pdf.SetY(200)
|
|
pdf.SetFont("Arial", "B", 11)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.CellFormat(0, 7, "REPORTING PERIOD", "", 1, "C", false, 0, "")
|
|
|
|
pdf.SetFont("Arial", "", 12)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
periodStr := fmt.Sprintf("%s - %s",
|
|
data.Start.Format("January 2, 2006 15:04"),
|
|
data.End.Format("January 2, 2006 15:04"))
|
|
pdf.CellFormat(0, 8, periodStr, "", 1, "C", false, 0, "")
|
|
|
|
// Duration
|
|
duration := data.End.Sub(data.Start)
|
|
durationStr := formatDuration(duration)
|
|
pdf.SetFont("Arial", "", 10)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.CellFormat(0, 6, fmt.Sprintf("(%s)", durationStr), "", 1, "C", false, 0, "")
|
|
|
|
// Bottom section
|
|
pdf.SetY(pageHeight - 50)
|
|
pdf.SetFont("Arial", "", 10)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.CellFormat(0, 6, fmt.Sprintf("Generated: %s", data.GeneratedAt.Format("January 2, 2006 at 15:04 MST")), "", 1, "C", false, 0, "")
|
|
pdf.CellFormat(0, 6, fmt.Sprintf("Data Points: %d", data.TotalPoints), "", 1, "C", false, 0, "")
|
|
|
|
// Bottom accent bar
|
|
pdf.SetFillColor(colorPrimary[0], colorPrimary[1], colorPrimary[2])
|
|
pdf.Rect(0, pageHeight-8, pageWidth, 8, "F")
|
|
}
|
|
|
|
// writeExecutiveSummary writes the executive summary with health status
|
|
func (g *PDFGenerator) writeExecutiveSummary(pdf *fpdf.Fpdf, data *ReportData) {
|
|
pageWidth, _ := pdf.GetPageSize()
|
|
|
|
// Determine overall health status. An alert that closed without
|
|
// recovering (moved to its Pulse agent) still counts until the report
|
|
// lists the successor's own alert for that metric.
|
|
health := assessAlertHealth(data.Alerts)
|
|
healthStatus := health.Status
|
|
healthColor := colorAccent // Green
|
|
healthMessage := health.Message
|
|
|
|
unresolved := health.Unresolved
|
|
activeAlerts := len(unresolved)
|
|
criticalAlerts := health.Critical
|
|
warningAlerts := health.Warning
|
|
|
|
if criticalAlerts > 0 {
|
|
healthColor = colorDanger
|
|
} else if warningAlerts > 0 {
|
|
healthColor = colorWarning
|
|
} else if data.TotalPoints == 0 && len(data.Summary.ByMetric) == 0 {
|
|
// No metrics arrived for the requested window. Reporting
|
|
// HEALTHY would green-light an empty report and mislead the
|
|
// operator into thinking the resource is fine when really
|
|
// Pulse has no data to evaluate. Fall back to a muted
|
|
// "NO DATA" card so the executive summary stays consistent
|
|
// with the "Data Points: 0" line on the cover.
|
|
healthStatus = "NO DATA"
|
|
healthColor = colorTextMuted
|
|
healthMessage = "No metrics reported during the selected window"
|
|
}
|
|
|
|
// Health Status Card
|
|
cardX := 20.0
|
|
cardWidth := pageWidth - 40
|
|
cardHeight := 35.0
|
|
|
|
pdf.SetFillColor(healthColor[0], healthColor[1], healthColor[2])
|
|
pdf.RoundedRect(cardX, pdf.GetY(), cardWidth, cardHeight, 3, "1234", "F")
|
|
|
|
// Health status text
|
|
pdf.SetXY(cardX, pdf.GetY()+8)
|
|
pdf.SetFont("Arial", "B", 24)
|
|
pdf.SetTextColor(255, 255, 255)
|
|
pdf.CellFormat(cardWidth, 12, healthStatus, "", 1, "C", false, 0, "")
|
|
|
|
pdf.SetFont("Arial", "", 11)
|
|
pdf.CellFormat(cardWidth, 8, healthMessage, "", 1, "C", false, 0, "")
|
|
|
|
pdf.SetY(pdf.GetY() + 15)
|
|
|
|
// AI-generated executive prose, rendered between the deterministic
|
|
// health card and the deterministic Quick Stats table. Only shown when
|
|
// the narrator (AI or heuristic) supplied prose; the heuristic narrator
|
|
// leaves this empty so existing reports look unchanged when AI is off.
|
|
if data.Narrative != nil && strings.TrimSpace(data.Narrative.ExecutiveSummary) != "" {
|
|
pdf.SetFont("Arial", "", 10)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.MultiCell(cardWidth, 5, data.Narrative.ExecutiveSummary, "", "L", false)
|
|
pdf.Ln(3)
|
|
}
|
|
|
|
g.writeAvailabilitySection(pdf, data.Availability)
|
|
|
|
// Quick Stats - simple table format (avoids fpdf positioning bugs)
|
|
pdf.SetFont("Arial", "B", 11)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 8, "Quick Stats", "", 1, "L", false, 0, "")
|
|
pdf.Ln(2)
|
|
|
|
// Calculate stats
|
|
var avgCPU, avgMem, avgDisk float64
|
|
if stats, ok := data.Summary.ByMetric["cpu"]; ok {
|
|
avgCPU = stats.Avg
|
|
}
|
|
if stats, ok := data.Summary.ByMetric["memory"]; ok {
|
|
avgMem = stats.Avg
|
|
}
|
|
if stats, ok := data.Summary.ByMetric["disk"]; ok {
|
|
avgDisk = stats.Avg
|
|
} else if stats, ok := data.Summary.ByMetric["usage"]; ok {
|
|
avgDisk = stats.Avg
|
|
}
|
|
|
|
// Simple table header - darker text for better visibility
|
|
colWidth := 42.5
|
|
pdf.SetFillColor(colorBackground[0], colorBackground[1], colorBackground[2])
|
|
pdf.SetFont("Arial", "B", 9)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(colWidth, 7, "CPU", "0", 0, "C", true, 0, "")
|
|
pdf.CellFormat(colWidth, 7, "Memory", "0", 0, "C", true, 0, "")
|
|
pdf.CellFormat(colWidth, 7, "Disk", "0", 0, "C", true, 0, "")
|
|
pdf.CellFormat(colWidth, 7, "Alerts", "0", 1, "C", true, 0, "")
|
|
|
|
// Values row - large numbers
|
|
pdf.SetFont("Arial", "B", 16)
|
|
pdf.SetTextColor(getStatColor(avgCPU)[0], getStatColor(avgCPU)[1], getStatColor(avgCPU)[2])
|
|
pdf.CellFormat(colWidth, 9, fmt.Sprintf("%.1f%%", avgCPU), "0", 0, "C", false, 0, "")
|
|
pdf.SetTextColor(getStatColor(avgMem)[0], getStatColor(avgMem)[1], getStatColor(avgMem)[2])
|
|
pdf.CellFormat(colWidth, 9, fmt.Sprintf("%.1f%%", avgMem), "0", 0, "C", false, 0, "")
|
|
pdf.SetTextColor(getStatColor(avgDisk)[0], getStatColor(avgDisk)[1], getStatColor(avgDisk)[2])
|
|
pdf.CellFormat(colWidth, 9, fmt.Sprintf("%.1f%%", avgDisk), "0", 0, "C", false, 0, "")
|
|
pdf.SetTextColor(getAlertCountColor(activeAlerts)[0], getAlertCountColor(activeAlerts)[1], getAlertCountColor(activeAlerts)[2])
|
|
pdf.CellFormat(colWidth, 9, fmt.Sprintf("%d", activeAlerts), "0", 1, "C", false, 0, "")
|
|
|
|
// Labels row with trend indicators
|
|
pdf.SetFont("Arial", "", 7)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
|
|
// Calculate trends (compare first half avg to second half avg)
|
|
cpuTrend := g.calculateTrend(data, "cpu")
|
|
memTrend := g.calculateTrend(data, "memory")
|
|
diskTrend := g.calculateTrend(data, "disk")
|
|
if diskTrend == "" {
|
|
diskTrend = g.calculateTrend(data, "usage")
|
|
}
|
|
|
|
pdf.CellFormat(colWidth, 5, "avg "+cpuTrend, "0", 0, "C", false, 0, "")
|
|
pdf.CellFormat(colWidth, 5, "avg "+memTrend, "0", 0, "C", false, 0, "")
|
|
pdf.CellFormat(colWidth, 5, "avg "+diskTrend, "0", 0, "C", false, 0, "")
|
|
pdf.CellFormat(colWidth, 5, "active now", "0", 1, "C", false, 0, "")
|
|
|
|
pdf.Ln(5)
|
|
|
|
// Key Observations section
|
|
pdf.SetFont("Arial", "B", 11)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 8, "Key Observations", "", 1, "L", false, 0, "")
|
|
pdf.Ln(2)
|
|
|
|
observations := g.generateObservations(data)
|
|
pdf.SetFont("Arial", "", 10)
|
|
for _, obs := range observations {
|
|
// Draw colored bullet circle
|
|
bulletX := pdf.GetX() + 3
|
|
bulletY := pdf.GetY() + 3
|
|
pdf.SetFillColor(obs.color[0], obs.color[1], obs.color[2])
|
|
pdf.Circle(bulletX, bulletY, 2, "F")
|
|
pdf.SetX(pdf.GetX() + 8)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 6, obs.text, "", 1, "L", false, 0, "")
|
|
pdf.Ln(1)
|
|
}
|
|
|
|
// Active Alerts summary (if any)
|
|
if activeAlerts > 0 {
|
|
pdf.Ln(5)
|
|
pdf.SetFont("Arial", "B", 11)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 8, "Active Alerts", "", 1, "L", false, 0, "")
|
|
pdf.Ln(2)
|
|
|
|
pdf.SetFont("Arial", "", 9)
|
|
alertCount := 0
|
|
for _, alert := range unresolved {
|
|
if alertCount < 5 {
|
|
if alert.Level == "critical" {
|
|
pdf.SetTextColor(colorDanger[0], colorDanger[1], colorDanger[2])
|
|
pdf.CellFormat(8, 5, "!", "", 0, "C", false, 0, "")
|
|
} else {
|
|
pdf.SetTextColor(colorWarning[0], colorWarning[1], colorWarning[2])
|
|
pdf.CellFormat(8, 5, "!", "", 0, "C", false, 0, "")
|
|
}
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 5, unresolvedAlertLine(alert, 70), "", 1, "L", false, 0, "")
|
|
alertCount++
|
|
}
|
|
}
|
|
if activeAlerts > 5 {
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.CellFormat(0, 5, fmt.Sprintf("... and %d more alerts", activeAlerts-5), "", 1, "L", false, 0, "")
|
|
}
|
|
}
|
|
|
|
// Recommendations section
|
|
recommendations := g.generateRecommendations(data, criticalAlerts, warningAlerts)
|
|
if len(recommendations) > 0 {
|
|
pdf.Ln(5)
|
|
pdf.SetFont("Arial", "B", 11)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 8, "Recommended Actions", "", 1, "L", false, 0, "")
|
|
pdf.Ln(2)
|
|
|
|
pdf.SetFont("Arial", "", 9)
|
|
for i, rec := range recommendations {
|
|
if i >= 4 {
|
|
break // Limit to 4 recommendations
|
|
}
|
|
pdf.SetTextColor(colorSecondary[0], colorSecondary[1], colorSecondary[2])
|
|
pdf.CellFormat(6, 5, fmt.Sprintf("%d.", i+1), "", 0, "L", false, 0, "")
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 5, rec, "", 1, "L", false, 0, "")
|
|
pdf.Ln(1)
|
|
}
|
|
}
|
|
|
|
// Period-over-period comparison. The AI narrator populates this when
|
|
// the engine supplied prior-window stats; the heuristic narrator leaves
|
|
// it empty.
|
|
if data.Narrative != nil {
|
|
comparison := strings.TrimSpace(data.Narrative.PeriodComparison)
|
|
if comparison != "" {
|
|
pdf.Ln(5)
|
|
pdf.SetFont("Arial", "B", 11)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 8, "Period-over-period changes", "", 1, "L", false, 0, "")
|
|
pdf.Ln(2)
|
|
pdf.SetFont("Arial", "", 9)
|
|
pdf.MultiCell(pageWidth-40, 5, comparison, "", "L", false)
|
|
}
|
|
disclaimer := strings.TrimSpace(data.Narrative.Disclaimer)
|
|
if disclaimer != "" {
|
|
pdf.Ln(4)
|
|
pdf.SetFont("Arial", "I", 8)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.MultiCell(pageWidth-40, 4, disclaimer, "", "L", false)
|
|
} else if data.Narrative.Source == NarrativeSourceHeuristic {
|
|
// Discoverability nudge: when the report fell back to the
|
|
// deterministic heuristic narrator, surface a one-line tip
|
|
// so users learn the AI upgrade exists. Without this, a
|
|
// user whose AI is unconfigured (or whose provider failed)
|
|
// has zero indication that AI-narrated reports are a
|
|
// separate capability behind Pulse Assistant.
|
|
pdf.Ln(4)
|
|
pdf.SetFont("Arial", "I", 8)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.MultiCell(pageWidth-40, 4,
|
|
"Tip: Configure Pulse Assistant in Settings to add AI-narrated executive summaries, fleet outlier detection, and period-over-period comparison to future reports.",
|
|
"", "L", false)
|
|
}
|
|
}
|
|
|
|
pdf.Ln(10)
|
|
}
|
|
|
|
// observation represents a key observation for the executive summary
|
|
type observation struct {
|
|
icon string
|
|
text string
|
|
color [3]int
|
|
}
|
|
|
|
// generateObservations returns the observation bullets for the executive
|
|
// summary. When data.Narrative is set (AI-generated or pre-computed
|
|
// heuristic), its bullets are used directly. Otherwise the heuristic
|
|
// narrator is invoked synchronously.
|
|
func (g *PDFGenerator) generateObservations(data *ReportData) []observation {
|
|
bullets := narrativeBulletsForRender(data)
|
|
if len(bullets) == 0 {
|
|
return []observation{{
|
|
icon: "-",
|
|
text: "Insufficient data for detailed analysis",
|
|
color: colorTextMuted,
|
|
}}
|
|
}
|
|
out := make([]observation, 0, len(bullets))
|
|
for _, b := range bullets {
|
|
out = append(out, observation{
|
|
icon: "-",
|
|
text: b.Text,
|
|
color: bulletColor(b.Severity),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func narrativeBulletsForRender(data *ReportData) []NarrativeBullet {
|
|
if data == nil {
|
|
return nil
|
|
}
|
|
if data.Narrative != nil && len(data.Narrative.Observations) > 0 {
|
|
return data.Narrative.Observations
|
|
}
|
|
in := narrativeInputFromReport(data, nil, nil)
|
|
out, _ := HeuristicNarrator{}.Narrate(context.Background(), in)
|
|
return out.Observations
|
|
}
|
|
|
|
func bulletColor(severity string) [3]int {
|
|
switch severity {
|
|
case NarrativeSeverityCritical:
|
|
return colorDanger
|
|
case NarrativeSeverityWarning:
|
|
return colorWarning
|
|
case NarrativeSeverityInfo:
|
|
return colorSecondary
|
|
case NarrativeSeverityOK:
|
|
return colorAccent
|
|
default:
|
|
return colorTextMuted
|
|
}
|
|
}
|
|
|
|
// calculateTrend compares first half to second half of data points
|
|
func (g *PDFGenerator) calculateTrend(data *ReportData, metricType string) string {
|
|
points, ok := data.Metrics[metricType]
|
|
if !ok || len(points) < 10 {
|
|
return ""
|
|
}
|
|
|
|
// Calculate average of first half and second half
|
|
mid := len(points) / 2
|
|
var firstSum, secondSum float64
|
|
for i := 0; i < mid; i++ {
|
|
firstSum += points[i].Value
|
|
}
|
|
for i := mid; i < len(points); i++ {
|
|
secondSum += points[i].Value
|
|
}
|
|
firstAvg := firstSum / float64(mid)
|
|
secondAvg := secondSum / float64(len(points)-mid)
|
|
|
|
// Calculate percentage change
|
|
if firstAvg == 0 {
|
|
return ""
|
|
}
|
|
change := ((secondAvg - firstAvg) / firstAvg) * 100
|
|
|
|
// Only show trend if significant (>5% change)
|
|
if change > 5 {
|
|
return "(trending up)"
|
|
} else if change < -5 {
|
|
return "(trending down)"
|
|
}
|
|
return "(stable)"
|
|
}
|
|
|
|
// generateRecommendations returns the recommendations list for the executive
|
|
// summary. When data.Narrative is set, its recommendations are returned
|
|
// directly. Otherwise the heuristic narrator is invoked synchronously. The
|
|
// criticalAlerts and warningAlerts arguments are retained for callers but
|
|
// are derived from data.Alerts inside the heuristic narrator.
|
|
func (g *PDFGenerator) generateRecommendations(data *ReportData, criticalAlerts, warningAlerts int) []string {
|
|
_, _ = criticalAlerts, warningAlerts
|
|
if data != nil && data.Narrative != nil && len(data.Narrative.Recommendations) > 0 {
|
|
return data.Narrative.Recommendations
|
|
}
|
|
in := narrativeInputFromReport(data, nil, nil)
|
|
out, _ := HeuristicNarrator{}.Narrate(context.Background(), in)
|
|
return out.Recommendations
|
|
}
|
|
|
|
// getStatColor returns color based on percentage value
|
|
func getStatColor(val float64) [3]int {
|
|
if val >= 90 {
|
|
return colorDanger
|
|
} else if val >= 75 {
|
|
return colorWarning
|
|
}
|
|
return colorAccent
|
|
}
|
|
|
|
// getAlertCountColor returns color based on alert count
|
|
func getAlertCountColor(count int) [3]int {
|
|
if count > 0 {
|
|
return colorDanger
|
|
}
|
|
return colorAccent
|
|
}
|
|
|
|
// addPageHeader adds a consistent header to content pages.
|
|
func (g *PDFGenerator) addPageHeader(pdf *fpdf.Fpdf, data *ReportData, section string) {
|
|
pageWidth, _ := pdf.GetPageSize()
|
|
|
|
// Top line
|
|
pdf.SetDrawColor(colorPrimary[0], colorPrimary[1], colorPrimary[2])
|
|
pdf.SetLineWidth(0.5)
|
|
pdf.Line(20, 15, pageWidth-20, 15)
|
|
|
|
// Header text
|
|
textX := 20.0
|
|
if g.drawBrandLogo(pdf, data.Brand, 20, 17, 7, 7) {
|
|
textX = 30
|
|
}
|
|
pdf.SetXY(textX, 18)
|
|
pdf.SetFont("Arial", "B", 9)
|
|
pdf.SetTextColor(colorPrimary[0], colorPrimary[1], colorPrimary[2])
|
|
pdf.CellFormat(pageWidth-textX-20, 5, reportHeaderBrandName(data.Brand, "PULSE")+" PERFORMANCE REPORT", "", 0, "L", false, 0, "")
|
|
|
|
pdf.SetFont("Arial", "", 9)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.SetXY(20, 18)
|
|
pdf.CellFormat(pageWidth-40, 5, reportSubjectDisplayName(data), "", 1, "R", false, 0, "")
|
|
|
|
// Section title
|
|
pdf.SetY(30)
|
|
pdf.SetFont("Arial", "B", 18)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 10, section, "", 1, "L", false, 0, "")
|
|
|
|
pdf.Ln(5)
|
|
}
|
|
|
|
// writeSummarySection writes the metrics summary with stats cards.
|
|
func (g *PDFGenerator) writeSummarySection(pdf *fpdf.Fpdf, data *ReportData) {
|
|
// Time period subtitle
|
|
pdf.SetFont("Arial", "", 10)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
periodStr := fmt.Sprintf("Statistics for %s to %s (%s)",
|
|
data.Start.Format("Jan 2, 2006 15:04"),
|
|
data.End.Format("Jan 2, 2006 15:04"),
|
|
formatDuration(data.End.Sub(data.Start)))
|
|
pdf.CellFormat(0, 6, periodStr, "", 1, "L", false, 0, "")
|
|
pdf.Ln(5)
|
|
|
|
if len(data.Summary.ByMetric) == 0 {
|
|
pdf.SetFont("Arial", "I", 11)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.CellFormat(0, 10, "No metrics data available for this period.", "", 1, "L", false, 0, "")
|
|
return
|
|
}
|
|
|
|
// Get sorted metric names
|
|
metricNames := make([]string, 0, len(data.Summary.ByMetric))
|
|
for name := range data.Summary.ByMetric {
|
|
metricNames = append(metricNames, name)
|
|
}
|
|
sort.Strings(metricNames)
|
|
|
|
// Stats cards - 2 per row
|
|
cardWidth := 82.0
|
|
cardHeight := 45.0
|
|
cardGap := 6.0
|
|
startX := 20.0
|
|
rowStartY := pdf.GetY()
|
|
|
|
_, pageHeight := pdf.GetPageSize()
|
|
cardBottomLimit := pageHeight - 30
|
|
|
|
for i, metricType := range metricNames {
|
|
stats := data.Summary.ByMetric[metricType]
|
|
unit := GetMetricUnit(metricType)
|
|
|
|
col := i % 2
|
|
if col == 0 && i > 0 {
|
|
// Move to next row
|
|
rowStartY = rowStartY + cardHeight + cardGap
|
|
pdf.SetY(rowStartY)
|
|
} else if col == 1 {
|
|
// Second column - return to row start Y
|
|
pdf.SetY(rowStartY)
|
|
}
|
|
|
|
// The grid positions cards absolutely, so it must paginate
|
|
// itself: a row that would cross the bottom margin previously
|
|
// fought fpdf's auto page break and scattered one orphan
|
|
// element per page for the rest of the section.
|
|
if col == 0 && rowStartY+cardHeight > cardBottomLimit {
|
|
pdf.AddPage()
|
|
rowStartY = pdf.GetY()
|
|
pdf.SetY(rowStartY)
|
|
}
|
|
|
|
cardX := startX + float64(col)*(cardWidth+cardGap)
|
|
cardY := rowStartY
|
|
|
|
// Card background
|
|
pdf.SetFillColor(255, 255, 255)
|
|
pdf.SetDrawColor(colorGridLine[0], colorGridLine[1], colorGridLine[2])
|
|
pdf.RoundedRect(cardX, cardY, cardWidth, cardHeight, 2, "1234", "FD")
|
|
|
|
// Card header with color bar
|
|
headerColor := getMetricColor(metricType)
|
|
pdf.SetFillColor(headerColor[0], headerColor[1], headerColor[2])
|
|
pdf.Rect(cardX, cardY, cardWidth, 3, "F")
|
|
|
|
// Metric name
|
|
pdf.SetXY(cardX+5, cardY+6)
|
|
pdf.SetFont("Arial", "B", 10)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(cardWidth-10, 6, GetMetricTypeDisplayName(metricType), "", 1, "L", false, 0, "")
|
|
|
|
// Current value (large)
|
|
pdf.SetXY(cardX+5, cardY+14)
|
|
pdf.SetFont("Arial", "B", 20)
|
|
pdf.SetTextColor(headerColor[0], headerColor[1], headerColor[2])
|
|
pdf.CellFormat(cardWidth-10, 10, formatMetricValue(stats.Current, unit), "", 1, "L", false, 0, "")
|
|
|
|
// Stats row
|
|
pdf.SetFont("Arial", "", 8)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
statsY := cardY + 28
|
|
|
|
// Two bounded label/value column pairs. Value cells must not use
|
|
// width 0 (extend to margin): long byte-rate values would run
|
|
// under the second column's labels.
|
|
writeStat := func(x, y float64, label, value string) {
|
|
pdf.SetFont("Arial", "", 8)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.SetXY(x, y)
|
|
pdf.CellFormat(13, 5, label, "", 0, "L", false, 0, "")
|
|
pdf.SetFont("Arial", "B", 8)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(26, 5, value, "", 0, "L", false, 0, "")
|
|
}
|
|
writeStat(cardX+5, statsY, "Min", formatMetricValue(stats.Min, unit))
|
|
writeStat(cardX+5, statsY+6, "Max", formatMetricValue(stats.Max, unit))
|
|
writeStat(cardX+46, statsY, "Avg", formatMetricValue(stats.Avg, unit))
|
|
writeStat(cardX+46, statsY+6, "Samples", fmt.Sprintf("%d", stats.Count))
|
|
}
|
|
|
|
// rowStartY tracks the current (possibly paginated) row, so the
|
|
// section ends just below the last row of cards.
|
|
pdf.SetY(rowStartY + cardHeight + cardGap)
|
|
}
|
|
|
|
// writeChartsSection writes charts for each metric.
|
|
func (g *PDFGenerator) writeChartsSection(pdf *fpdf.Fpdf, data *ReportData) {
|
|
if len(data.Metrics) == 0 {
|
|
return
|
|
}
|
|
|
|
// Get sorted metric names
|
|
metricNames := make([]string, 0, len(data.Metrics))
|
|
for name := range data.Metrics {
|
|
metricNames = append(metricNames, name)
|
|
}
|
|
sort.Strings(metricNames)
|
|
|
|
chartWidth := 170.0
|
|
chartHeight := 55.0
|
|
|
|
// Count valid charts
|
|
validCharts := 0
|
|
for _, metricType := range metricNames {
|
|
if len(data.Metrics[metricType]) >= 2 {
|
|
validCharts++
|
|
}
|
|
}
|
|
if validCharts == 0 {
|
|
return
|
|
}
|
|
|
|
// Always start charts on a new page with proper header
|
|
pdf.AddPage()
|
|
g.addPageHeader(pdf, data, "Performance Charts")
|
|
|
|
for _, metricType := range metricNames {
|
|
points := data.Metrics[metricType]
|
|
if len(points) < 2 {
|
|
continue
|
|
}
|
|
|
|
// Check if we need a new page (need space for chart title + chart + labels)
|
|
if pdf.GetY() > 195 {
|
|
pdf.AddPage()
|
|
g.addPageHeader(pdf, data, "Performance Charts")
|
|
}
|
|
|
|
// Chart title
|
|
pdf.SetFont("Arial", "B", 11)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
unit := GetMetricUnit(metricType)
|
|
titleStr := GetMetricTypeDisplayName(metricType)
|
|
if unit != "" {
|
|
titleStr = fmt.Sprintf("%s (%s)", titleStr, unit)
|
|
}
|
|
pdf.CellFormat(0, 7, titleStr, "", 1, "L", false, 0, "")
|
|
|
|
chartX := 20.0
|
|
chartY := pdf.GetY()
|
|
|
|
g.drawChart(pdf, points, chartX, chartY, chartWidth, chartHeight, metricType)
|
|
|
|
pdf.SetY(chartY + chartHeight + 12)
|
|
}
|
|
}
|
|
|
|
// drawChart draws a single chart with grid, area fill, and line.
|
|
func (g *PDFGenerator) drawChart(pdf *fpdf.Fpdf, points []MetricDataPoint, x, y, width, height float64, metricType string) {
|
|
// Chart background
|
|
pdf.SetFillColor(255, 255, 255)
|
|
pdf.SetDrawColor(colorGridLine[0], colorGridLine[1], colorGridLine[2])
|
|
pdf.SetLineWidth(0.3)
|
|
pdf.Rect(x, y, width, height, "FD")
|
|
|
|
// Find min/max for scaling
|
|
minVal, maxVal := points[0].Value, points[0].Value
|
|
for _, p := range points {
|
|
if p.Value < minVal {
|
|
minVal = p.Value
|
|
}
|
|
if p.Value > maxVal {
|
|
maxVal = p.Value
|
|
}
|
|
}
|
|
|
|
// Add padding to range
|
|
valRange := maxVal - minVal
|
|
if valRange < 1 {
|
|
valRange = 10
|
|
}
|
|
minVal = math.Max(0, minVal-valRange*0.1)
|
|
maxVal = maxVal + valRange*0.1
|
|
|
|
// Draw horizontal grid lines and Y-axis labels
|
|
pdf.SetFont("Arial", "", 7)
|
|
numGridLines := 5
|
|
for i := 0; i <= numGridLines; i++ {
|
|
gridY := y + height - (float64(i)/float64(numGridLines))*height
|
|
val := minVal + (float64(i)/float64(numGridLines))*(maxVal-minVal)
|
|
|
|
// Grid line
|
|
pdf.SetDrawColor(colorGridLine[0], colorGridLine[1], colorGridLine[2])
|
|
pdf.SetLineWidth(0.1)
|
|
pdf.Line(x, gridY, x+width, gridY)
|
|
|
|
// Y-axis label
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.SetXY(x-15, gridY-2)
|
|
pdf.CellFormat(12, 5, fmt.Sprintf("%.0f", val), "", 0, "R", false, 0, "")
|
|
}
|
|
|
|
// Time calculations
|
|
startTime := points[0].Timestamp.Unix()
|
|
endTime := points[len(points)-1].Timestamp.Unix()
|
|
timeRange := float64(endTime - startTime)
|
|
if timeRange == 0 {
|
|
timeRange = 1
|
|
}
|
|
|
|
// Build polygon points for area fill
|
|
chartColor := getMetricColor(metricType)
|
|
|
|
// Draw area fill
|
|
pdf.SetFillColor(chartColor[0], chartColor[1], chartColor[2])
|
|
pdf.SetAlpha(0.15, "Normal")
|
|
|
|
polyStr := ""
|
|
for i, p := range points {
|
|
xPos := x + 2 + (float64(p.Timestamp.Unix()-startTime)/timeRange)*(width-4)
|
|
yPos := y + height - 2 - ((p.Value-minVal)/(maxVal-minVal))*(height-4)
|
|
yPos = math.Max(y+2, math.Min(y+height-2, yPos))
|
|
|
|
if i == 0 {
|
|
polyStr = fmt.Sprintf("%.2f %.2f m ", xPos, y+height-2)
|
|
}
|
|
polyStr += fmt.Sprintf("%.2f %.2f l ", xPos, yPos)
|
|
}
|
|
// Close polygon
|
|
lastX := x + 2 + (float64(points[len(points)-1].Timestamp.Unix()-startTime)/timeRange)*(width-4)
|
|
polyStr += fmt.Sprintf("%.2f %.2f l h f", lastX, y+height-2)
|
|
|
|
// Use raw PDF drawing for polygon
|
|
// Actually, fpdf doesn't support arbitrary polygons easily, so we'll use a different approach
|
|
// Draw as many small rectangles to approximate the fill
|
|
pdf.SetAlpha(0.2, "Normal")
|
|
for i := 1; i < len(points); i++ {
|
|
p1 := points[i-1]
|
|
p2 := points[i]
|
|
|
|
x1 := x + 2 + (float64(p1.Timestamp.Unix()-startTime)/timeRange)*(width-4)
|
|
x2 := x + 2 + (float64(p2.Timestamp.Unix()-startTime)/timeRange)*(width-4)
|
|
y1 := y + height - 2 - ((p1.Value-minVal)/(maxVal-minVal))*(height-4)
|
|
y2 := y + height - 2 - ((p2.Value-minVal)/(maxVal-minVal))*(height-4)
|
|
|
|
y1 = math.Max(y+2, math.Min(y+height-2, y1))
|
|
y2 = math.Max(y+2, math.Min(y+height-2, y2))
|
|
|
|
// Draw trapezoid approximation using polygon
|
|
pdf.Polygon([]fpdf.PointType{
|
|
{X: x1, Y: y1},
|
|
{X: x2, Y: y2},
|
|
{X: x2, Y: y + height - 2},
|
|
{X: x1, Y: y + height - 2},
|
|
}, "F")
|
|
}
|
|
|
|
pdf.SetAlpha(1, "Normal")
|
|
|
|
// Draw the line
|
|
pdf.SetDrawColor(chartColor[0], chartColor[1], chartColor[2])
|
|
pdf.SetLineWidth(0.8)
|
|
|
|
prevX, prevY := 0.0, 0.0
|
|
for i, p := range points {
|
|
xPos := x + 2 + (float64(p.Timestamp.Unix()-startTime)/timeRange)*(width-4)
|
|
yPos := y + height - 2 - ((p.Value-minVal)/(maxVal-minVal))*(height-4)
|
|
yPos = math.Max(y+2, math.Min(y+height-2, yPos))
|
|
|
|
if i > 0 {
|
|
pdf.Line(prevX, prevY, xPos, yPos)
|
|
}
|
|
prevX, prevY = xPos, yPos
|
|
}
|
|
|
|
// X-axis labels
|
|
pdf.SetFont("Arial", "", 7)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.SetXY(x, y+height+1)
|
|
pdf.CellFormat(40, 4, points[0].Timestamp.Format("Jan 2 15:04"), "", 0, "L", false, 0, "")
|
|
pdf.SetXY(x+width-40, y+height+1)
|
|
pdf.CellFormat(40, 4, points[len(points)-1].Timestamp.Format("Jan 2 15:04"), "", 0, "R", false, 0, "")
|
|
}
|
|
|
|
// writeDataSection writes the data table.
|
|
func (g *PDFGenerator) writeDataSection(pdf *fpdf.Fpdf, data *ReportData) {
|
|
if len(data.Metrics) == 0 {
|
|
return
|
|
}
|
|
|
|
// Always start data section on a new page with proper header
|
|
pdf.AddPage()
|
|
g.addPageHeader(pdf, data, "Data Sample")
|
|
|
|
pdf.SetFont("Arial", "", 9)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.CellFormat(0, 6, "Showing up to 50 data points. Export as CSV for the complete dataset.", "", 1, "L", false, 0, "")
|
|
pdf.Ln(3)
|
|
|
|
// Get sorted metric names
|
|
metricNames := make([]string, 0, len(data.Metrics))
|
|
for name := range data.Metrics {
|
|
metricNames = append(metricNames, name)
|
|
}
|
|
sort.Strings(metricNames)
|
|
|
|
// Calculate column widths - ensure enough space for metric headers
|
|
timestampWidth := 35.0
|
|
availableWidth := 170.0 - timestampWidth
|
|
metricWidth := availableWidth / float64(len(metricNames))
|
|
if metricWidth < 30 {
|
|
metricWidth = 30
|
|
}
|
|
|
|
// Table header
|
|
pdf.SetFillColor(colorTableHeader[0], colorTableHeader[1], colorTableHeader[2])
|
|
pdf.SetTextColor(255, 255, 255)
|
|
pdf.SetFont("Arial", "B", 7)
|
|
|
|
pdf.CellFormat(timestampWidth, 7, "Timestamp", "1", 0, "C", true, 0, "")
|
|
for _, name := range metricNames {
|
|
displayName := GetMetricTypeDisplayName(name)
|
|
unit := GetMetricUnit(name)
|
|
if unit != "" {
|
|
displayName = fmt.Sprintf("%s (%s)", displayName, unit)
|
|
}
|
|
if len(displayName) > 18 {
|
|
displayName = displayName[:18]
|
|
}
|
|
pdf.CellFormat(metricWidth, 7, displayName, "1", 0, "C", true, 0, "")
|
|
}
|
|
pdf.Ln(-1)
|
|
|
|
// Collect timestamps
|
|
timestampSet := make(map[int64]bool)
|
|
metricsByTime := make(map[string]map[int64]float64)
|
|
|
|
for metricName, points := range data.Metrics {
|
|
metricsByTime[metricName] = make(map[int64]float64)
|
|
for _, p := range points {
|
|
ts := p.Timestamp.Unix()
|
|
timestampSet[ts] = true
|
|
metricsByTime[metricName][ts] = p.Value
|
|
}
|
|
}
|
|
|
|
timestamps := make([]int64, 0, len(timestampSet))
|
|
for ts := range timestampSet {
|
|
timestamps = append(timestamps, ts)
|
|
}
|
|
sort.Slice(timestamps, func(i, j int) bool { return timestamps[i] < timestamps[j] })
|
|
|
|
// Limit rows
|
|
if len(timestamps) > 50 {
|
|
timestamps = timestamps[:50]
|
|
}
|
|
|
|
// Table rows
|
|
pdf.SetFont("Arial", "", 7)
|
|
fill := false
|
|
|
|
for rowIdx, ts := range timestamps {
|
|
// Check page break
|
|
if pdf.GetY() > 260 {
|
|
pdf.AddPage()
|
|
g.addPageHeader(pdf, data, "Data Sample (continued)")
|
|
pdf.Ln(5)
|
|
|
|
// Re-draw header
|
|
pdf.SetFillColor(colorTableHeader[0], colorTableHeader[1], colorTableHeader[2])
|
|
pdf.SetTextColor(255, 255, 255)
|
|
pdf.SetFont("Arial", "B", 7)
|
|
pdf.CellFormat(timestampWidth, 7, "Timestamp", "1", 0, "C", true, 0, "")
|
|
for _, name := range metricNames {
|
|
displayName := GetMetricTypeDisplayName(name)
|
|
unit := GetMetricUnit(name)
|
|
if unit != "" {
|
|
displayName = fmt.Sprintf("%s (%s)", displayName, unit)
|
|
}
|
|
if len(displayName) > 18 {
|
|
displayName = displayName[:18]
|
|
}
|
|
pdf.CellFormat(metricWidth, 7, displayName, "1", 0, "C", true, 0, "")
|
|
}
|
|
pdf.Ln(-1)
|
|
pdf.SetFont("Arial", "", 7)
|
|
fill = false
|
|
}
|
|
|
|
if fill {
|
|
pdf.SetFillColor(colorTableAlt[0], colorTableAlt[1], colorTableAlt[2])
|
|
} else {
|
|
pdf.SetFillColor(255, 255, 255)
|
|
}
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
|
|
t := time.Unix(ts, 0)
|
|
pdf.CellFormat(timestampWidth, 6, t.Format("Jan 02 15:04:05"), "1", 0, "L", fill, 0, "")
|
|
|
|
for _, metricName := range metricNames {
|
|
if val, ok := metricsByTime[metricName][ts]; ok {
|
|
pdf.CellFormat(metricWidth, 6, fmt.Sprintf("%.2f", val), "1", 0, "C", fill, 0, "")
|
|
} else {
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.CellFormat(metricWidth, 6, "-", "1", 0, "C", fill, 0, "")
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
}
|
|
}
|
|
pdf.Ln(-1)
|
|
fill = !fill
|
|
_ = rowIdx
|
|
}
|
|
}
|
|
|
|
// writeResourceDetails writes resource information section
|
|
func (g *PDFGenerator) writeResourceDetails(pdf *fpdf.Fpdf, data *ReportData) {
|
|
if data.Resource == nil {
|
|
return
|
|
}
|
|
|
|
res := data.Resource
|
|
|
|
// Info grid - 2 columns for short fields, full width for long fields
|
|
pdf.SetFont("Arial", "", 10)
|
|
leftCol := 20.0
|
|
rightCol := 105.0
|
|
labelWidth := 30.0
|
|
valueWidth := 50.0
|
|
|
|
// Helper to write a label-value pair in a column
|
|
writeField := func(x float64, label, value string) {
|
|
pdf.SetXY(x, pdf.GetY())
|
|
pdf.SetFont("Arial", "", 9)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.CellFormat(labelWidth, 6, label, "", 0, "L", false, 0, "")
|
|
pdf.SetFont("Arial", "B", 9)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
// Truncate long values to fit in column
|
|
if len(value) > 35 {
|
|
value = value[:32] + "..."
|
|
}
|
|
pdf.CellFormat(valueWidth, 6, value, "", 0, "L", false, 0, "")
|
|
}
|
|
|
|
// Helper to write a full-width label-value pair
|
|
writeFullWidth := func(label, value string) {
|
|
pdf.SetX(leftCol)
|
|
pdf.SetFont("Arial", "", 9)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.CellFormat(labelWidth, 6, label, "", 0, "L", false, 0, "")
|
|
pdf.SetFont("Arial", "B", 9)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
// Allow longer values for full-width fields
|
|
if len(value) > 80 {
|
|
value = value[:77] + "..."
|
|
}
|
|
pdf.CellFormat(0, 6, value, "", 1, "L", false, 0, "")
|
|
}
|
|
|
|
startY := pdf.GetY()
|
|
|
|
// Left column - basic info
|
|
writeField(leftCol, "Name:", res.Name)
|
|
pdf.SetY(pdf.GetY() + 7)
|
|
writeField(leftCol, "Status:", res.Status)
|
|
pdf.SetY(pdf.GetY() + 7)
|
|
if res.Node != "" {
|
|
writeField(leftCol, "Node:", res.Node)
|
|
pdf.SetY(pdf.GetY() + 7)
|
|
}
|
|
if res.Host != "" {
|
|
writeField(leftCol, "Host:", res.Host)
|
|
pdf.SetY(pdf.GetY() + 7)
|
|
}
|
|
if res.Uptime > 0 {
|
|
writeField(leftCol, "Uptime:", formatUptime(res.Uptime))
|
|
pdf.SetY(pdf.GetY() + 7)
|
|
}
|
|
|
|
leftEndY := pdf.GetY()
|
|
|
|
// Right column - hardware info
|
|
pdf.SetY(startY)
|
|
if res.CPUCores > 0 {
|
|
coreStr := fmt.Sprintf("%d cores", res.CPUCores)
|
|
if res.CPUSockets > 0 {
|
|
coreStr = fmt.Sprintf("%d cores (%d sockets)", res.CPUCores, res.CPUSockets)
|
|
}
|
|
writeField(rightCol, "Cores:", coreStr)
|
|
pdf.SetY(pdf.GetY() + 7)
|
|
}
|
|
if res.MemoryTotal > 0 {
|
|
writeField(rightCol, "Memory:", formatBytes(float64(res.MemoryTotal)))
|
|
pdf.SetY(pdf.GetY() + 7)
|
|
}
|
|
if res.DiskTotal > 0 {
|
|
writeField(rightCol, "Disk:", formatBytes(float64(res.DiskTotal)))
|
|
pdf.SetY(pdf.GetY() + 7)
|
|
}
|
|
if res.Temperature != nil {
|
|
writeField(rightCol, "CPU Temp:", fmt.Sprintf("%.0fC", *res.Temperature))
|
|
pdf.SetY(pdf.GetY() + 7)
|
|
}
|
|
if len(res.LoadAverage) >= 3 {
|
|
writeField(rightCol, "Load:", fmt.Sprintf("%.2f, %.2f, %.2f", res.LoadAverage[0], res.LoadAverage[1], res.LoadAverage[2]))
|
|
pdf.SetY(pdf.GetY() + 7)
|
|
}
|
|
|
|
rightEndY := pdf.GetY()
|
|
if leftEndY > rightEndY {
|
|
pdf.SetY(leftEndY)
|
|
}
|
|
pdf.SetY(pdf.GetY() + 3)
|
|
|
|
// Full-width fields for long values
|
|
if res.CPUModel != "" {
|
|
writeFullWidth("CPU:", res.CPUModel)
|
|
}
|
|
if res.KernelVersion != "" {
|
|
writeFullWidth("Kernel:", res.KernelVersion)
|
|
}
|
|
if res.PVEVersion != "" {
|
|
writeFullWidth("PVE:", res.PVEVersion)
|
|
}
|
|
if res.OSName != "" {
|
|
osStr := res.OSName
|
|
if res.OSVersion != "" {
|
|
osStr = fmt.Sprintf("%s %s", res.OSName, res.OSVersion)
|
|
}
|
|
writeFullWidth("OS:", osStr)
|
|
}
|
|
if len(res.IPAddresses) > 0 {
|
|
writeFullWidth("IP:", res.IPAddresses[0])
|
|
}
|
|
|
|
// Tags
|
|
if len(res.Tags) > 0 {
|
|
pdf.SetY(pdf.GetY() + 3)
|
|
pdf.SetX(leftCol)
|
|
pdf.SetFont("Arial", "", 9)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.CellFormat(labelWidth, 6, "Tags:", "", 0, "L", false, 0, "")
|
|
pdf.SetFont("Arial", "", 9)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
tagStr := ""
|
|
for i, tag := range res.Tags {
|
|
if i > 0 {
|
|
tagStr += ", "
|
|
}
|
|
tagStr += tag
|
|
}
|
|
pdf.CellFormat(0, 6, tagStr, "", 1, "L", false, 0, "")
|
|
}
|
|
|
|
pdf.Ln(8)
|
|
}
|
|
|
|
// writeAlertsSection writes the alerts table
|
|
func (g *PDFGenerator) writeAlertsSection(pdf *fpdf.Fpdf, data *ReportData) {
|
|
if len(data.Alerts) == 0 {
|
|
return
|
|
}
|
|
|
|
pdf.SetFont("Arial", "B", 12)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 8, "Alerts During Period", "", 1, "L", false, 0, "")
|
|
pdf.Ln(2)
|
|
|
|
// Table header
|
|
colWidths := []float64{25, 20, 65, 30, 30}
|
|
headers := []string{"Type", "Level", "Message", "Started", "Resolved"}
|
|
|
|
pdf.SetFillColor(colorTableHeader[0], colorTableHeader[1], colorTableHeader[2])
|
|
pdf.SetTextColor(255, 255, 255)
|
|
pdf.SetFont("Arial", "B", 8)
|
|
|
|
for i, header := range headers {
|
|
pdf.CellFormat(colWidths[i], 7, header, "1", 0, "C", true, 0, "")
|
|
}
|
|
pdf.Ln(-1)
|
|
|
|
// Table rows
|
|
pdf.SetFont("Arial", "", 8)
|
|
fill := false
|
|
|
|
for _, alert := range data.Alerts {
|
|
if fill {
|
|
pdf.SetFillColor(colorTableAlt[0], colorTableAlt[1], colorTableAlt[2])
|
|
} else {
|
|
pdf.SetFillColor(255, 255, 255)
|
|
}
|
|
|
|
// Type
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(colWidths[0], 6, alert.Type, "1", 0, "L", fill, 0, "")
|
|
|
|
// Level with color
|
|
if alert.Level == "critical" {
|
|
pdf.SetTextColor(colorDanger[0], colorDanger[1], colorDanger[2])
|
|
} else {
|
|
pdf.SetTextColor(colorWarning[0], colorWarning[1], colorWarning[2])
|
|
}
|
|
pdf.CellFormat(colWidths[1], 6, alert.Level, "1", 0, "C", fill, 0, "")
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
|
|
// Message (truncate if too long)
|
|
msg := alert.Message
|
|
if len(msg) > 45 {
|
|
msg = msg[:42] + "..."
|
|
}
|
|
pdf.CellFormat(colWidths[2], 6, msg, "1", 0, "L", fill, 0, "")
|
|
|
|
// Started
|
|
pdf.CellFormat(colWidths[3], 6, alert.StartTime.Format("Jan 02 15:04"), "1", 0, "C", fill, 0, "")
|
|
|
|
// Resolved. A close that was not a recovery reads "Moved" in
|
|
// neutral text, never as a green resolve time.
|
|
if alert.ClosedWithoutRecovery() {
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.CellFormat(colWidths[4], 6, closedWithoutRecoveryLabel(alert), "1", 0, "C", fill, 0, "")
|
|
} else if alert.ResolvedTime != nil {
|
|
pdf.SetTextColor(colorAccent[0], colorAccent[1], colorAccent[2])
|
|
pdf.CellFormat(colWidths[4], 6, alert.ResolvedTime.Format("Jan 02 15:04"), "1", 0, "C", fill, 0, "")
|
|
} else {
|
|
pdf.SetTextColor(colorDanger[0], colorDanger[1], colorDanger[2])
|
|
pdf.CellFormat(colWidths[4], 6, "Active", "1", 0, "C", fill, 0, "")
|
|
}
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
|
|
pdf.Ln(-1)
|
|
fill = !fill
|
|
}
|
|
|
|
// Say once what each "Moved" row means, in the alert engine's words.
|
|
var notes []string
|
|
seenNotes := make(map[string]struct{})
|
|
for _, alert := range data.Alerts {
|
|
summary := alert.ResolutionSummary()
|
|
if summary == "" {
|
|
continue
|
|
}
|
|
note := closedWithoutRecoveryLabel(alert) + ": " + summary
|
|
if _, ok := seenNotes[note]; ok {
|
|
continue
|
|
}
|
|
seenNotes[note] = struct{}{}
|
|
notes = append(notes, note)
|
|
}
|
|
if len(notes) > 0 {
|
|
pdf.Ln(2)
|
|
pdf.SetFont("Arial", "I", 8)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
for _, note := range notes {
|
|
pdf.MultiCell(0, 4, note, "", "L", false)
|
|
}
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
}
|
|
|
|
pdf.Ln(10)
|
|
}
|
|
|
|
// closedWithoutRecoveryLabel is the alerts table's Resolved cell for a close
|
|
// that was not a recovery.
|
|
func closedWithoutRecoveryLabel(alert AlertInfo) string {
|
|
if alert.Resolution != nil && alert.Resolution.Reason == AlertResolutionMovedToAgent {
|
|
return "Moved"
|
|
}
|
|
return "Closed"
|
|
}
|
|
|
|
// unresolvedAlertLine is one alert in an "Active Alerts" list, cut to max
|
|
// characters. An alert that closed without recovering keeps its outcome,
|
|
// such as "(moved to pve1 (Host Agent))", and gives up message text instead.
|
|
func unresolvedAlertLine(alert AlertInfo, max int) string {
|
|
suffix := ""
|
|
if outcome := alert.ResolutionOutcome(); outcome != "" {
|
|
suffix = " (" + outcome + ")"
|
|
}
|
|
room := max - len(suffix)
|
|
if room < 10 {
|
|
room = 10
|
|
}
|
|
msg := alert.Message
|
|
if len(msg) > room {
|
|
msg = msg[:room-3] + "..."
|
|
}
|
|
return msg + suffix
|
|
}
|
|
|
|
// writeStorageSection writes storage pools table
|
|
func (g *PDFGenerator) writeStorageSection(pdf *fpdf.Fpdf, data *ReportData) {
|
|
if len(data.Storage) == 0 {
|
|
return
|
|
}
|
|
|
|
// Check if we need a new page
|
|
if pdf.GetY() > 200 {
|
|
pdf.AddPage()
|
|
g.addPageHeader(pdf, data, "Storage Pools")
|
|
}
|
|
|
|
pdf.SetFont("Arial", "B", 12)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 8, "Storage Pools", "", 1, "L", false, 0, "")
|
|
pdf.Ln(2)
|
|
|
|
// Table header
|
|
colWidths := []float64{35, 25, 20, 30, 30, 30}
|
|
headers := []string{"Name", "Type", "Status", "Used", "Total", "Usage"}
|
|
|
|
pdf.SetFillColor(colorTableHeader[0], colorTableHeader[1], colorTableHeader[2])
|
|
pdf.SetTextColor(255, 255, 255)
|
|
pdf.SetFont("Arial", "B", 8)
|
|
|
|
for i, header := range headers {
|
|
pdf.CellFormat(colWidths[i], 7, header, "1", 0, "C", true, 0, "")
|
|
}
|
|
pdf.Ln(-1)
|
|
|
|
// Table rows
|
|
pdf.SetFont("Arial", "", 8)
|
|
fill := false
|
|
|
|
for _, storage := range data.Storage {
|
|
if fill {
|
|
pdf.SetFillColor(colorTableAlt[0], colorTableAlt[1], colorTableAlt[2])
|
|
} else {
|
|
pdf.SetFillColor(255, 255, 255)
|
|
}
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
|
|
pdf.CellFormat(colWidths[0], 6, storage.Name, "1", 0, "L", fill, 0, "")
|
|
pdf.CellFormat(colWidths[1], 6, storage.Type, "1", 0, "C", fill, 0, "")
|
|
|
|
// Status with color
|
|
if storage.Status == "active" || storage.Status == "available" {
|
|
pdf.SetTextColor(colorAccent[0], colorAccent[1], colorAccent[2])
|
|
} else {
|
|
pdf.SetTextColor(colorWarning[0], colorWarning[1], colorWarning[2])
|
|
}
|
|
pdf.CellFormat(colWidths[2], 6, storage.Status, "1", 0, "C", fill, 0, "")
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
|
|
pdf.CellFormat(colWidths[3], 6, formatBytes(float64(storage.Used)), "1", 0, "R", fill, 0, "")
|
|
pdf.CellFormat(colWidths[4], 6, formatBytes(float64(storage.Total)), "1", 0, "R", fill, 0, "")
|
|
|
|
// Usage with color coding
|
|
usageStr := fmt.Sprintf("%.1f%%", storage.UsagePerc)
|
|
if storage.UsagePerc >= 90 {
|
|
pdf.SetTextColor(colorDanger[0], colorDanger[1], colorDanger[2])
|
|
} else if storage.UsagePerc >= 80 {
|
|
pdf.SetTextColor(colorWarning[0], colorWarning[1], colorWarning[2])
|
|
} else {
|
|
pdf.SetTextColor(colorAccent[0], colorAccent[1], colorAccent[2])
|
|
}
|
|
pdf.CellFormat(colWidths[5], 6, usageStr, "1", 0, "C", fill, 0, "")
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
|
|
pdf.Ln(-1)
|
|
fill = !fill
|
|
}
|
|
|
|
pdf.Ln(10)
|
|
}
|
|
|
|
// writeDisksSection writes physical disks table
|
|
func (g *PDFGenerator) writeDisksSection(pdf *fpdf.Fpdf, data *ReportData) {
|
|
if len(data.Disks) == 0 {
|
|
return
|
|
}
|
|
|
|
// Check if we need a new page
|
|
if pdf.GetY() > 200 {
|
|
pdf.AddPage()
|
|
g.addPageHeader(pdf, data, "Physical Disks")
|
|
}
|
|
|
|
pdf.SetFont("Arial", "B", 12)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 8, "Physical Disks", "", 1, "L", false, 0, "")
|
|
pdf.Ln(2)
|
|
|
|
// Table header
|
|
colWidths := []float64{25, 50, 25, 25, 20, 25}
|
|
headers := []string{"Device", "Model", "Size", "Health", "Temp", "Life"}
|
|
|
|
pdf.SetFillColor(colorTableHeader[0], colorTableHeader[1], colorTableHeader[2])
|
|
pdf.SetTextColor(255, 255, 255)
|
|
pdf.SetFont("Arial", "B", 8)
|
|
|
|
for i, header := range headers {
|
|
pdf.CellFormat(colWidths[i], 7, header, "1", 0, "C", true, 0, "")
|
|
}
|
|
pdf.Ln(-1)
|
|
|
|
// Table rows
|
|
pdf.SetFont("Arial", "", 8)
|
|
fill := false
|
|
|
|
for _, disk := range data.Disks {
|
|
if fill {
|
|
pdf.SetFillColor(colorTableAlt[0], colorTableAlt[1], colorTableAlt[2])
|
|
} else {
|
|
pdf.SetFillColor(255, 255, 255)
|
|
}
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
|
|
pdf.CellFormat(colWidths[0], 6, disk.Device, "1", 0, "L", fill, 0, "")
|
|
|
|
// Model (truncate if too long)
|
|
model := disk.Model
|
|
if len(model) > 30 {
|
|
model = model[:27] + "..."
|
|
}
|
|
pdf.CellFormat(colWidths[1], 6, model, "1", 0, "L", fill, 0, "")
|
|
|
|
pdf.CellFormat(colWidths[2], 6, formatBytes(float64(disk.Size)), "1", 0, "R", fill, 0, "")
|
|
|
|
// Health with color
|
|
if disk.Health == "PASSED" {
|
|
pdf.SetTextColor(colorAccent[0], colorAccent[1], colorAccent[2])
|
|
} else if disk.Health == "FAILED" {
|
|
pdf.SetTextColor(colorDanger[0], colorDanger[1], colorDanger[2])
|
|
} else {
|
|
pdf.SetTextColor(colorWarning[0], colorWarning[1], colorWarning[2])
|
|
}
|
|
pdf.CellFormat(colWidths[3], 6, disk.Health, "1", 0, "C", fill, 0, "")
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
|
|
// Temperature
|
|
tempStr := "-"
|
|
if disk.Temperature > 0 {
|
|
tempStr = fmt.Sprintf("%dC", disk.Temperature)
|
|
switch diskTemperatureSeverity(disk) {
|
|
case "critical":
|
|
pdf.SetTextColor(colorDanger[0], colorDanger[1], colorDanger[2])
|
|
case "warning":
|
|
pdf.SetTextColor(colorWarning[0], colorWarning[1], colorWarning[2])
|
|
}
|
|
}
|
|
pdf.CellFormat(colWidths[4], 6, tempStr, "1", 0, "C", fill, 0, "")
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
|
|
// SSD Life remaining (100% = healthy, 0% = end of life)
|
|
lifeStr := "-"
|
|
if disk.WearLevel > 0 && disk.WearLevel <= 100 {
|
|
lifeStr = fmt.Sprintf("%d%%", disk.WearLevel)
|
|
if disk.WearLevel <= 10 {
|
|
pdf.SetTextColor(colorDanger[0], colorDanger[1], colorDanger[2])
|
|
} else if disk.WearLevel <= 30 {
|
|
pdf.SetTextColor(colorWarning[0], colorWarning[1], colorWarning[2])
|
|
} else {
|
|
pdf.SetTextColor(colorAccent[0], colorAccent[1], colorAccent[2])
|
|
}
|
|
}
|
|
pdf.CellFormat(colWidths[5], 6, lifeStr, "1", 0, "C", fill, 0, "")
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
|
|
pdf.Ln(-1)
|
|
fill = !fill
|
|
}
|
|
|
|
pdf.Ln(10)
|
|
}
|
|
|
|
// writeBackupsSection writes backups table
|
|
func (g *PDFGenerator) writeBackupsSection(pdf *fpdf.Fpdf, data *ReportData) {
|
|
if len(data.Backups) == 0 {
|
|
return
|
|
}
|
|
|
|
// Check if we need a new page
|
|
if pdf.GetY() > 200 {
|
|
pdf.AddPage()
|
|
g.addPageHeader(pdf, data, "Backups")
|
|
}
|
|
|
|
pdf.SetFont("Arial", "B", 12)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 8, "Backups", "", 1, "L", false, 0, "")
|
|
pdf.Ln(2)
|
|
|
|
// Table header
|
|
colWidths := []float64{25, 35, 45, 35, 30}
|
|
headers := []string{"Type", "Storage", "Date", "Size", "Protected"}
|
|
|
|
pdf.SetFillColor(colorTableHeader[0], colorTableHeader[1], colorTableHeader[2])
|
|
pdf.SetTextColor(255, 255, 255)
|
|
pdf.SetFont("Arial", "B", 8)
|
|
|
|
for i, header := range headers {
|
|
pdf.CellFormat(colWidths[i], 7, header, "1", 0, "C", true, 0, "")
|
|
}
|
|
pdf.Ln(-1)
|
|
|
|
// Table rows
|
|
pdf.SetFont("Arial", "", 8)
|
|
fill := false
|
|
|
|
for _, backup := range data.Backups {
|
|
if fill {
|
|
pdf.SetFillColor(colorTableAlt[0], colorTableAlt[1], colorTableAlt[2])
|
|
} else {
|
|
pdf.SetFillColor(255, 255, 255)
|
|
}
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
|
|
pdf.CellFormat(colWidths[0], 6, backup.Type, "1", 0, "C", fill, 0, "")
|
|
pdf.CellFormat(colWidths[1], 6, backup.Storage, "1", 0, "L", fill, 0, "")
|
|
pdf.CellFormat(colWidths[2], 6, backup.Timestamp.Format("2006-01-02 15:04"), "1", 0, "C", fill, 0, "")
|
|
pdf.CellFormat(colWidths[3], 6, formatBytes(float64(backup.Size)), "1", 0, "R", fill, 0, "")
|
|
|
|
// Protected
|
|
if backup.Protected {
|
|
pdf.SetTextColor(colorAccent[0], colorAccent[1], colorAccent[2])
|
|
pdf.CellFormat(colWidths[4], 6, "Yes", "1", 0, "C", fill, 0, "")
|
|
} else {
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.CellFormat(colWidths[4], 6, "No", "1", 0, "C", fill, 0, "")
|
|
}
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
|
|
pdf.Ln(-1)
|
|
fill = !fill
|
|
}
|
|
|
|
pdf.Ln(10)
|
|
}
|
|
|
|
// formatUptime converts seconds to human-readable uptime
|
|
func formatUptime(seconds int64) string {
|
|
days := seconds / 86400
|
|
hours := (seconds % 86400) / 3600
|
|
mins := (seconds % 3600) / 60
|
|
|
|
if days > 0 {
|
|
return fmt.Sprintf("%dd %dh %dm", days, hours, mins)
|
|
}
|
|
if hours > 0 {
|
|
return fmt.Sprintf("%dh %dm", hours, mins)
|
|
}
|
|
return fmt.Sprintf("%dm", mins)
|
|
}
|
|
|
|
// addPageNumbers adds page numbers to all pages except the first (cover).
|
|
func (g *PDFGenerator) addPageNumbers(pdf *fpdf.Fpdf) {
|
|
// Disable auto page break while adding footers to prevent creating new pages
|
|
pdf.SetAutoPageBreak(false, 0)
|
|
|
|
totalPages := pdf.PageCount()
|
|
|
|
// Iterate through pages 2 to totalPages (skip cover page)
|
|
for i := 2; i <= totalPages; i++ {
|
|
pdf.SetPage(i)
|
|
pageWidth, pageHeight := pdf.GetPageSize()
|
|
|
|
pdf.SetY(pageHeight - 15)
|
|
pdf.SetFont("Arial", "", 8)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
|
|
pageNum := i - 1
|
|
totalContent := totalPages - 1
|
|
pdf.CellFormat(0, 5, fmt.Sprintf("Page %d of %d", pageNum, totalContent), "", 0, "C", false, 0, "")
|
|
|
|
// Bottom line
|
|
pdf.SetDrawColor(colorGridLine[0], colorGridLine[1], colorGridLine[2])
|
|
pdf.SetLineWidth(0.3)
|
|
pdf.Line(20, pageHeight-20, pageWidth-20, pageHeight-20)
|
|
}
|
|
}
|
|
|
|
// GenerateMulti creates a multi-resource PDF report from the provided data.
|
|
func (g *PDFGenerator) GenerateMulti(data *MultiReportData) ([]byte, error) {
|
|
pdf := fpdf.New("P", "mm", "A4", "")
|
|
pdf.SetMargins(20, 20, 20)
|
|
pdf.SetAutoPageBreak(true, 25)
|
|
|
|
// Translate UTF-8 strings to the cp1252 space the core fonts expect;
|
|
// see the matching call in Generate and pdf_codepage.go.
|
|
translateMultiReportStrings(data, pdf.UnicodeTranslatorFromDescriptor(""))
|
|
|
|
// Page 1: Cover page
|
|
g.writeMultiCoverPage(pdf, data)
|
|
|
|
// Page 2: Fleet summary
|
|
pdf.AddPage()
|
|
g.addMultiPageHeader(pdf, data, "Fleet Summary")
|
|
g.writeFleetSummary(pdf, data)
|
|
|
|
// Pages 3+: condensed per-resource blocks, flowed several to a page.
|
|
// One near-empty A4 page per resource read as padding in client-facing
|
|
// reports, so a new page starts only when the next block will not fit.
|
|
_, pageHeight := pdf.GetPageSize()
|
|
bottomLimit := pageHeight - 30
|
|
pageOpen := false
|
|
fleetAlerts := data.fleetAlerts()
|
|
for _, rd := range data.Resources {
|
|
unresolved := unresolvedAlertsAmong(rd.Alerts, fleetAlerts)
|
|
startNewPage := !pageOpen || pdf.GetY()+condensedResourceBlockHeight(rd, unresolved) > bottomLimit
|
|
if startNewPage {
|
|
pdf.AddPage()
|
|
g.addMultiPageHeader(pdf, data, "Resource Details")
|
|
pageOpen = true
|
|
} else {
|
|
pdf.Ln(3)
|
|
pdf.SetDrawColor(colorGridLine[0], colorGridLine[1], colorGridLine[2])
|
|
pdf.Line(20, pdf.GetY(), 190, pdf.GetY())
|
|
pdf.Ln(5)
|
|
}
|
|
g.writeCondensedResourcePage(pdf, rd, unresolved)
|
|
}
|
|
|
|
// Add page numbers to all pages except cover
|
|
g.addMultiPageNumbers(pdf)
|
|
|
|
// Output to buffer
|
|
var buf bytes.Buffer
|
|
if err := pdf.Output(&buf); err != nil {
|
|
return nil, fmt.Errorf("PDF output error: %w", err)
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// writeMultiCoverPage creates a cover page for multi-resource reports.
|
|
func (g *PDFGenerator) writeMultiCoverPage(pdf *fpdf.Fpdf, data *MultiReportData) {
|
|
pdf.AddPage()
|
|
|
|
pageWidth, pageHeight := pdf.GetPageSize()
|
|
|
|
// Top accent bar
|
|
pdf.SetFillColor(colorPrimary[0], colorPrimary[1], colorPrimary[2])
|
|
pdf.Rect(0, 0, pageWidth, 8, "F")
|
|
|
|
// Branding area
|
|
if g.drawBrandLogo(pdf, data.Brand, pageWidth/2-20, 34, 40, 18) {
|
|
pdf.SetY(58)
|
|
} else {
|
|
pdf.SetY(50)
|
|
}
|
|
pdf.SetTextColor(colorPrimary[0], colorPrimary[1], colorPrimary[2])
|
|
writeCoverLine(pdf, reportCoverBrandName(data.Brand), 15, 32, 20, "B")
|
|
|
|
pdf.SetFont("Arial", "", 12)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.CellFormat(0, 8, "Infrastructure Monitoring", "", 1, "C", false, 0, "")
|
|
|
|
// Main title
|
|
pdf.SetY(100)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
writeCoverLine(pdf, data.Title, 12, 28, 16, "B")
|
|
|
|
// Subtitle with counts. A wrapped title can run past the fixed band, so
|
|
// keep the subtitle below whatever the title actually used.
|
|
subtitleY := 120.0
|
|
if y := pdf.GetY() + 4; y > subtitleY {
|
|
subtitleY = y
|
|
}
|
|
pdf.SetY(subtitleY)
|
|
pdf.SetFont("Arial", "", 14)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
|
|
// Calculate duration
|
|
duration := data.End.Sub(data.Start)
|
|
durationStr := formatDuration(duration)
|
|
subtitle := fmt.Sprintf("%d Resources | %s", len(data.Resources), durationStr)
|
|
pdf.CellFormat(0, 8, subtitle, "", 1, "C", false, 0, "")
|
|
|
|
// Scope box, kept below the flexible title/subtitle block
|
|
scopeY := 140.0
|
|
if y := pdf.GetY() + 8; y > scopeY {
|
|
scopeY = y
|
|
}
|
|
pdf.SetY(scopeY)
|
|
boxX := 40.0
|
|
boxWidth := pageWidth - 80
|
|
boxHeight := 40.0
|
|
|
|
pdf.SetFillColor(colorBackground[0], colorBackground[1], colorBackground[2])
|
|
pdf.SetDrawColor(colorGridLine[0], colorGridLine[1], colorGridLine[2])
|
|
pdf.RoundedRect(boxX, pdf.GetY(), boxWidth, boxHeight, 3, "1234", "FD")
|
|
|
|
// Count by type
|
|
nodeCount, vmCount, ctCount := 0, 0, 0
|
|
for _, rd := range data.Resources {
|
|
switch CanonicalResourceType(rd.ResourceType) {
|
|
case "node":
|
|
nodeCount++
|
|
case "vm":
|
|
vmCount++
|
|
case "system-container", "oci-container", "app-container":
|
|
ctCount++
|
|
}
|
|
}
|
|
|
|
pdf.SetY(pdf.GetY() + 10)
|
|
pdf.SetFont("Arial", "B", 11)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.CellFormat(0, 7, "SCOPE", "", 1, "C", false, 0, "")
|
|
|
|
pdf.SetFont("Arial", "", 12)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
|
|
var scopeParts []string
|
|
if nodeCount > 0 {
|
|
word := "Nodes"
|
|
if nodeCount == 1 {
|
|
word = "Node"
|
|
}
|
|
scopeParts = append(scopeParts, fmt.Sprintf("%d %s", nodeCount, word))
|
|
}
|
|
if vmCount > 0 {
|
|
word := "VMs"
|
|
if vmCount == 1 {
|
|
word = "VM"
|
|
}
|
|
scopeParts = append(scopeParts, fmt.Sprintf("%d %s", vmCount, word))
|
|
}
|
|
if ctCount > 0 {
|
|
word := "Containers"
|
|
if ctCount == 1 {
|
|
word = "Container"
|
|
}
|
|
scopeParts = append(scopeParts, fmt.Sprintf("%d %s", ctCount, word))
|
|
}
|
|
|
|
scopeStr := ""
|
|
for i, part := range scopeParts {
|
|
if i > 0 {
|
|
scopeStr += ", "
|
|
}
|
|
scopeStr += part
|
|
}
|
|
pdf.CellFormat(0, 8, scopeStr, "", 1, "C", false, 0, "")
|
|
|
|
// Time period
|
|
pdf.SetY(200)
|
|
pdf.SetFont("Arial", "B", 11)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.CellFormat(0, 7, "REPORTING PERIOD", "", 1, "C", false, 0, "")
|
|
|
|
pdf.SetFont("Arial", "", 12)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
periodStr := fmt.Sprintf("%s - %s",
|
|
data.Start.Format("January 2, 2006 15:04"),
|
|
data.End.Format("January 2, 2006 15:04"))
|
|
pdf.CellFormat(0, 8, periodStr, "", 1, "C", false, 0, "")
|
|
|
|
pdf.SetFont("Arial", "", 10)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.CellFormat(0, 6, fmt.Sprintf("(%s)", durationStr), "", 1, "C", false, 0, "")
|
|
|
|
// Bottom section
|
|
pdf.SetY(pageHeight - 50)
|
|
pdf.SetFont("Arial", "", 10)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.CellFormat(0, 6, fmt.Sprintf("Generated: %s", data.GeneratedAt.Format("January 2, 2006 at 15:04 MST")), "", 1, "C", false, 0, "")
|
|
pdf.CellFormat(0, 6, fmt.Sprintf("Total Data Points: %d", data.TotalPoints), "", 1, "C", false, 0, "")
|
|
|
|
// Bottom accent bar
|
|
pdf.SetFillColor(colorPrimary[0], colorPrimary[1], colorPrimary[2])
|
|
pdf.Rect(0, pageHeight-8, pageWidth, 8, "F")
|
|
}
|
|
|
|
// addMultiPageHeader adds a consistent header to multi-report content pages.
|
|
func (g *PDFGenerator) addMultiPageHeader(pdf *fpdf.Fpdf, data *MultiReportData, section string) {
|
|
pageWidth, _ := pdf.GetPageSize()
|
|
|
|
// Top line
|
|
pdf.SetDrawColor(colorPrimary[0], colorPrimary[1], colorPrimary[2])
|
|
pdf.SetLineWidth(0.5)
|
|
pdf.Line(20, 15, pageWidth-20, 15)
|
|
|
|
// Header text
|
|
textX := 20.0
|
|
if g.drawBrandLogo(pdf, data.Brand, 20, 17, 7, 7) {
|
|
textX = 30
|
|
}
|
|
pdf.SetXY(textX, 18)
|
|
pdf.SetFont("Arial", "B", 9)
|
|
pdf.SetTextColor(colorPrimary[0], colorPrimary[1], colorPrimary[2])
|
|
pdf.CellFormat(pageWidth-textX-20, 5, reportHeaderBrandName(data.Brand, "PULSE")+" FLEET REPORT", "", 0, "L", false, 0, "")
|
|
|
|
pdf.SetFont("Arial", "", 9)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.SetXY(20, 18)
|
|
pdf.CellFormat(pageWidth-40, 5, fmt.Sprintf("%d Resources", len(data.Resources)), "", 1, "R", false, 0, "")
|
|
|
|
// Section title
|
|
pdf.SetY(30)
|
|
pdf.SetFont("Arial", "B", 18)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 10, section, "", 1, "L", false, 0, "")
|
|
|
|
pdf.Ln(5)
|
|
}
|
|
|
|
// writeFleetSummary writes the fleet summary table and observations.
|
|
func (g *PDFGenerator) writeFleetSummary(pdf *fpdf.Fpdf, data *MultiReportData) {
|
|
pageWidth, _ := pdf.GetPageSize()
|
|
|
|
// Determine aggregate health
|
|
healthStatus := "HEALTHY"
|
|
healthColor := colorAccent
|
|
healthMessage := "All systems operating normally"
|
|
|
|
totalActive := 0
|
|
totalCritical := 0
|
|
totalWarning := 0
|
|
fleetAlerts := data.fleetAlerts()
|
|
for _, rd := range data.Resources {
|
|
for _, alert := range unresolvedAlertsAmong(rd.Alerts, fleetAlerts) {
|
|
totalActive++
|
|
if alert.Level == "critical" {
|
|
totalCritical++
|
|
} else {
|
|
totalWarning++
|
|
}
|
|
}
|
|
}
|
|
|
|
fleetTotalPoints := 0
|
|
for _, rd := range data.Resources {
|
|
fleetTotalPoints += rd.TotalPoints
|
|
}
|
|
|
|
if totalCritical > 0 {
|
|
healthStatus = "CRITICAL"
|
|
healthColor = colorDanger
|
|
healthMessage = fmt.Sprintf("%d critical issues across fleet", totalCritical)
|
|
} else if totalWarning > 0 {
|
|
healthStatus = "WARNING"
|
|
healthColor = colorWarning
|
|
healthMessage = fmt.Sprintf("%d warnings across fleet", totalWarning)
|
|
} else if fleetTotalPoints == 0 {
|
|
// No metrics arrived for any resource in the window. Reporting
|
|
// HEALTHY would green-light an empty report and tell the reader
|
|
// their fleet is fine when Pulse has nothing to evaluate — the
|
|
// worst failure mode for a report whose job is reassurance.
|
|
// Mirror the single-resource executive summary's muted NO DATA
|
|
// card instead.
|
|
healthStatus = "NO DATA"
|
|
healthColor = colorTextMuted
|
|
healthMessage = "No metrics reported during the selected window"
|
|
}
|
|
|
|
// Health status card
|
|
cardX := 20.0
|
|
cardWidth := pageWidth - 40
|
|
cardHeight := 30.0
|
|
|
|
pdf.SetFillColor(healthColor[0], healthColor[1], healthColor[2])
|
|
pdf.RoundedRect(cardX, pdf.GetY(), cardWidth, cardHeight, 3, "1234", "F")
|
|
|
|
pdf.SetXY(cardX, pdf.GetY()+6)
|
|
pdf.SetFont("Arial", "B", 20)
|
|
pdf.SetTextColor(255, 255, 255)
|
|
pdf.CellFormat(cardWidth, 10, healthStatus, "", 1, "C", false, 0, "")
|
|
pdf.SetFont("Arial", "", 10)
|
|
pdf.CellFormat(cardWidth, 7, healthMessage, "", 1, "C", false, 0, "")
|
|
|
|
pdf.SetY(pdf.GetY() + 12)
|
|
|
|
// Summary table
|
|
pdf.SetFont("Arial", "B", 11)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 8, "Resource Summary", "", 1, "L", false, 0, "")
|
|
pdf.Ln(2)
|
|
|
|
// Table header
|
|
colWidths := []float64{37, 22, 16, 19, 19, 19, 19, 19}
|
|
headers := []string{"Resource", "Type", "Status", "Uptime", "Avg CPU", "Avg Mem", "Avg Disk", "Alerts"}
|
|
|
|
pdf.SetFillColor(colorTableHeader[0], colorTableHeader[1], colorTableHeader[2])
|
|
pdf.SetTextColor(255, 255, 255)
|
|
pdf.SetFont("Arial", "B", 8)
|
|
|
|
for i, header := range headers {
|
|
pdf.CellFormat(colWidths[i], 7, header, "1", 0, "C", true, 0, "")
|
|
}
|
|
pdf.Ln(-1)
|
|
|
|
// Table rows
|
|
pdf.SetFont("Arial", "", 8)
|
|
fill := false
|
|
|
|
// Track highest values for observations
|
|
var highestCPUName string
|
|
var highestCPUVal float64
|
|
var mostAlertsName string
|
|
var mostAlertsCount int
|
|
|
|
for _, rd := range data.Resources {
|
|
if fill {
|
|
pdf.SetFillColor(colorTableAlt[0], colorTableAlt[1], colorTableAlt[2])
|
|
} else {
|
|
pdf.SetFillColor(255, 255, 255)
|
|
}
|
|
|
|
// Resource name
|
|
resourceName := rd.ResourceID
|
|
if rd.Resource != nil && rd.Resource.Name != "" {
|
|
resourceName = rd.Resource.Name
|
|
}
|
|
if len(resourceName) > 25 {
|
|
resourceName = resourceName[:22] + "..."
|
|
}
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(colWidths[0], 6, resourceName, "1", 0, "L", fill, 0, "")
|
|
|
|
// Type
|
|
pdf.CellFormat(colWidths[1], 6, GetResourceTypeDisplayName(rd.ResourceType), "1", 0, "C", fill, 0, "")
|
|
|
|
// Status
|
|
status := "N/A"
|
|
if rd.Resource != nil {
|
|
status = rd.Resource.Status
|
|
}
|
|
if status == "online" || status == "running" {
|
|
pdf.SetTextColor(colorAccent[0], colorAccent[1], colorAccent[2])
|
|
} else if status == "stopped" || status == "offline" {
|
|
pdf.SetTextColor(colorDanger[0], colorDanger[1], colorDanger[2])
|
|
} else {
|
|
pdf.SetTextColor(colorWarning[0], colorWarning[1], colorWarning[2])
|
|
}
|
|
pdf.CellFormat(colWidths[2], 6, status, "1", 0, "C", fill, 0, "")
|
|
|
|
// Uptime over the window. A dash means the resource was never
|
|
// observed (or no timeline was available) - distinct from 100%.
|
|
uptimeLabel := "-"
|
|
uptimeColor := colorTextMuted
|
|
if rd.Availability.Observed() {
|
|
uptimeLabel = availabilityUptimeLabel(rd.Availability.UptimePercent)
|
|
uptimeColor = colorAccent
|
|
if rd.Availability.TotalDowntime > 0 {
|
|
uptimeColor = colorWarning
|
|
}
|
|
if rd.Availability.UptimePercent < 99 {
|
|
uptimeColor = colorDanger
|
|
}
|
|
}
|
|
pdf.SetTextColor(uptimeColor[0], uptimeColor[1], uptimeColor[2])
|
|
pdf.CellFormat(colWidths[3], 6, uptimeLabel, "1", 0, "C", fill, 0, "")
|
|
|
|
// Avg CPU
|
|
var avgCPU float64
|
|
if stats, ok := rd.Summary.ByMetric["cpu"]; ok {
|
|
avgCPU = stats.Avg
|
|
}
|
|
pdf.SetTextColor(getStatColor(avgCPU)[0], getStatColor(avgCPU)[1], getStatColor(avgCPU)[2])
|
|
pdf.CellFormat(colWidths[4], 6, fmt.Sprintf("%.1f%%", avgCPU), "1", 0, "C", fill, 0, "")
|
|
|
|
if avgCPU > highestCPUVal {
|
|
highestCPUVal = avgCPU
|
|
if rd.Resource != nil && rd.Resource.Name != "" {
|
|
highestCPUName = rd.Resource.Name
|
|
} else {
|
|
highestCPUName = rd.ResourceID
|
|
}
|
|
}
|
|
|
|
// Avg Memory
|
|
var avgMem float64
|
|
if stats, ok := rd.Summary.ByMetric["memory"]; ok {
|
|
avgMem = stats.Avg
|
|
}
|
|
pdf.SetTextColor(getStatColor(avgMem)[0], getStatColor(avgMem)[1], getStatColor(avgMem)[2])
|
|
pdf.CellFormat(colWidths[5], 6, fmt.Sprintf("%.1f%%", avgMem), "1", 0, "C", fill, 0, "")
|
|
|
|
// Avg Disk
|
|
var avgDisk float64
|
|
if stats, ok := rd.Summary.ByMetric["disk"]; ok {
|
|
avgDisk = stats.Avg
|
|
} else if stats, ok := rd.Summary.ByMetric["usage"]; ok {
|
|
avgDisk = stats.Avg
|
|
}
|
|
pdf.SetTextColor(getStatColor(avgDisk)[0], getStatColor(avgDisk)[1], getStatColor(avgDisk)[2])
|
|
pdf.CellFormat(colWidths[6], 6, fmt.Sprintf("%.1f%%", avgDisk), "1", 0, "C", fill, 0, "")
|
|
|
|
// Alerts count
|
|
alertCount := len(unresolvedAlertsAmong(rd.Alerts, fleetAlerts))
|
|
pdf.SetTextColor(getAlertCountColor(alertCount)[0], getAlertCountColor(alertCount)[1], getAlertCountColor(alertCount)[2])
|
|
pdf.CellFormat(colWidths[7], 6, fmt.Sprintf("%d", alertCount), "1", 0, "C", fill, 0, "")
|
|
|
|
if alertCount > mostAlertsCount {
|
|
mostAlertsCount = alertCount
|
|
if rd.Resource != nil && rd.Resource.Name != "" {
|
|
mostAlertsName = rd.Resource.Name
|
|
} else {
|
|
mostAlertsName = rd.ResourceID
|
|
}
|
|
}
|
|
|
|
pdf.Ln(-1)
|
|
fill = !fill
|
|
}
|
|
|
|
pdf.Ln(8)
|
|
|
|
// Fleet narrative section. When data.FleetNarrative is set (AI or
|
|
// heuristic) it owns the prose, outlier list, patterns, and
|
|
// recommendations rendered here. The legacy highest-CPU /
|
|
// most-alerts bullets are rendered as a fallback when no narrative
|
|
// has been attached, preserving the prior multi-report behaviour
|
|
// for callers that bypass the engine wiring.
|
|
if data.FleetNarrative != nil {
|
|
writeFleetNarrativeSection(pdf, data.FleetNarrative)
|
|
return
|
|
}
|
|
|
|
pdf.SetFont("Arial", "B", 11)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 8, "Fleet Observations", "", 1, "L", false, 0, "")
|
|
pdf.Ln(2)
|
|
|
|
pdf.SetFont("Arial", "", 10)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
|
|
if highestCPUName != "" {
|
|
pdf.SetFillColor(colorSecondary[0], colorSecondary[1], colorSecondary[2])
|
|
pdf.Circle(pdf.GetX()+3, pdf.GetY()+3, 2, "F")
|
|
pdf.SetX(pdf.GetX() + 8)
|
|
pdf.CellFormat(0, 6, fmt.Sprintf("Highest CPU: %s (avg %.1f%%)", highestCPUName, highestCPUVal), "", 1, "L", false, 0, "")
|
|
pdf.Ln(1)
|
|
}
|
|
|
|
if mostAlertsCount > 0 && mostAlertsName != "" {
|
|
pdf.SetFillColor(colorDanger[0], colorDanger[1], colorDanger[2])
|
|
pdf.Circle(pdf.GetX()+3, pdf.GetY()+3, 2, "F")
|
|
pdf.SetX(pdf.GetX() + 8)
|
|
pdf.CellFormat(0, 6, fmt.Sprintf("Most alerts: %s (%d active)", mostAlertsName, mostAlertsCount), "", 1, "L", false, 0, "")
|
|
pdf.Ln(1)
|
|
}
|
|
|
|
if totalActive == 0 {
|
|
pdf.SetFillColor(colorAccent[0], colorAccent[1], colorAccent[2])
|
|
pdf.Circle(pdf.GetX()+3, pdf.GetY()+3, 2, "F")
|
|
pdf.SetX(pdf.GetX() + 8)
|
|
pdf.CellFormat(0, 6, "No active alerts across the fleet", "", 1, "L", false, 0, "")
|
|
}
|
|
}
|
|
|
|
// writeFleetNarrativeSection renders the fleet-level narrative produced
|
|
// by either the heuristic or AI fleet narrator. Layout mirrors the
|
|
// single-resource executive summary but is scoped to fleet semantics:
|
|
// outliers point at named resources, patterns describe cross-cutting
|
|
// trends, and the period-comparison and provenance footers keep the
|
|
// AI/heuristic distinction visible.
|
|
func writeFleetNarrativeSection(pdf *fpdf.Fpdf, fn *FleetNarrative) {
|
|
if fn == nil {
|
|
return
|
|
}
|
|
pageWidth, _ := pdf.GetPageSize()
|
|
bodyWidth := pageWidth - 40
|
|
|
|
if summary := strings.TrimSpace(fn.ExecutiveSummary); summary != "" {
|
|
pdf.SetFont("Arial", "", 10)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.MultiCell(bodyWidth, 5, summary, "", "L", false)
|
|
pdf.Ln(3)
|
|
}
|
|
|
|
if len(fn.Outliers) > 0 {
|
|
pdf.SetFont("Arial", "B", 11)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 8, "Resources to investigate", "", 1, "L", false, 0, "")
|
|
pdf.Ln(2)
|
|
pdf.SetFont("Arial", "", 10)
|
|
for _, o := range fn.Outliers {
|
|
color := bulletColor(o.Severity)
|
|
pdf.SetFillColor(color[0], color[1], color[2])
|
|
pdf.Circle(pdf.GetX()+3, pdf.GetY()+3, 2, "F")
|
|
pdf.SetX(pdf.GetX() + 8)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
label := o.ResourceName
|
|
if label == "" {
|
|
label = o.ResourceID
|
|
}
|
|
pdf.CellFormat(0, 6, fmt.Sprintf("%s - %s", label, o.Reason), "", 1, "L", false, 0, "")
|
|
pdf.Ln(1)
|
|
}
|
|
}
|
|
|
|
if len(fn.Patterns) > 0 {
|
|
pdf.Ln(3)
|
|
pdf.SetFont("Arial", "B", 11)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 8, "Cross-cutting patterns", "", 1, "L", false, 0, "")
|
|
pdf.Ln(2)
|
|
pdf.SetFont("Arial", "", 10)
|
|
for _, b := range fn.Patterns {
|
|
color := bulletColor(b.Severity)
|
|
pdf.SetFillColor(color[0], color[1], color[2])
|
|
pdf.Circle(pdf.GetX()+3, pdf.GetY()+3, 2, "F")
|
|
pdf.SetX(pdf.GetX() + 8)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 6, b.Text, "", 1, "L", false, 0, "")
|
|
pdf.Ln(1)
|
|
}
|
|
}
|
|
|
|
if len(fn.Recommendations) > 0 {
|
|
pdf.Ln(3)
|
|
pdf.SetFont("Arial", "B", 11)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 8, "Recommended Actions", "", 1, "L", false, 0, "")
|
|
pdf.Ln(2)
|
|
pdf.SetFont("Arial", "", 9)
|
|
for i, rec := range fn.Recommendations {
|
|
if i >= 5 {
|
|
break
|
|
}
|
|
pdf.SetTextColor(colorSecondary[0], colorSecondary[1], colorSecondary[2])
|
|
pdf.CellFormat(6, 5, fmt.Sprintf("%d.", i+1), "", 0, "L", false, 0, "")
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 5, rec, "", 1, "L", false, 0, "")
|
|
pdf.Ln(1)
|
|
}
|
|
}
|
|
|
|
if comparison := strings.TrimSpace(fn.PeriodComparison); comparison != "" {
|
|
pdf.Ln(3)
|
|
pdf.SetFont("Arial", "B", 11)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 8, "Period-over-period changes", "", 1, "L", false, 0, "")
|
|
pdf.Ln(2)
|
|
pdf.SetFont("Arial", "", 9)
|
|
pdf.MultiCell(bodyWidth, 5, comparison, "", "L", false)
|
|
}
|
|
|
|
if disclaimer := strings.TrimSpace(fn.Disclaimer); disclaimer != "" {
|
|
pdf.Ln(4)
|
|
pdf.SetFont("Arial", "I", 8)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.MultiCell(bodyWidth, 4, disclaimer, "", "L", false)
|
|
} else if fn.Source == NarrativeSourceHeuristic {
|
|
// Same discoverability nudge as the single-resource path: a
|
|
// heuristic fleet narrative is functional but doesn't surface
|
|
// the AI capability that would have produced this section.
|
|
// Without the nudge, the user has no signal that fleet AI
|
|
// synthesis is a thing they could enable.
|
|
pdf.Ln(4)
|
|
pdf.SetFont("Arial", "I", 8)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.MultiCell(bodyWidth, 4,
|
|
"Tip: Configure Pulse Assistant in Settings to add AI-narrated fleet synthesis with named outliers, cross-cutting patterns, and period-over-period comparison to future reports.",
|
|
"", "L", false)
|
|
}
|
|
}
|
|
|
|
// writeCondensedResourcePage writes a condensed single-page view for one resource.
|
|
// condensedResourceBlockHeight estimates the vertical space a condensed
|
|
// resource block needs so the fleet layout can decide whether it fits on
|
|
// the current page. Estimates err slightly high; a block that still
|
|
// overflows is carried across pages by fpdf's auto page break.
|
|
func condensedResourceBlockHeight(rd *ReportData, unresolved []AlertInfo) float64 {
|
|
height := 11.0 + 6.0 + 27.0 + 8.0 // name header + availability line + stats bar + padding
|
|
if len(rd.Metrics["cpu"]) >= 2 || len(rd.Metrics["memory"]) >= 2 {
|
|
height += 58 // chart title + canvas + legend
|
|
}
|
|
active := len(unresolved)
|
|
if active > 0 {
|
|
shown := active
|
|
if shown > 3 {
|
|
shown = 4 // three alerts plus the "and N more" line
|
|
}
|
|
height += 10 + float64(shown)*5
|
|
}
|
|
if len(rd.Storage) > 0 {
|
|
height += 10 + float64(len(rd.Storage))*5
|
|
}
|
|
if len(rd.Backups) > 0 {
|
|
height += 20
|
|
}
|
|
return height
|
|
}
|
|
|
|
// writeCondensedResourcePage writes one resource's block of a fleet report.
|
|
// unresolved is the resource's live alerts matched against the whole fleet.
|
|
func (g *PDFGenerator) writeCondensedResourcePage(pdf *fpdf.Fpdf, rd *ReportData, unresolved []AlertInfo) {
|
|
// Resource header
|
|
resourceName := rd.ResourceID
|
|
if rd.Resource != nil && rd.Resource.Name != "" {
|
|
resourceName = rd.Resource.Name
|
|
}
|
|
|
|
// Name - measure width while font is still set to bold
|
|
pdf.SetFont("Arial", "B", 14)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
nameWidth := pdf.GetStringWidth(resourceName)
|
|
pdf.CellFormat(nameWidth+2, 8, resourceName, "", 0, "L", false, 0, "")
|
|
|
|
// Type/status/uptime inline after the name
|
|
typeDisplay := GetResourceTypeDisplayName(rd.ResourceType)
|
|
status := "unknown"
|
|
if rd.Resource != nil {
|
|
status = rd.Resource.Status
|
|
}
|
|
|
|
pdf.SetFont("Arial", "", 10)
|
|
if status == "online" || status == "running" {
|
|
pdf.SetTextColor(colorAccent[0], colorAccent[1], colorAccent[2])
|
|
} else if status == "stopped" || status == "offline" {
|
|
pdf.SetTextColor(colorDanger[0], colorDanger[1], colorDanger[2])
|
|
} else {
|
|
pdf.SetTextColor(colorWarning[0], colorWarning[1], colorWarning[2])
|
|
}
|
|
statusStr := fmt.Sprintf(" | %s | %s", typeDisplay, status)
|
|
|
|
// Uptime
|
|
if rd.Resource != nil && rd.Resource.Uptime > 0 {
|
|
statusStr += fmt.Sprintf(" | Uptime: %s", formatUptime(rd.Resource.Uptime))
|
|
}
|
|
pdf.CellFormat(0, 8, statusStr, "", 1, "L", false, 0, "")
|
|
|
|
// Availability over the window, when the state timeline produced one.
|
|
if rd.Availability != nil {
|
|
line := "Availability: not observed this period"
|
|
lineColor := colorTextMuted
|
|
if rd.Availability.Observed() {
|
|
line = fmt.Sprintf("Availability: %s", availabilityUptimeLabel(rd.Availability.UptimePercent))
|
|
if rd.Availability.DownIncidents > 0 {
|
|
outageWord := "outages"
|
|
if rd.Availability.DownIncidents == 1 {
|
|
outageWord = "outage"
|
|
}
|
|
line += fmt.Sprintf(" (%d %s, %s down)", rd.Availability.DownIncidents, outageWord, formatOutageDuration(rd.Availability.TotalDowntime))
|
|
}
|
|
lineColor = colorTextDark
|
|
}
|
|
pdf.SetFont("Arial", "", 9)
|
|
pdf.SetTextColor(lineColor[0], lineColor[1], lineColor[2])
|
|
pdf.CellFormat(0, 5, line, "", 1, "L", false, 0, "")
|
|
}
|
|
pdf.Ln(3)
|
|
|
|
// Stats bar - CPU, Memory, Disk averages and maxes
|
|
pdf.SetFillColor(colorBackground[0], colorBackground[1], colorBackground[2])
|
|
pdf.SetDrawColor(colorGridLine[0], colorGridLine[1], colorGridLine[2])
|
|
barY := pdf.GetY()
|
|
barWidth := 170.0
|
|
barHeight := 22.0
|
|
pdf.RoundedRect(20, barY, barWidth, barHeight, 2, "1234", "FD")
|
|
|
|
colW := barWidth / 3.0
|
|
|
|
// CPU stats
|
|
var avgCPU, maxCPU, avgMem, maxMem, avgDisk, maxDisk float64
|
|
if stats, ok := rd.Summary.ByMetric["cpu"]; ok {
|
|
avgCPU = stats.Avg
|
|
maxCPU = stats.Max
|
|
}
|
|
if stats, ok := rd.Summary.ByMetric["memory"]; ok {
|
|
avgMem = stats.Avg
|
|
maxMem = stats.Max
|
|
}
|
|
if stats, ok := rd.Summary.ByMetric["disk"]; ok {
|
|
avgDisk = stats.Avg
|
|
maxDisk = stats.Max
|
|
} else if stats, ok := rd.Summary.ByMetric["usage"]; ok {
|
|
avgDisk = stats.Avg
|
|
maxDisk = stats.Max
|
|
}
|
|
|
|
// CPU column
|
|
pdf.SetXY(20+2, barY+3)
|
|
pdf.SetFont("Arial", "B", 9)
|
|
pdf.SetTextColor(colorSecondary[0], colorSecondary[1], colorSecondary[2])
|
|
pdf.CellFormat(colW-4, 5, "CPU", "", 0, "C", false, 0, "")
|
|
|
|
pdf.SetXY(20+2, barY+9)
|
|
pdf.SetFont("Arial", "B", 11)
|
|
pdf.SetTextColor(getStatColor(avgCPU)[0], getStatColor(avgCPU)[1], getStatColor(avgCPU)[2])
|
|
pdf.CellFormat(colW-4, 5, fmt.Sprintf("avg %.1f%% / max %.1f%%", avgCPU, maxCPU), "", 0, "C", false, 0, "")
|
|
|
|
// Memory column
|
|
pdf.SetXY(20+colW+2, barY+3)
|
|
pdf.SetFont("Arial", "B", 9)
|
|
pdf.SetTextColor([3]int{155, 89, 182}[0], [3]int{155, 89, 182}[1], [3]int{155, 89, 182}[2])
|
|
pdf.CellFormat(colW-4, 5, "Memory", "", 0, "C", false, 0, "")
|
|
|
|
pdf.SetXY(20+colW+2, barY+9)
|
|
pdf.SetFont("Arial", "B", 11)
|
|
pdf.SetTextColor(getStatColor(avgMem)[0], getStatColor(avgMem)[1], getStatColor(avgMem)[2])
|
|
pdf.CellFormat(colW-4, 5, fmt.Sprintf("avg %.1f%% / max %.1f%%", avgMem, maxMem), "", 0, "C", false, 0, "")
|
|
|
|
// Disk column
|
|
pdf.SetXY(20+2*colW+2, barY+3)
|
|
pdf.SetFont("Arial", "B", 9)
|
|
pdf.SetTextColor(colorAccent[0], colorAccent[1], colorAccent[2])
|
|
pdf.CellFormat(colW-4, 5, "Disk", "", 0, "C", false, 0, "")
|
|
|
|
pdf.SetXY(20+2*colW+2, barY+9)
|
|
pdf.SetFont("Arial", "B", 11)
|
|
pdf.SetTextColor(getStatColor(avgDisk)[0], getStatColor(avgDisk)[1], getStatColor(avgDisk)[2])
|
|
pdf.CellFormat(colW-4, 5, fmt.Sprintf("avg %.1f%% / max %.1f%%", avgDisk, maxDisk), "", 0, "C", false, 0, "")
|
|
|
|
pdf.SetY(barY + barHeight + 5)
|
|
|
|
// Small chart: CPU + Memory overlaid (if we have data)
|
|
cpuPoints := rd.Metrics["cpu"]
|
|
memPoints := rd.Metrics["memory"]
|
|
if len(cpuPoints) >= 2 || len(memPoints) >= 2 {
|
|
chartHeight := 40.0
|
|
chartWidth := 170.0
|
|
chartX := 20.0
|
|
chartY := pdf.GetY()
|
|
|
|
// Use CPU data primarily, or memory if no CPU
|
|
primaryPoints := cpuPoints
|
|
if len(primaryPoints) < 2 {
|
|
primaryPoints = memPoints
|
|
}
|
|
|
|
if len(primaryPoints) >= 2 {
|
|
// Chart title
|
|
pdf.SetFont("Arial", "B", 9)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 5, "Performance Overview", "", 1, "L", false, 0, "")
|
|
chartY = pdf.GetY()
|
|
|
|
g.drawChart(pdf, primaryPoints, chartX, chartY, chartWidth, chartHeight, "cpu")
|
|
|
|
// If we have both CPU and memory, overlay memory
|
|
if len(cpuPoints) >= 2 && len(memPoints) >= 2 {
|
|
g.drawChartOverlay(pdf, memPoints, cpuPoints, chartX, chartY, chartWidth, chartHeight)
|
|
}
|
|
|
|
// Legend (below chart X-axis labels which are at chartY + chartHeight + 1..+5)
|
|
pdf.SetY(chartY + chartHeight + 7)
|
|
pdf.SetFont("Arial", "", 7)
|
|
if len(cpuPoints) >= 2 {
|
|
pdf.SetTextColor(colorSecondary[0], colorSecondary[1], colorSecondary[2])
|
|
pdf.CellFormat(30, 4, "--- CPU", "", 0, "L", false, 0, "")
|
|
}
|
|
if len(memPoints) >= 2 {
|
|
pdf.SetTextColor(155, 89, 182)
|
|
pdf.CellFormat(30, 4, "--- Memory", "", 0, "L", false, 0, "")
|
|
}
|
|
pdf.Ln(6)
|
|
}
|
|
}
|
|
|
|
// Active alerts (up to 3), including any that moved without recovering
|
|
activeAlerts := unresolved
|
|
|
|
if len(activeAlerts) > 0 {
|
|
pdf.Ln(3)
|
|
pdf.SetFont("Arial", "B", 10)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 6, "Active Alerts", "", 1, "L", false, 0, "")
|
|
pdf.Ln(1)
|
|
|
|
pdf.SetFont("Arial", "", 9)
|
|
maxAlerts := 3
|
|
if len(activeAlerts) < maxAlerts {
|
|
maxAlerts = len(activeAlerts)
|
|
}
|
|
for i := 0; i < maxAlerts; i++ {
|
|
alert := activeAlerts[i]
|
|
if alert.Level == "critical" {
|
|
pdf.SetTextColor(colorDanger[0], colorDanger[1], colorDanger[2])
|
|
} else {
|
|
pdf.SetTextColor(colorWarning[0], colorWarning[1], colorWarning[2])
|
|
}
|
|
pdf.CellFormat(6, 5, "!", "", 0, "C", false, 0, "")
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 5, unresolvedAlertLine(alert, 80), "", 1, "L", false, 0, "")
|
|
}
|
|
if len(activeAlerts) > 3 {
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
pdf.CellFormat(0, 5, fmt.Sprintf("... and %d more", len(activeAlerts)-3), "", 1, "L", false, 0, "")
|
|
}
|
|
}
|
|
|
|
// Storage summary (nodes) or backup summary (VMs/containers)
|
|
if len(rd.Storage) > 0 {
|
|
pdf.Ln(3)
|
|
pdf.SetFont("Arial", "B", 10)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 6, "Storage Pools", "", 1, "L", false, 0, "")
|
|
pdf.Ln(1)
|
|
|
|
pdf.SetFont("Arial", "", 9)
|
|
for _, s := range rd.Storage {
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
line := fmt.Sprintf("%s (%s): %s / %s (%.1f%%)",
|
|
s.Name, s.Type,
|
|
formatBytes(float64(s.Used)),
|
|
formatBytes(float64(s.Total)),
|
|
s.UsagePerc)
|
|
pdf.CellFormat(0, 5, line, "", 1, "L", false, 0, "")
|
|
}
|
|
}
|
|
|
|
if len(rd.Backups) > 0 {
|
|
pdf.Ln(3)
|
|
pdf.SetFont("Arial", "B", 10)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 6, "Backups", "", 1, "L", false, 0, "")
|
|
pdf.Ln(1)
|
|
|
|
pdf.SetFont("Arial", "", 9)
|
|
pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2])
|
|
pdf.CellFormat(0, 5, fmt.Sprintf("%d backups available", len(rd.Backups)), "", 1, "L", false, 0, "")
|
|
if len(rd.Backups) > 0 {
|
|
latest := rd.Backups[0]
|
|
for _, b := range rd.Backups {
|
|
if b.Timestamp.After(latest.Timestamp) {
|
|
latest = b
|
|
}
|
|
}
|
|
pdf.CellFormat(0, 5, fmt.Sprintf("Latest: %s (%s)", latest.Timestamp.Format("2006-01-02 15:04"), formatBytes(float64(latest.Size))), "", 1, "L", false, 0, "")
|
|
}
|
|
}
|
|
}
|
|
|
|
// drawChartOverlay draws a secondary line on an existing chart using memory data over CPU scale.
|
|
func (g *PDFGenerator) drawChartOverlay(pdf *fpdf.Fpdf, overlayPoints []MetricDataPoint, primaryPoints []MetricDataPoint, x, y, width, height float64) {
|
|
if len(overlayPoints) < 2 || len(primaryPoints) < 2 {
|
|
return
|
|
}
|
|
|
|
// Use the same time scale as the primary chart
|
|
startTime := primaryPoints[0].Timestamp.Unix()
|
|
endTime := primaryPoints[len(primaryPoints)-1].Timestamp.Unix()
|
|
timeRange := float64(endTime - startTime)
|
|
if timeRange == 0 {
|
|
timeRange = 1
|
|
}
|
|
|
|
// Use the primary chart's value range for consistent scaling
|
|
minVal, maxVal := primaryPoints[0].Value, primaryPoints[0].Value
|
|
for _, p := range primaryPoints {
|
|
if p.Value < minVal {
|
|
minVal = p.Value
|
|
}
|
|
if p.Value > maxVal {
|
|
maxVal = p.Value
|
|
}
|
|
}
|
|
// Also include overlay points in the range
|
|
for _, p := range overlayPoints {
|
|
if p.Value < minVal {
|
|
minVal = p.Value
|
|
}
|
|
if p.Value > maxVal {
|
|
maxVal = p.Value
|
|
}
|
|
}
|
|
|
|
valRange := maxVal - minVal
|
|
if valRange < 1 {
|
|
valRange = 10
|
|
}
|
|
minVal = math.Max(0, minVal-valRange*0.1)
|
|
maxVal = maxVal + valRange*0.1
|
|
|
|
// Draw the overlay line in purple (memory color)
|
|
memColor := [3]int{155, 89, 182}
|
|
pdf.SetDrawColor(memColor[0], memColor[1], memColor[2])
|
|
pdf.SetLineWidth(0.6)
|
|
|
|
prevX, prevY := 0.0, 0.0
|
|
for i, p := range overlayPoints {
|
|
xPos := x + 2 + (float64(p.Timestamp.Unix()-startTime)/timeRange)*(width-4)
|
|
yPos := y + height - 2 - ((p.Value-minVal)/(maxVal-minVal))*(height-4)
|
|
yPos = math.Max(y+2, math.Min(y+height-2, yPos))
|
|
|
|
if i > 0 {
|
|
pdf.Line(prevX, prevY, xPos, yPos)
|
|
}
|
|
prevX, prevY = xPos, yPos
|
|
}
|
|
}
|
|
|
|
// addMultiPageNumbers adds page numbers to all pages except the first (cover).
|
|
func (g *PDFGenerator) addMultiPageNumbers(pdf *fpdf.Fpdf) {
|
|
pdf.SetAutoPageBreak(false, 0)
|
|
|
|
totalPages := pdf.PageCount()
|
|
|
|
for i := 2; i <= totalPages; i++ {
|
|
pdf.SetPage(i)
|
|
pageWidth, pageHeight := pdf.GetPageSize()
|
|
|
|
pdf.SetY(pageHeight - 15)
|
|
pdf.SetFont("Arial", "", 8)
|
|
pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2])
|
|
|
|
pageNum := i - 1
|
|
totalContent := totalPages - 1
|
|
pdf.CellFormat(0, 5, fmt.Sprintf("Page %d of %d", pageNum, totalContent), "", 0, "C", false, 0, "")
|
|
|
|
// Bottom line
|
|
pdf.SetDrawColor(colorGridLine[0], colorGridLine[1], colorGridLine[2])
|
|
pdf.SetLineWidth(0.3)
|
|
pdf.Line(20, pageHeight-20, pageWidth-20, pageHeight-20)
|
|
}
|
|
}
|
|
|
|
// getMetricColor returns a color for a metric type.
|
|
func getMetricColor(metricType string) [3]int {
|
|
switch metricType {
|
|
case "cpu":
|
|
return colorSecondary // Blue
|
|
case "memory":
|
|
return [3]int{155, 89, 182} // Purple
|
|
case "disk", "usage":
|
|
return colorAccent // Green
|
|
default:
|
|
return colorSecondary
|
|
}
|
|
}
|
|
|
|
// formatDuration formats a duration in human-readable form.
|
|
func formatDuration(d time.Duration) string {
|
|
hours := int(d.Hours())
|
|
if hours >= 24 {
|
|
days := hours / 24
|
|
remainingHours := hours % 24
|
|
dayWord := "days"
|
|
if days == 1 {
|
|
dayWord = "day"
|
|
}
|
|
if remainingHours > 0 {
|
|
hourWord := "hours"
|
|
if remainingHours == 1 {
|
|
hourWord = "hour"
|
|
}
|
|
return fmt.Sprintf("%d %s, %d %s", days, dayWord, remainingHours, hourWord)
|
|
}
|
|
return fmt.Sprintf("%d %s", days, dayWord)
|
|
}
|
|
if hours > 0 {
|
|
minutes := int(d.Minutes()) % 60
|
|
hourWord := "hours"
|
|
if hours == 1 {
|
|
hourWord = "hour"
|
|
}
|
|
if minutes > 0 {
|
|
minWord := "minutes"
|
|
if minutes == 1 {
|
|
minWord = "minute"
|
|
}
|
|
return fmt.Sprintf("%d %s, %d %s", hours, hourWord, minutes, minWord)
|
|
}
|
|
return fmt.Sprintf("%d %s", hours, hourWord)
|
|
}
|
|
minutes := int(d.Minutes())
|
|
minWord := "minutes"
|
|
if minutes == 1 {
|
|
minWord = "minute"
|
|
}
|
|
return fmt.Sprintf("%d %s", minutes, minWord)
|
|
}
|
|
|
|
// diskTemperatureSeverity colours a disk reading by its alert disk temperature
|
|
// thresholds: "critical" from the trigger, "warning" from the clear value, and
|
|
// "" below it or when the disk carries no thresholds.
|
|
func diskTemperatureSeverity(disk DiskInfo) string {
|
|
if disk.Temperature <= 0 || disk.TemperatureCritical <= 0 {
|
|
return ""
|
|
}
|
|
reading := float64(disk.Temperature)
|
|
if reading >= disk.TemperatureCritical {
|
|
return "critical"
|
|
}
|
|
if disk.TemperatureWarning > 0 && reading >= disk.TemperatureWarning {
|
|
return "warning"
|
|
}
|
|
return ""
|
|
}
|