server/internal/handlers/incident_report.go
Rama main · réplica de lectura del repositorio original.
package handlers
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"log"
"math"
"net/http"
"sort"
"strings"
"time"
"blackbox/server/internal/models"
"blackbox/shared/types"
"github.com/go-chi/chi/v5"
"github.com/phpdave11/gofpdf"
"gorm.io/gorm"
)
type reportData struct {
Incident models.Incident
Entries []reportEntry
AIAnalysis string
AIModel string
NodeNames []string
Services []string
GeneratedAt time.Time
}
type reportEntry struct {
Link models.IncidentEntry
Entry types.Entry
}
// pdfTheme drives all colors so the same functions produce a dark (default)
// or print-friendly (white background, dark ink) PDF.
type pdfTheme struct{ print bool }
func (t pdfTheme) label(pdf *gofpdf.Fpdf) {
if t.print {
pdf.SetTextColor(0x99, 0x00, 0x00)
} else {
pdf.SetTextColor(0xCC, 0x22, 0x22)
}
}
func (t pdfTheme) body(pdf *gofpdf.Fpdf) {
if t.print {
pdf.SetTextColor(0x00, 0x00, 0x00)
} else {
pdf.SetTextColor(0xE8, 0xE8, 0xE8)
}
}
func (t pdfTheme) dim(pdf *gofpdf.Fpdf) {
if t.print {
pdf.SetTextColor(0x44, 0x44, 0x44)
} else {
pdf.SetTextColor(0xA0, 0xA0, 0xA0)
}
}
func (t pdfTheme) ruleColor(pdf *gofpdf.Fpdf) {
if t.print {
pdf.SetDrawColor(0xCC, 0xCC, 0xCC)
} else {
pdf.SetDrawColor(0x44, 0x08, 0x08)
}
}
// fillBackground fills the current page. Dimensions are derived from the PDF
// rather than hardcoded so the fill always covers the full page regardless of
// format. A small +0.1 buffer avoids sub-pixel slivers from float imprecision.
func (t pdfTheme) fillBackground(pdf *gofpdf.Fpdf) {
w, h, _ := pdf.PageSize(0)
if t.print {
pdf.SetFillColor(0xFF, 0xFF, 0xFF) // explicit white — don't rely on default
} else {
pdf.SetFillColor(0x00, 0x00, 0x00)
}
pdf.Rect(0, 0, w+0.1, h+0.1, "F")
}
func DownloadIncidentReport(database *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var incident models.Incident
if err := database.First(&incident, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
writeError(w, http.StatusNotFound, "incident not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to fetch incident")
return
}
if incident.Status != "resolved" {
writeError(w, http.StatusBadRequest, "incident is not resolved")
return
}
var links []models.IncidentEntry
if err := database.Where("incident_id = ?", id).Order("score DESC").Find(&links).Error; err != nil {
writeError(w, http.StatusInternalServerError, "failed to fetch incident entries")
return
}
reportEntries := make([]reportEntry, 0, len(links))
for _, link := range links {
var entry types.Entry
if err := database.First(&entry, "id = ?", link.EntryID).Error; err != nil {
log.Printf("DownloadIncidentReport missing entry for incident %s entry %s role %s: %v", link.IncidentID, link.EntryID, link.Role, err)
continue
}
reportEntries = append(reportEntries, reportEntry{Link: link, Entry: entry})
}
sort.Slice(reportEntries, func(i, j int) bool {
return reportEntries[i].Entry.Timestamp.Before(reportEntries[j].Entry.Timestamp)
})
aiAnalysis, aiModel := incidentAIFields(incident.Metadata)
data := reportData{
Incident: incident,
Entries: reportEntries,
AIAnalysis: aiAnalysis,
AIModel: aiModel,
NodeNames: decodeStringList(incident.NodeNames),
Services: decodeStringList(incident.Services),
GeneratedAt: time.Now().UTC(),
}
printMode := r.URL.Query().Get("print") == "1"
pdfBytes, err := generateIncidentPDF(data, pdfTheme{print: printMode})
if err != nil {
log.Printf("DownloadIncidentReport PDF generation failed for incident %s: %v", id, err)
writeError(w, http.StatusInternalServerError, "failed to generate PDF")
return
}
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="incident-%s-report.pdf"`, data.Incident.ID))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(pdfBytes)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(pdfBytes)
}
}
func incidentAIFields(metadata string) (string, string) {
var meta map[string]interface{}
if metadata == "" {
return "", ""
}
if err := json.Unmarshal([]byte(metadata), &meta); err != nil {
return "", ""
}
analysis, _ := meta["ai_analysis"].(string)
model, _ := meta["ai_model"].(string)
return strings.TrimSpace(analysis), strings.TrimSpace(model)
}
func decodeStringList(value string) []string {
var items []string
if value != "" {
_ = json.Unmarshal([]byte(value), &items)
}
if items == nil {
return []string{}
}
return items
}
func generateIncidentPDF(data reportData, theme pdfTheme) ([]byte, error) {
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.SetMargins(20, 20, 20)
pdf.SetHeaderFunc(func() {
theme.fillBackground(pdf)
pdf.SetXY(20, 20)
})
pdf.AddPage()
pdfHeader(pdf, data, theme)
pdfRule(pdf, theme)
pdfOverview(pdf, data, theme)
pdfRule(pdf, theme)
pdfAISection(pdf, data, theme)
pdfEventChain(pdf, data.Entries, theme)
pdfRule(pdf, theme)
pdfFooter(pdf, data.GeneratedAt, theme)
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func pdfHeader(pdf *gofpdf.Fpdf, data reportData, theme pdfTheme) {
pdf.SetFont("Courier", "", 12)
theme.label(pdf)
label := "BLACKBOX INCIDENT REPORT"
idStr := fmt.Sprintf("#%s", data.Incident.ID)
pageW, _, _ := pdf.PageSize(0)
usableW := pageW - 40
pdf.CellFormat(usableW/2, 7, label, "", 0, "L", false, 0, "")
pdf.CellFormat(usableW/2, 7, idStr, "", 1, "R", false, 0, "")
pdf.SetFont("Courier", "B", 16)
theme.body(pdf)
pdf.MultiCell(0, 9, sanitizePDFString(data.Incident.Title), "", "L", false)
pdf.Ln(2)
}
func pdfOverview(pdf *gofpdf.Fpdf, data reportData, theme pdfTheme) {
inc := data.Incident
pdf.SetFont("Courier", "", 11)
theme.label(pdf)
pdf.CellFormat(0, 6, "OVERVIEW", "", 1, "L", false, 0, "")
pdf.Ln(1)
keyW := 34.0
row := func(key, value string) {
pdf.SetFont("Courier", "", 11)
theme.label(pdf)
pdf.CellFormat(keyW, 6, key, "", 0, "L", false, 0, "")
theme.body(pdf)
pdf.MultiCell(0, 6, sanitizePDFString(value), "", "L", false)
}
row("Status:", strings.ToUpper(inc.Status))
row("Confidence:", strings.ToUpper(inc.Confidence))
row("Nodes:", joinOrDash(data.NodeNames))
row("Services:", joinOrDash(data.Services))
row("Opened (UTC):", inc.OpenedAt.UTC().Format("2006-01-02 15:04:05"))
resolvedVal := "-"
durationVal := "-"
if inc.ResolvedAt != nil {
resolvedVal = inc.ResolvedAt.UTC().Format("2006-01-02 15:04:05")
durationVal = formatDuration(inc.OpenedAt, *inc.ResolvedAt)
}
row("Resolved (UTC):", resolvedVal)
row("Duration:", durationVal)
pdf.Ln(2)
}
func joinOrDash(items []string) string {
if len(items) == 0 {
return "-"
}
return strings.Join(items, ", ")
}
func pdfAISection(pdf *gofpdf.Fpdf, data reportData, theme pdfTheme) {
if data.AIAnalysis == "" {
return
}
pdf.SetFont("Courier", "", 11)
theme.label(pdf)
label := "AI ANALYSIS"
if data.AIModel != "" {
label = fmt.Sprintf("AI ANALYSIS [%s]", data.AIModel)
}
pdf.CellFormat(0, 6, sanitizePDFString(label), "", 1, "L", false, 0, "")
pdf.Ln(1)
pdf.SetFont("Courier", "", 11)
theme.body(pdf)
pdf.MultiCell(0, 6, sanitizePDFString(data.AIAnalysis), "", "L", false)
pdf.Ln(2)
pdfRule(pdf, theme)
}
// truncateToFit binary-searches for the longest prefix of text whose rendered
// width (at the current font) fits within maxWidth, appending "…" if truncated.
func truncateToFit(pdf *gofpdf.Fpdf, text string, maxWidth float64) string {
if pdf.GetStringWidth(text) <= maxWidth {
return text
}
runes := []rune(text)
lo, hi := 0, len(runes)
for lo < hi {
mid := (lo + hi + 1) / 2
if pdf.GetStringWidth(string(runes[:mid])+"...") <= maxWidth {
lo = mid
} else {
hi = mid - 1
}
}
if lo == 0 {
return ""
}
return string(runes[:lo]) + "..."
}
func pdfEventChain(pdf *gofpdf.Fpdf, entries []reportEntry, theme pdfTheme) {
pdf.SetFont("Courier", "", 11)
theme.label(pdf)
pdf.CellFormat(0, 6, fmt.Sprintf("EVENT CHAIN (%d entries)", len(entries)), "", 1, "L", false, 0, "")
pdf.Ln(1)
pdf.SetFont("Courier", "", 10)
theme.label(pdf)
pageW, _, _ := pdf.PageSize(0)
usableW := pageW - 40
colRole := 24.0
colTS := 50.0
colSrc := 24.0
colSvc := 32.0
colEvent := usableW - colRole - colTS - colSrc - colSvc
pdf.CellFormat(colRole, 5, "ROLE", "", 0, "L", false, 0, "")
pdf.CellFormat(colTS, 5, "TIMESTAMP", "", 0, "L", false, 0, "")
pdf.CellFormat(colSrc, 5, "SOURCE", "", 0, "L", false, 0, "")
pdf.CellFormat(colSvc, 5, "SERVICE", "", 0, "L", false, 0, "")
pdf.CellFormat(colEvent, 5, "EVENT", "", 1, "L", false, 0, "")
for _, re := range entries {
link := re.Link
entry := re.Entry
pdf.SetFont("Courier", "", 10)
theme.body(pdf)
rol := truncateToFit(pdf, roleLabel(link.Role), colRole-1)
src := truncateToFit(pdf, sanitizePDFString(entry.Source), colSrc-1)
svc := truncateToFit(pdf, sanitizePDFString(entry.Service), colSvc-1)
evt := truncateToFit(pdf, sanitizePDFString(entry.Event), colEvent-1)
pdf.CellFormat(colRole, 5, rol, "", 0, "L", false, 0, "")
pdf.CellFormat(colTS, 5, entry.Timestamp.UTC().Format("2006-01-02 15:04:05"), "", 0, "L", false, 0, "")
pdf.CellFormat(colSrc, 5, src, "", 0, "L", false, 0, "")
pdf.CellFormat(colSvc, 5, svc, "", 0, "L", false, 0, "")
pdf.CellFormat(colEvent, 5, evt, "", 1, "L", false, 0, "")
if entry.Content != "" {
// Flatten newlines so MultiCell wraps at word boundaries, not at raw
// line breaks in the source data which can produce jagged sub-rows.
flat := strings.NewReplacer("\r\n", " ", "\n", " ", "\r", " ").Replace(entry.Content)
content := sanitizePDFString(truncateRunes(flat, 160))
pdf.SetX(26)
pdf.SetFont("Courier", "", 10)
theme.dim(pdf)
pdf.MultiCell(usableW-6, 5, content, "", "L", false)
}
if link.Role == "ai_cause" && link.Reason != "" {
flat := strings.NewReplacer("\r\n", " ", "\n", " ", "\r", " ").Replace(link.Reason)
reason := sanitizePDFString("AI: " + truncateRunes(flat, 160))
pdf.SetX(26)
pdf.SetFont("Courier", "I", 10)
theme.dim(pdf)
pdf.MultiCell(usableW-6, 5, reason, "", "L", false)
}
}
pdf.Ln(2)
}
func pdfFooter(pdf *gofpdf.Fpdf, generatedAt time.Time, theme pdfTheme) {
pdf.SetFont("Courier", "", 10)
theme.dim(pdf)
pdf.CellFormat(0, 6, fmt.Sprintf("Generated by Blackbox on %s", generatedAt.UTC().Format("2006-01-02")), "", 1, "C", false, 0, "")
}
func pdfRule(pdf *gofpdf.Fpdf, theme pdfTheme) {
pdf.Ln(2)
theme.ruleColor(pdf)
pdf.SetLineWidth(0.3)
x := pdf.GetX()
y := pdf.GetY()
pageW, _, _ := pdf.PageSize(0)
pdf.Line(20, y, pageW-20, y)
pdf.SetXY(x, y+3)
}
func roleLabel(role string) string {
switch role {
case "trigger":
return "TRIGGER"
case "cause":
return "CAUSE"
case "evidence":
return "EVIDENCE"
case "recovery":
return "RECOVERY"
case "ai_cause":
return "AI CAUSE"
default:
return strings.ToUpper(role)
}
}
func formatDuration(opened time.Time, resolved time.Time) string {
secs := int(math.Round(resolved.Sub(opened).Seconds()))
if secs < 0 {
secs = 0
}
if secs < 60 {
return fmt.Sprintf("%ds", secs)
}
if secs < 3600 {
return fmt.Sprintf("%dm %ds", secs/60, secs%60)
}
return fmt.Sprintf("%dh %dm", secs/3600, (secs%3600)/60)
}
func truncateRunes(value string, max int) string {
runes := []rune(value)
if len(runes) <= max {
return value
}
return string(runes[:max]) + "..."
}
func sanitizePDFString(s string) string {
replacer := strings.NewReplacer(
"‘", "'", "’", "'", // curly single quotes
"“", "\"", "”", "\"", // curly double quotes
"–", "-", "—", "--", // en/em dash
"…", "...", // ellipsis
" ", " ", // non-breaking space
)
// Strip remaining non-Latin-1 chars
var b strings.Builder
for _, r := range replacer.Replace(s) {
if r <= 0xFF {
b.WriteRune(r)
} else {
b.WriteRune('?')
}
}
return b.String()
}