diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index 9c23b3a..5d05b9e 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -55,6 +55,7 @@ func (s *Server) registerV1(m *http.ServeMux) { m.HandleFunc("GET /revisions", s.handleListRevisions) m.HandleFunc("GET /revisions/{revision_id}", s.handleGetRevision) m.HandleFunc("GET /revisions/{revision_id}/preview", s.handleRevisionPreview) + m.HandleFunc("GET /revisions/{revision_id}/diagnostic-log", s.handleRevisionDiagnosticLog) m.HandleFunc("GET /revisions/{revision_a}/diff/{revision_b}", s.handleRevisionDiff) m.HandleFunc("POST /revisions/{revision_id}/rollback", s.handleRevisionRollback) m.HandleFunc("POST /apply", s.handleApply) @@ -503,6 +504,242 @@ func (s *Server) handleRevisionPreview(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, obj) } +func (s *Server) handleRevisionDiagnosticLog(w http.ResponseWriter, r *http.Request) { + a, ok := authFromContext(r.Context()) + if !ok { + writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth") + return + } + if !s.requireAtLeast(w, a, "viewer") { + return + } + revID := r.PathValue("revision_id") + rev, err := s.store.GetRevision(a.TenantID, revID) + if err != nil { + writeProblem(w, http.StatusNotFound, "Not Found", "revision not found") + return + } + + moduleType := "" + moduleName := "" + if strings.TrimSpace(rev.ModuleID) != "" { + if mod, modErr := s.store.GetModule(a.TenantID, rev.ModuleID); modErr == nil && mod != nil { + moduleType = strings.TrimSpace(mod.Type) + moduleName = strings.TrimSpace(mod.Name) + } + } + + communityLabels := map[string]string{} + if communities, listErr := s.store.ListCommunities(a.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 := s.store.ListCDNSources(a.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 := s.store.ListRevisionPrefixes(a.TenantID, revID, 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=" + a.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 := "revision-" + shortRevisionID(rev.ID) + "-diagnostic.log" + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(b.String())) +} + +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] +} + func (s *Server) handleRevisionDiff(w http.ResponseWriter, r *http.Request) { a, ok := authFromContext(r.Context()) if !ok { diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index 30f68f9..c80ccbc 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -124,6 +124,34 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) { } }) + t.Run("diagnostic log for revision", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, base+"/v1/revisions/"+rev+"/diagnostic-log", nil) + req.Header.Set("Authorization", "Bearer opkey") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("status %d: %s", resp.StatusCode, b) + } + if got := resp.Header.Get("Content-Type"); !strings.Contains(got, "text/plain") { + t.Fatalf("unexpected content-type: %q", got) + } + raw, _ := io.ReadAll(resp.Body) + body := string(raw) + for _, needle := range []string{ + "EvoBGP revision diagnostic log", + "revision_id=" + rev, + "## Raw rows", + } { + if !strings.Contains(body, needle) { + t.Fatalf("diagnostic log missing %q", needle) + } + } + }) + t.Run("list modules peers speakers", func(t *testing.T) { for _, path := range []string{"/v1/modules", "/v1/peers", "/v1/speakers"} { req, _ := http.NewRequest(http.MethodGet, base+path, nil) diff --git a/web/src/lib/components/operations/OperationsRevisionsTab.svelte b/web/src/lib/components/operations/OperationsRevisionsTab.svelte index 5e3f3e1..aa44cf1 100644 --- a/web/src/lib/components/operations/OperationsRevisionsTab.svelte +++ b/web/src/lib/components/operations/OperationsRevisionsTab.svelte @@ -13,6 +13,7 @@ import RefreshCw from '@lucide/svelte/icons/refresh-cw'; import Undo from '@lucide/svelte/icons/undo'; import Eye from '@lucide/svelte/icons/eye'; + import Download from '@lucide/svelte/icons/download'; type Props = { revisions: RevisionRow[]; @@ -20,10 +21,19 @@ onReload: () => void; onOpenPreview: (rev: RevisionRow) => void; onRollbackRequest: (rev: RevisionRow) => void; + onDownloadDiagnosticLog: (rev: RevisionRow) => void; formatDate: (d?: string | null) => string; }; - let { revisions, revLoading, onReload, onOpenPreview, onRollbackRequest, formatDate }: Props = $props(); + let { + revisions, + revLoading, + onReload, + onOpenPreview, + onRollbackRequest, + onDownloadDiagnosticLog, + formatDate + }: Props = $props(); @@ -65,6 +75,14 @@ + diff --git a/web/src/routes/operations/+page.svelte b/web/src/routes/operations/+page.svelte index 947e45c..12c4184 100644 --- a/web/src/routes/operations/+page.svelte +++ b/web/src/routes/operations/+page.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import { + apiFetch, apiJSON, apiMutate, fetchModuleSourceCatalog, @@ -172,6 +173,47 @@ } } + function parseDiagnosticLogFilename(contentDisposition: string | null, rev: RevisionRow): string { + const fallback = `revision-${rev.id.slice(0, 8)}-diagnostic.log`; + if (!contentDisposition) return fallback; + const utf8Match = contentDisposition.match(/filename\*\s*=\s*UTF-8''([^;]+)/i); + if (utf8Match?.[1]) { + try { + return decodeURIComponent(utf8Match[1]); + } catch { + return utf8Match[1]; + } + } + const quotedMatch = contentDisposition.match(/filename\s*=\s*"([^"]+)"/i); + if (quotedMatch?.[1]) return quotedMatch[1]; + const plainMatch = contentDisposition.match(/filename\s*=\s*([^;]+)/i); + if (plainMatch?.[1]) return plainMatch[1].trim(); + return fallback; + } + + async function downloadRevisionDiagnosticLog(rev: RevisionRow) { + try { + const res = await apiFetch(`/v1/revisions/${rev.id}/diagnostic-log`, { method: 'GET' }); + if (!res.ok) { + const message = (await res.text()) || `HTTP ${res.status}`; + throw new Error(`Не удалось скачать диагностический лог: ${message}`); + } + const blob = await res.blob(); + const objectUrl = URL.createObjectURL(blob); + const filename = parseDiagnosticLogFilename(res.headers.get('Content-Disposition'), rev); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = filename; + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(objectUrl); + } catch (e) { + toast.error(e instanceof Error ? e.message : String(e)); + } + } + async function loadJobs() { jobsLoading = true; try { @@ -730,6 +772,7 @@ onReload={loadRevisions} onOpenPreview={openPreview} onRollbackRequest={(rev) => (rollbackTarget = rev)} + onDownloadDiagnosticLog={downloadRevisionDiagnosticLog} {formatDate} />