feat: enhance job processing with detailed logging and UI improvements. Introduce revision log entries in the Worker to capture job metadata, including source and community details. Update the Svelte UI to display job logs with expandable details, improving user experience and job tracking capabilities.
CI / changes (push) Successful in 7s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 27s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m7s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m7s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 16s
CI / docker-go-prime (push) Successful in 23s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 1m1s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 2m13s
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 1m22s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m23s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m3s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m15s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m16s

This commit is contained in:
Denozordec
2026-04-06 17:10:29 +07:00
parent 678bd27085
commit c48cc4e2d4
2 changed files with 368 additions and 13 deletions
+169 -5
View File
@@ -2,8 +2,11 @@ package jobs
import (
"context"
"fmt"
"net/http"
"net/netip"
"os"
"sort"
"strings"
"time"
@@ -37,20 +40,29 @@ func mergeBirdPostApplyMeta(j *Job) {
}
const (
KindModuleRefresh = "module_refresh"
KindDeployApply = "deploy_apply"
KindModuleRefresh = "module_refresh"
KindDeployApply = "deploy_apply"
KindRevisionRollback = "revision_rollback"
KindBirdReload = "bird_reload"
KindBirdReload = "bird_reload"
)
// Worker executes queued jobs against store.Backend (memory or SQL).
type Worker struct {
Store store.Backend
HTTPClient *http.Client // optional; CDN refresh uses this (default 45s timeout).
Store store.Backend
HTTPClient *http.Client // optional; CDN refresh uses this (default 45s timeout).
// Registry is set after BootstrapWorkers creates the job queue; used to chain deploy_apply after refresh/rollback.
Registry *Registry
}
type revisionLogEntry struct {
Kind string `json:"kind"`
Source string `json:"source"`
Community string `json:"community"`
PrefixCount int `json:"prefix_count"`
Sample []string `json:"sample,omitempty"`
Message string `json:"message"`
}
var defaultWorkerHTTP = &http.Client{Timeout: 45 * time.Second}
func (w *Worker) httpClient() *http.Client {
@@ -90,6 +102,15 @@ func (w *Worker) Process(j *Job) {
return
}
j.mergeMeta(map[string]any{"revision_id": rev})
if entries, total, err := w.buildRevisionLogEntries(j.TenantID, rev); err == nil {
j.mergeMeta(map[string]any{
"log_entries": entries,
"log_total": total,
"log_generated": time.Now().UTC().Format(time.RFC3339Nano),
})
} else {
j.mergeMeta(map[string]any{"log_build_error": err.Error()})
}
w.enqueueDeployAllSpeakers(j, j.TenantID, rev)
j.Succeed()
case KindDeployApply:
@@ -169,6 +190,7 @@ func (w *Worker) runDeployApply(j *Job) {
return
}
}
applied := make([]string, 0, 8)
applyOne := func(speakerID string) error {
if err := w.Store.SetLastAppliedRevision(j.TenantID, speakerID, revID); err != nil {
return err
@@ -177,6 +199,7 @@ func (w *Worker) runDeployApply(j *Job) {
if err := w.Store.PublishRevisionForSpeaker(speakerID, revID); err != nil {
return err
}
applied = append(applied, speakerID)
return nil
}
if hasSpeaker && spk != "" {
@@ -194,6 +217,14 @@ func (w *Worker) runDeployApply(j *Job) {
return
}
}
j.mergeMeta(map[string]any{
"apply_summary": map[string]any{
"revision_id": revID,
"speakers_count": len(applied),
"speaker_ids": applied,
"message": fmt.Sprintf("Ревизия %s применена на %d спикерах", shortID(revID), len(applied)),
},
})
mergeBirdPostApplyMeta(j)
j.Succeed()
}
@@ -210,6 +241,139 @@ func (w *Worker) runRollback(j *Job) {
return
}
j.mergeMeta(map[string]any{"new_revision_id": newID})
j.mergeMeta(map[string]any{
"rollback_summary": map[string]any{
"source_revision_id": src,
"new_revision_id": newID,
"message": fmt.Sprintf("Rollback %s -> %s", shortID(src), shortID(newID)),
},
})
w.enqueueDeployAllSpeakers(j, j.TenantID, newID)
j.Succeed()
}
func (w *Worker) buildRevisionLogEntries(tenantID, revID string) ([]map[string]any, int, error) {
if w == nil || w.Store == nil {
return nil, 0, fmt.Errorf("store not configured")
}
var all []store.PrefixRow
cursor := ""
for {
rows, next, more := w.Store.ListRevisionPrefixes(tenantID, revID, cursor, 1000)
all = append(all, rows...)
if !more {
break
}
cursor = next
if strings.TrimSpace(cursor) == "" {
break
}
}
type agg struct {
kind string
source string
community string
count int
sample []string
}
groups := map[string]*agg{}
for _, p := range all {
src := strings.TrimSpace(p.Source)
comm := "none"
if p.CommunityID != nil && strings.TrimSpace(*p.CommunityID) != "" {
comm = strings.TrimSpace(*p.CommunityID)
}
kind, sourceName := classifySource(src)
k := kind + "|" + sourceName + "|" + comm
g, ok := groups[k]
if !ok {
g = &agg{kind: kind, source: sourceName, community: comm}
groups[k] = g
}
g.count++
if len(g.sample) < 5 {
g.sample = append(g.sample, p.Prefix)
}
}
keys := make([]string, 0, len(groups))
for k := range groups {
keys = append(keys, k)
}
sort.Strings(keys)
out := make([]map[string]any, 0, len(keys))
for _, k := range keys {
g := groups[k]
msg := humanLogMessage(g.kind, g.source, g.count, g.community, g.sample)
out = append(out, map[string]any{
"kind": g.kind,
"source": g.source,
"community": g.community,
"prefix_count": g.count,
"sample": g.sample,
"message": msg,
})
}
return out, len(all), nil
}
func classifySource(src string) (kind, name 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 src == "" {
return "unknown", "unknown"
}
return "source", src
}
}
func humanLogMessage(kind, source string, count int, community string, sample []string) string {
switch kind {
case "asn":
return fmt.Sprintf("AS%s -> %d префиксов добавлены в community %s", source, count, community)
case "domain":
ips := strings.Join(prettyDomainSample(sample), " ")
if ips == "" {
ips = "-"
}
return fmt.Sprintf("%s -> ip (%s) -> добавлены в community %s", source, ips, community)
case "cdn":
return fmt.Sprintf("CDN source %s -> %d префиксов добавлены в community %s", source, count, community)
case "ip_range":
return fmt.Sprintf("IP ranges -> %d префиксов добавлены в community %s", count, community)
default:
return fmt.Sprintf("%s -> %d префиксов добавлены в community %s", source, count, community)
}
}
func prettyDomainSample(sample []string) []string {
out := make([]string, 0, len(sample))
for _, s := range sample {
p, err := netip.ParsePrefix(strings.TrimSpace(s))
if err != nil {
out = append(out, s)
continue
}
if (p.Addr().Is4() && p.Bits() == 32) || (p.Addr().Is6() && p.Bits() == 128) {
out = append(out, p.Addr().String())
continue
}
out = append(out, s)
}
return out
}
func shortID(id string) string {
s := strings.TrimSpace(id)
if len(s) <= 8 {
return s
}
return s[:8]
}
+199 -8
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { apiJSON, apiMutate, waitForJob } from '$lib/api/client.js';
import type {
BirdStatus,
@@ -58,6 +59,7 @@
import X from '@lucide/svelte/icons/x';
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
import Bird from '@lucide/svelte/icons/bird';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
// Revisions
let revisions = $state<RevisionRow[]>([]);
@@ -104,6 +106,16 @@
let cancelling = $state(false);
let jobDetailDialog = $state(false);
let jobDetail = $state<JobRow | null>(null);
let expandedJobIds = new SvelteSet<string>();
type JobLogEntry = {
kind: string;
source: string;
community: string;
prefix_count: number;
sample?: string[];
message: string;
};
// Global apply / bird reload
let applying = $state(false);
@@ -311,6 +323,72 @@
jobDetailDialog = true;
}
function isJobExpanded(jobId: string): boolean {
return expandedJobIds.has(jobId);
}
function toggleJobExpanded(jobId: string) {
if (expandedJobIds.has(jobId)) {
expandedJobIds.delete(jobId);
} else {
expandedJobIds.add(jobId);
}
}
function asMetaRecord(meta: unknown): Record<string, unknown> | null {
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return null;
return meta as Record<string, unknown>;
}
function getJobLogEntries(job: JobRow): JobLogEntry[] {
const meta = asMetaRecord(job.meta);
if (!meta) return [];
const rawEntries = meta.log_entries;
if (!Array.isArray(rawEntries)) return [];
const parsed: JobLogEntry[] = [];
for (const item of rawEntries) {
const row = asMetaRecord(item);
if (!row) continue;
const kind = row.kind;
const source = row.source;
const community = row.community;
const prefixCount = row.prefix_count;
const message = row.message;
if (
typeof kind !== 'string' ||
typeof source !== 'string' ||
typeof community !== 'string' ||
typeof prefixCount !== 'number' ||
!Number.isFinite(prefixCount) ||
typeof message !== 'string'
) {
continue;
}
const sample = Array.isArray(row.sample)
? row.sample.filter((value): value is string => typeof value === 'string')
: undefined;
parsed.push({
kind,
source,
community,
prefix_count: prefixCount,
message,
...(sample && sample.length > 0 ? { sample } : {})
});
}
return parsed;
}
function getJobLogTotal(job: JobRow, entries?: JobLogEntry[]): number {
const meta = asMetaRecord(job.meta);
const rawTotal = meta?.log_total;
if (typeof rawTotal === 'number' && Number.isFinite(rawTotal)) {
return rawTotal;
}
const safeEntries = entries ?? getJobLogEntries(job);
return safeEntries.reduce((acc, entry) => acc + entry.prefix_count, 0);
}
function jobStatusVariant(status: string): 'default' | 'secondary' | 'outline' | 'destructive' {
if (status === 'succeeded') return 'default';
if (status === 'running') return 'secondary';
@@ -549,25 +627,138 @@
<TableHead>Статус</TableHead>
<TableHead>Создана</TableHead>
<TableHead>Завершена</TableHead>
<TableHead class="w-24"></TableHead>
<TableHead class="w-32"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each jobs as job (job.job_id)}
<TableRow class="cursor-pointer" onclick={() => openJobDetail(job)}>
<TableCell class="font-medium">{job.kind}</TableCell>
<TableRow>
<TableCell class="font-medium">
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="icon-sm"
class="text-muted-foreground hover:text-foreground"
aria-label={isJobExpanded(job.job_id) ? 'Свернуть детали задачи' : 'Развернуть детали задачи'}
aria-expanded={isJobExpanded(job.job_id)}
onclick={(e) => {
e.stopPropagation();
toggleJobExpanded(job.job_id);
}}
>
<ChevronDown class={cn('size-3.5 transition-transform', isJobExpanded(job.job_id) && 'rotate-180')} />
</Button>
<span>{job.kind}</span>
</div>
</TableCell>
<TableCell><Badge variant={jobStatusVariant(job.status)}>{job.status}</Badge></TableCell>
<TableCell class="text-sm">{formatDate(job.created_at)}</TableCell>
<TableCell class="text-sm">{formatDate(job.finished_at)}</TableCell>
<TableCell>
{#if job.status === 'running' || job.status === 'queued'}
<Button variant="ghost" size="icon-sm" class="text-destructive"
onclick={(e) => { e.stopPropagation(); cancelTarget = job; }}>
<X class="size-3.5" />
<div class="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon-sm"
onclick={(e) => {
e.stopPropagation();
openJobDetail(job);
}}
aria-label="Открыть задачу в модальном окне"
>
<Eye class="size-3.5" />
</Button>
{/if}
{#if job.status === 'running' || job.status === 'queued'}
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={(e) => {
e.stopPropagation();
cancelTarget = job;
}}
aria-label="Отменить задачу"
>
<X class="size-3.5" />
</Button>
{/if}
</div>
</TableCell>
</TableRow>
{#if isJobExpanded(job.job_id)}
<TableRow class="bg-muted/20 hover:bg-muted/30">
<TableCell colspan={5}>
{@const logEntries = getJobLogEntries(job)}
{@const logTotal = getJobLogTotal(job, logEntries)}
<div class="space-y-3 py-2">
<div class="grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3">
<div class="space-y-1">
<p class="text-muted-foreground text-xs">job_id</p>
<p class="font-mono break-all text-xs">{job.job_id}</p>
</div>
<div class="space-y-1">
<p class="text-muted-foreground text-xs">status</p>
<Badge variant={jobStatusVariant(job.status)}>{job.status}</Badge>
</div>
<div class="space-y-1">
<p class="text-muted-foreground text-xs">created</p>
<p>{formatDate(job.created_at)}</p>
</div>
<div class="space-y-1">
<p class="text-muted-foreground text-xs">started</p>
<p>{formatDate(job.started_at)}</p>
</div>
<div class="space-y-1">
<p class="text-muted-foreground text-xs">finished</p>
<p>{formatDate(job.finished_at)}</p>
</div>
</div>
{#if job.error}
<div class="rounded-md border border-destructive/30 bg-destructive/5 p-3">
<p class="text-muted-foreground mb-1 text-xs">error</p>
<p class="text-destructive break-words text-sm">{job.error}</p>
</div>
{/if}
{#if logEntries.length > 0}
<div class="space-y-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm font-medium">Журнал обработки</p>
<p class="text-muted-foreground rounded-md border bg-muted/50 px-2 py-0.5 text-xs">
{logEntries.length} записей, всего {logTotal} префиксов
</p>
</div>
<div class="space-y-2">
{#each logEntries as entry, idx (`${job.job_id}-${idx}`)}
<div class="rounded-md border bg-muted/30 p-3">
<p class="break-words text-sm">{entry.message}</p>
<p class="text-muted-foreground mt-1 text-xs">
source: <span class="font-mono">{entry.source}</span>
·
kind: <span class="font-mono">{entry.kind}</span>
·
community: <span class="font-mono">{entry.community}</span>
·
prefixes: <span class="font-mono">{entry.prefix_count}</span>
</p>
{#if entry.sample && entry.sample.length > 0}
<p class="bg-background mt-2 break-all rounded border px-2 py-1 font-mono text-xs">
{entry.sample.join(', ')}
</p>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<div class="space-y-1">
<p class="text-muted-foreground text-xs">Meta (raw JSON)</p>
<ScrollArea class="max-h-56 rounded-md border bg-muted/30 p-3">
<pre class="font-mono text-xs whitespace-pre-wrap">{JSON.stringify(job.meta ?? {}, null, 2)}</pre>
</ScrollArea>
</div>
</div>
</TableCell>
</TableRow>
{/if}
{:else}
<TableRow>
<TableCell colspan={5} class="text-muted-foreground text-center py-8">