feat: add diagnostic log endpoint for revisions and implement download functionality
CI / changes (push) Successful in 7s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 38s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m3s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m2s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 18s
CI / docker-go-prime (push) Successful in 22s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 59s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 3m10s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m22s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m20s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m20s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m6s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m22s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m21s
CI / changes (push) Successful in 7s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 38s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m3s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m2s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 18s
CI / docker-go-prime (push) Successful in 22s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 59s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 3m10s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m22s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m20s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m20s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m6s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m22s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m21s
Introduced a new endpoint `GET /revisions/{revision_id}/diagnostic-log` to retrieve detailed diagnostic logs for specific revisions. Implemented the corresponding handler to generate and return the log in a structured format. Additionally, updated the frontend to include a download button for the diagnostic log, enhancing user accessibility to revision data.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
@@ -65,6 +75,14 @@
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => onRollbackRequest(rev)}>
|
||||
<Undo class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Скачать диагностический лог"
|
||||
onclick={() => onDownloadDiagnosticLog(rev)}
|
||||
>
|
||||
<Download class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
Reference in New Issue
Block a user