package reports import ( "fmt" "sort" "strconv" "strings" "time" "evobgp/internal/store" ) // RevisionDiagnosticLog is plain-text diagnostic output for a revision. type RevisionDiagnosticLog struct { Body []byte Filename string } // BuildRevisionDiagnosticLog aggregates revision prefixes by source and community. func BuildRevisionDiagnosticLog(st store.Backend, tenantID string, rev *store.Revision) (*RevisionDiagnosticLog, error) { moduleType := "" moduleName := "" if strings.TrimSpace(rev.ModuleID) != "" { if mod, modErr := st.GetModule(tenantID, rev.ModuleID); modErr == nil && mod != nil { moduleType = strings.TrimSpace(mod.Type) moduleName = strings.TrimSpace(mod.Name) } } communityLabels := map[string]string{} if communities, listErr := st.ListCommunities(tenantID); listErr == nil { for _, c := range communities { if c == nil { continue } label := strings.TrimSpace(c.Title) if label == "" { label = strings.TrimSpace(c.Community) } if label == "" { label = c.ID } communityLabels[c.ID] = label } } type rawRow struct { Prefix string Source string Kind string SourceName string SourceDetail string CommunityID string CommunityLabel string } type summary struct { Kind string Source string SourceDetail string CommunityID string CommunityLabel string Count int Sample []string } cdnSourceURLByID := map[string]string{} if moduleType == "CDN_CIDRS" && strings.TrimSpace(rev.ModuleID) != "" { if sources, srcErr := st.ListCDNSources(tenantID, rev.ModuleID); srcErr == nil { for _, src := range sources { if src == nil { continue } id := strings.TrimSpace(src.ID) url := strings.TrimSpace(src.URL) if id != "" && url != "" { cdnSourceURLByID[id] = url } } } } rows := make([]rawRow, 0, rev.MaterializedPrefixCount) byGroup := map[string]*summary{} cursor := "" for { page, next, more := st.ListRevisionPrefixes(tenantID, rev.ID, cursor, 2000) for _, p := range page { kind, sourceName := classifyRevisionSource(p.Source) sourceDetail := sourceName if kind == "cdn" { if url, ok := cdnSourceURLByID[sourceName]; ok && strings.TrimSpace(url) != "" { sourceDetail = url } } communityID := "none" if p.CommunityID != nil && strings.TrimSpace(*p.CommunityID) != "" { communityID = strings.TrimSpace(*p.CommunityID) } communityLabel := "без community" if communityID != "none" { if lbl, ok := communityLabels[communityID]; ok && strings.TrimSpace(lbl) != "" { communityLabel = lbl } else { communityLabel = communityID } } rows = append(rows, rawRow{ Prefix: p.Prefix, Source: p.Source, Kind: kind, SourceName: sourceName, SourceDetail: sourceDetail, CommunityID: communityID, CommunityLabel: communityLabel, }) groupKey := kind + "|" + sourceName + "|" + communityID g, ok := byGroup[groupKey] if !ok { g = &summary{ Kind: kind, Source: sourceName, SourceDetail: sourceDetail, CommunityID: communityID, CommunityLabel: communityLabel, Sample: make([]string, 0, 5), } byGroup[groupKey] = g } g.Count++ if len(g.Sample) < 5 { g.Sample = append(g.Sample, p.Prefix) } } if !more || strings.TrimSpace(next) == "" { break } cursor = next } summaryKeys := make([]string, 0, len(byGroup)) for k := range byGroup { summaryKeys = append(summaryKeys, k) } sort.Strings(summaryKeys) sort.Slice(rows, func(i, j int) bool { if rows[i].Kind != rows[j].Kind { return rows[i].Kind < rows[j].Kind } if rows[i].SourceName != rows[j].SourceName { return rows[i].SourceName < rows[j].SourceName } if rows[i].CommunityID != rows[j].CommunityID { return rows[i].CommunityID < rows[j].CommunityID } return rows[i].Prefix < rows[j].Prefix }) var b strings.Builder b.WriteString("EvoBGP revision diagnostic log\n") b.WriteString("generated_at=" + time.Now().UTC().Format(time.RFC3339Nano) + "\n") b.WriteString("tenant_id=" + tenantID + "\n") b.WriteString("revision_id=" + rev.ID + "\n") b.WriteString("revision_created_at=" + rev.CreatedAt.UTC().Format(time.RFC3339Nano) + "\n") b.WriteString("content_hash=" + rev.ContentHash + "\n") b.WriteString("materialized_prefix_count=" + strconv.Itoa(rev.MaterializedPrefixCount) + "\n") if rev.ModuleID != "" { b.WriteString("module_id=" + rev.ModuleID + "\n") } else { b.WriteString("module_id=\n") } b.WriteString("module_type=" + moduleType + "\n") b.WriteString("module_name=" + moduleName + "\n") b.WriteString("fetched_rows=" + strconv.Itoa(len(rows)) + "\n\n") b.WriteString("## Aggregation by source and community\n") for _, k := range summaryKeys { g := byGroup[k] b.WriteString("- kind=" + g.Kind + " source=" + g.Source + " source_detail=" + g.SourceDetail + " community_id=" + g.CommunityID + " community_label=" + g.CommunityLabel + " count=" + strconv.Itoa(g.Count)) if len(g.Sample) > 0 { b.WriteString(" sample=" + strings.Join(g.Sample, ",")) } b.WriteByte('\n') } b.WriteString("\n## Raw rows\n") b.WriteString("prefix\tkind\tsource\tsource_detail\tcommunity_id\tcommunity_label\n") for _, row := range rows { b.WriteString(row.Prefix) b.WriteByte('\t') b.WriteString(row.Kind) b.WriteByte('\t') b.WriteString(row.Source) b.WriteByte('\t') b.WriteString(strings.ReplaceAll(row.SourceDetail, "\t", " ")) b.WriteByte('\t') b.WriteString(row.CommunityID) b.WriteByte('\t') b.WriteString(strings.ReplaceAll(row.CommunityLabel, "\t", " ")) b.WriteByte('\n') } filename := fmt.Sprintf("revision-%s-diagnostic.log", shortRevisionID(rev.ID)) return &RevisionDiagnosticLog{Body: []byte(b.String()), Filename: filename}, nil } func classifyRevisionSource(src string) (kind, sourceName string) { switch { case strings.HasPrefix(src, "as:"): return "asn", strings.TrimPrefix(src, "as:") case strings.HasPrefix(src, "domain:"): return "domain", strings.TrimPrefix(src, "domain:") case strings.HasPrefix(src, "cdn:"): return "cdn", strings.TrimPrefix(src, "cdn:") case src == "ip_range": return "ip_range", "manual_ranges" default: if strings.TrimSpace(src) == "" { return "unknown", "unknown" } return "source", strings.TrimSpace(src) } } func shortRevisionID(id string) string { s := strings.TrimSpace(id) if len(s) <= 8 { return s } return s[:8] }