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", s.handleListRevisions)
|
||||||
m.HandleFunc("GET /revisions/{revision_id}", s.handleGetRevision)
|
m.HandleFunc("GET /revisions/{revision_id}", s.handleGetRevision)
|
||||||
m.HandleFunc("GET /revisions/{revision_id}/preview", s.handleRevisionPreview)
|
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("GET /revisions/{revision_a}/diff/{revision_b}", s.handleRevisionDiff)
|
||||||
m.HandleFunc("POST /revisions/{revision_id}/rollback", s.handleRevisionRollback)
|
m.HandleFunc("POST /revisions/{revision_id}/rollback", s.handleRevisionRollback)
|
||||||
m.HandleFunc("POST /apply", s.handleApply)
|
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)
|
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) {
|
func (s *Server) handleRevisionDiff(w http.ResponseWriter, r *http.Request) {
|
||||||
a, ok := authFromContext(r.Context())
|
a, ok := authFromContext(r.Context())
|
||||||
if !ok {
|
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) {
|
t.Run("list modules peers speakers", func(t *testing.T) {
|
||||||
for _, path := range []string{"/v1/modules", "/v1/peers", "/v1/speakers"} {
|
for _, path := range []string{"/v1/modules", "/v1/peers", "/v1/speakers"} {
|
||||||
req, _ := http.NewRequest(http.MethodGet, base+path, nil)
|
req, _ := http.NewRequest(http.MethodGet, base+path, nil)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
import Undo from '@lucide/svelte/icons/undo';
|
import Undo from '@lucide/svelte/icons/undo';
|
||||||
import Eye from '@lucide/svelte/icons/eye';
|
import Eye from '@lucide/svelte/icons/eye';
|
||||||
|
import Download from '@lucide/svelte/icons/download';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
revisions: RevisionRow[];
|
revisions: RevisionRow[];
|
||||||
@@ -20,10 +21,19 @@
|
|||||||
onReload: () => void;
|
onReload: () => void;
|
||||||
onOpenPreview: (rev: RevisionRow) => void;
|
onOpenPreview: (rev: RevisionRow) => void;
|
||||||
onRollbackRequest: (rev: RevisionRow) => void;
|
onRollbackRequest: (rev: RevisionRow) => void;
|
||||||
|
onDownloadDiagnosticLog: (rev: RevisionRow) => void;
|
||||||
formatDate: (d?: string | null) => string;
|
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>
|
</script>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -65,6 +75,14 @@
|
|||||||
<Button variant="ghost" size="icon-sm" onclick={() => onRollbackRequest(rev)}>
|
<Button variant="ghost" size="icon-sm" onclick={() => onRollbackRequest(rev)}>
|
||||||
<Undo class="size-3.5" />
|
<Undo class="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
title="Скачать диагностический лог"
|
||||||
|
onclick={() => onDownloadDiagnosticLog(rev)}
|
||||||
|
>
|
||||||
|
<Download class="size-3.5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
import {
|
import {
|
||||||
|
apiFetch,
|
||||||
apiJSON,
|
apiJSON,
|
||||||
apiMutate,
|
apiMutate,
|
||||||
fetchModuleSourceCatalog,
|
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() {
|
async function loadJobs() {
|
||||||
jobsLoading = true;
|
jobsLoading = true;
|
||||||
try {
|
try {
|
||||||
@@ -730,6 +772,7 @@
|
|||||||
onReload={loadRevisions}
|
onReload={loadRevisions}
|
||||||
onOpenPreview={openPreview}
|
onOpenPreview={openPreview}
|
||||||
onRollbackRequest={(rev) => (rollbackTarget = rev)}
|
onRollbackRequest={(rev) => (rollbackTarget = rev)}
|
||||||
|
onDownloadDiagnosticLog={downloadRevisionDiagnosticLog}
|
||||||
{formatDate}
|
{formatDate}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user