Ctrl K
ComunidadGuías…

server/internal/handlers/incident_report.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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()
}