feat: enhance community label handling and improve UI localization across operations pages. Introduce community label mapping for better readability in job logs and update various components to reflect Russian translations, enhancing user experience and consistency in the interface.
CI / changes (push) Successful in 6s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 43s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m8s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m9s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 17s
CI / docker-go-prime (push) Successful in 23s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 1m5s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 2m29s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m31s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m27s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m27s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m15s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m28s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m30s

This commit is contained in:
Denozordec
2026-04-07 00:58:15 +07:00
parent e1c4cbce93
commit 53fd527a6d
16 changed files with 338 additions and 128 deletions
+62 -21
View File
@@ -58,12 +58,13 @@ type Worker struct {
} }
type revisionLogEntry struct { type revisionLogEntry struct {
Kind string `json:"kind"` Kind string `json:"kind"`
Source string `json:"source"` Source string `json:"source"`
Community string `json:"community"` Community string `json:"community"`
PrefixCount int `json:"prefix_count"` CommunityLabel string `json:"community_label"`
Sample []string `json:"sample,omitempty"` PrefixCount int `json:"prefix_count"`
Message string `json:"message"` Sample []string `json:"sample,omitempty"`
Message string `json:"message"`
} }
var defaultWorkerHTTP = &http.Client{Timeout: 45 * time.Second} var defaultWorkerHTTP = &http.Client{Timeout: 45 * time.Second}
@@ -275,17 +276,54 @@ func (w *Worker) runRollback(j *Job) {
"rollback_summary": map[string]any{ "rollback_summary": map[string]any{
"source_revision_id": src, "source_revision_id": src,
"new_revision_id": newID, "new_revision_id": newID,
"message": fmt.Sprintf("Rollback %s -> %s", shortID(src), shortID(newID)), "message": fmt.Sprintf("Откат %s %s", shortID(src), shortID(newID)),
}, },
}) })
w.enqueueDeployAllSpeakers(j, j.TenantID, newID) w.enqueueDeployAllSpeakers(j, j.TenantID, newID)
j.Succeed() j.Succeed()
} }
// buildCommunityLabelMap maps community UUID -> human-readable title (or BGP community string).
func buildCommunityLabelMap(st store.Backend, tenantID string) map[string]string {
out := make(map[string]string)
if st == nil {
return out
}
list, err := st.ListCommunities(tenantID)
if err != nil || list == nil {
return out
}
for _, c := range list {
if c == nil {
continue
}
label := strings.TrimSpace(c.Title)
if label == "" {
label = strings.TrimSpace(c.Community)
}
if label == "" {
label = c.ID
}
out[c.ID] = label
}
return out
}
func resolveCommunityLabel(commID string, byID map[string]string) string {
if commID == "" || commID == "none" {
return "без community"
}
if lbl, ok := byID[commID]; ok && strings.TrimSpace(lbl) != "" {
return strings.TrimSpace(lbl)
}
return commID
}
func (w *Worker) buildRevisionLogEntries(tenantID, revID string) ([]map[string]any, int, error) { func (w *Worker) buildRevisionLogEntries(tenantID, revID string) ([]map[string]any, int, error) {
if w == nil || w.Store == nil { if w == nil || w.Store == nil {
return nil, 0, fmt.Errorf("store not configured") return nil, 0, fmt.Errorf("store not configured")
} }
commLabels := buildCommunityLabelMap(w.Store, tenantID)
var all []store.PrefixRow var all []store.PrefixRow
cursor := "" cursor := ""
for { for {
@@ -333,14 +371,16 @@ func (w *Worker) buildRevisionLogEntries(tenantID, revID string) ([]map[string]a
out := make([]map[string]any, 0, len(keys)) out := make([]map[string]any, 0, len(keys))
for _, k := range keys { for _, k := range keys {
g := groups[k] g := groups[k]
msg := humanLogMessage(g.kind, g.source, g.count, g.community, g.sample) cl := resolveCommunityLabel(g.community, commLabels)
msg := humanLogMessage(g.kind, g.source, g.count, cl, g.sample)
out = append(out, map[string]any{ out = append(out, map[string]any{
"kind": g.kind, "kind": g.kind,
"source": g.source, "source": g.source,
"community": g.community, "community": g.community,
"prefix_count": g.count, "community_label": cl,
"sample": g.sample, "prefix_count": g.count,
"message": msg, "sample": g.sample,
"message": msg,
}) })
} }
return out, len(all), nil return out, len(all), nil
@@ -364,22 +404,23 @@ func classifySource(src string) (kind, name string) {
} }
} }
func humanLogMessage(kind, source string, count int, community string, sample []string) string { // humanLogMessage builds a Russian log line; communityLabel is already resolved (title or BGP value).
func humanLogMessage(kind, source string, count int, communityLabel string, sample []string) string {
switch kind { switch kind {
case "asn": case "asn":
return fmt.Sprintf("AS%s -> %d префиксов добавлены в community %s", source, count, community) return fmt.Sprintf("AS%s: добавлено %d префиксов в сообщество «%s»", source, count, communityLabel)
case "domain": case "domain":
ips := strings.Join(prettyDomainSample(sample), " ") ips := strings.Join(prettyDomainSample(sample), " ")
if ips == "" { if ips == "" {
ips = "-" ips = ""
} }
return fmt.Sprintf("%s -> ip (%s) -> добавлены в community %s", source, ips, community) return fmt.Sprintf("%s: IP (%s) добавлено в сообщество «%s»", source, ips, communityLabel)
case "cdn": case "cdn":
return fmt.Sprintf("CDN source %s -> %d префиксов добавлены в community %s", source, count, community) return fmt.Sprintf("CDN «%s»: добавлено %d префиксов в сообщество «%s»", source, count, communityLabel)
case "ip_range": case "ip_range":
return fmt.Sprintf("IP ranges -> %d префиксов добавлены в community %s", count, community) return fmt.Sprintf("Статические диапазоны: добавлено %d префиксов в сообщество «%s»", count, communityLabel)
default: default:
return fmt.Sprintf("%s -> %d префиксов добавлены в community %s", source, count, community) return fmt.Sprintf("%s: добавлено %d префиксов в сообщество «%s»", source, count, communityLabel)
} }
} }
+11 -5
View File
@@ -70,7 +70,7 @@
{#if !collapsed} {#if !collapsed}
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<a href={resolve('/')} class="text-sidebar-foreground block truncate font-semibold tracking-tight">EvoBGP</a> <a href={resolve('/')} class="text-sidebar-foreground block truncate font-semibold tracking-tight">EvoBGP</a>
<p class="text-sidebar-foreground/50 truncate text-xs">Control plane</p> <p class="text-sidebar-foreground/50 truncate text-xs">Панель управления</p>
</div> </div>
{/if} {/if}
<DropdownMenu> <DropdownMenu>
@@ -133,7 +133,8 @@
href={resolve(item.href)} href={resolve(item.href)}
class={cn( class={cn(
buttonVariants({ variant: active ? 'secondary' : 'ghost', size: 'icon-sm' }), buttonVariants({ variant: active ? 'secondary' : 'ghost', size: 'icon-sm' }),
'w-full no-underline flex items-center justify-center' 'w-full no-underline flex items-center justify-center',
active && 'ring-sidebar-primary/50 bg-sidebar-accent/90 text-sidebar-accent-foreground ring-1'
)} )}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
> >
@@ -147,7 +148,9 @@
href={resolve(item.href)} href={resolve(item.href)}
class={cn( class={cn(
buttonVariants({ variant: active ? 'secondary' : 'ghost', size: 'sm' }), buttonVariants({ variant: active ? 'secondary' : 'ghost', size: 'sm' }),
'w-full justify-start gap-2 no-underline' 'w-full justify-start gap-2 no-underline',
active &&
'border-l-sidebar-primary bg-sidebar-accent/80 text-sidebar-accent-foreground border-l-2 shadow-sm'
)} )}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
> >
@@ -171,7 +174,8 @@
href={resolve(item.href)} href={resolve(item.href)}
class={cn( class={cn(
buttonVariants({ variant: active ? 'secondary' : 'ghost', size: 'icon-sm' }), buttonVariants({ variant: active ? 'secondary' : 'ghost', size: 'icon-sm' }),
'w-full no-underline flex items-center justify-center' 'w-full no-underline flex items-center justify-center',
active && 'ring-sidebar-primary/50 bg-sidebar-accent/90 text-sidebar-accent-foreground ring-1'
)} )}
> >
<Icon class="size-4" /> <Icon class="size-4" />
@@ -184,7 +188,9 @@
href={resolve(item.href)} href={resolve(item.href)}
class={cn( class={cn(
buttonVariants({ variant: active ? 'secondary' : 'ghost', size: 'sm' }), buttonVariants({ variant: active ? 'secondary' : 'ghost', size: 'sm' }),
'w-full justify-start gap-2 no-underline' 'w-full justify-start gap-2 no-underline',
active &&
'border-l-sidebar-primary bg-sidebar-accent/80 text-sidebar-accent-foreground border-l-2 shadow-sm'
)} )}
> >
<Icon class="size-4" /> <Icon class="size-4" />
@@ -5,6 +5,7 @@
import Filter from '@lucide/svelte/icons/filter'; import Filter from '@lucide/svelte/icons/filter';
import Search from '@lucide/svelte/icons/search'; import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import { jobKindFilterRu, jobStatusRu } from '$lib/ui-labels.js';
type Props = { type Props = {
searchQ: string; searchQ: string;
@@ -42,19 +43,19 @@
const statusOptions: { value: string; label: string }[] = [ const statusOptions: { value: string; label: string }[] = [
{ value: '', label: 'Все статусы' }, { value: '', label: 'Все статусы' },
{ value: 'queued', label: 'queued' }, { value: 'queued', label: jobStatusRu('queued') },
{ value: 'running', label: 'running' }, { value: 'running', label: jobStatusRu('running') },
{ value: 'succeeded', label: 'succeeded' }, { value: 'succeeded', label: jobStatusRu('succeeded') },
{ value: 'failed', label: 'failed' }, { value: 'failed', label: jobStatusRu('failed') },
{ value: 'cancelled', label: 'cancelled' } { value: 'cancelled', label: jobStatusRu('cancelled') }
]; ];
const kindOptions: { value: string; label: string }[] = [ const kindOptions: { value: string; label: string }[] = [
{ value: '', label: 'Все типы' }, { value: '', label: 'Все типы' },
{ value: 'module_refresh', label: 'module_refresh' }, { value: 'module_refresh', label: jobKindFilterRu('module_refresh') },
{ value: 'deploy_apply', label: 'deploy_apply' }, { value: 'deploy_apply', label: jobKindFilterRu('deploy_apply') },
{ value: 'revision_rollback', label: 'revision_rollback' }, { value: 'revision_rollback', label: jobKindFilterRu('revision_rollback') },
{ value: 'bird_reload', label: 'bird_reload' } { value: 'bird_reload', label: jobKindFilterRu('bird_reload') }
]; ];
</script> </script>
@@ -109,7 +110,7 @@
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="job-filter-status" class="text-xs">Статус (API)</Label> <Label for="job-filter-status" class="text-xs">Статус</Label>
<select <select
id="job-filter-status" id="job-filter-status"
class="border-border bg-background h-9 w-full rounded-md border px-2 text-sm" class="border-border bg-background h-9 w-full rounded-md border px-2 text-sm"
@@ -123,7 +124,7 @@
</select> </select>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="job-filter-kind" class="text-xs">Тип задачи (API)</Label> <Label for="job-filter-kind" class="text-xs">Тип задачи</Label>
<select <select
id="job-filter-kind" id="job-filter-kind"
class="border-border bg-background h-9 w-full rounded-md border px-2 text-sm" class="border-border bg-background h-9 w-full rounded-md border px-2 text-sm"
@@ -25,6 +25,7 @@
import Link2 from '@lucide/svelte/icons/link-2'; import Link2 from '@lucide/svelte/icons/link-2';
import { cn } from '$lib/utils.js'; import { cn } from '$lib/utils.js';
import { jobKindSubtitle, jobKindTitle } from '$lib/operations/job-kind-label.js'; import { jobKindSubtitle, jobKindTitle } from '$lib/operations/job-kind-label.js';
import { jobStatusRu, logKindRu, moduleTypeRu } from '$lib/ui-labels.js';
import JobReportTableBlock from './job-report-table-block.svelte'; import JobReportTableBlock from './job-report-table-block.svelte';
import { asnReportColumns, reportRowColumns } from './job-report-columns.js'; import { asnReportColumns, reportRowColumns } from './job-report-columns.js';
import type { RowData } from '@tanstack/table-core'; import type { RowData } from '@tanstack/table-core';
@@ -84,7 +85,7 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<CardTitle class="text-base">Задачи</CardTitle> <CardTitle class="text-base">Задачи</CardTitle>
<CardDescription class="flex flex-wrap items-center gap-x-2 gap-y-1"> <CardDescription class="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>Фоновые задачи (ingest, apply, refresh)</span> <span>Фоновые задачи (ingest, применение, обновление)</span>
{#if jobsFetchedTotal !== undefined} {#if jobsFetchedTotal !== undefined}
<span class="text-muted-foreground font-normal tabular-nums"> <span class="text-muted-foreground font-normal tabular-nums">
· Показано {jobs.length} из {jobsFetchedTotal} · Показано {jobs.length} из {jobsFetchedTotal}
@@ -168,7 +169,7 @@
<CircleDot class="size-3.5 shrink-0" aria-hidden="true" /> <CircleDot class="size-3.5 shrink-0" aria-hidden="true" />
Статус Статус
</p> </p>
<Badge class="mt-1" variant={jobStatusVariant(job.status)}>{job.status}</Badge> <Badge class="mt-1" variant={jobStatusVariant(job.status)}>{jobStatusRu(job.status)}</Badge>
</div> </div>
<div <div
class="rounded-md border border-chart-2/25 bg-chart-2/5 px-2.5 py-2 dark:bg-chart-2/10" class="rounded-md border border-chart-2/25 bg-chart-2/5 px-2.5 py-2 dark:bg-chart-2/10"
@@ -221,7 +222,7 @@
{#if detailedJob.error} {#if detailedJob.error}
<div class="rounded-md border border-destructive/30 bg-destructive/5 p-3"> <div class="rounded-md border border-destructive/30 bg-destructive/5 p-3">
<p class="mb-1 text-xs text-muted-foreground">error</p> <p class="mb-1 text-xs text-muted-foreground">Ошибка</p>
<p class="text-sm break-words text-destructive">{detailedJob.error}</p> <p class="text-sm break-words text-destructive">{detailedJob.error}</p>
</div> </div>
{/if} {/if}
@@ -249,7 +250,7 @@
<div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2"> <div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2">
<span <span
class="text-[11px] tracking-wide text-muted-foreground uppercase" class="text-[11px] tracking-wide text-muted-foreground uppercase"
>source</span >Источник</span
> >
<p <p
class="mt-1 rounded bg-background px-1.5 py-0.5 font-mono text-xs break-all" class="mt-1 rounded bg-background px-1.5 py-0.5 font-mono text-xs break-all"
@@ -260,29 +261,30 @@
<div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2"> <div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2">
<span <span
class="text-[11px] tracking-wide text-muted-foreground uppercase" class="text-[11px] tracking-wide text-muted-foreground uppercase"
>kind</span >Тип</span
> >
<p <p
class="mt-1 rounded bg-background px-1.5 py-0.5 font-mono text-xs break-all" class="mt-1 rounded bg-background px-1.5 py-0.5 font-mono text-xs break-all"
> >
{entry.kind} {logKindRu(entry.kind)}
</p> </p>
</div> </div>
<div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2"> <div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2">
<span <span
class="text-[11px] tracking-wide text-muted-foreground uppercase" class="text-[11px] tracking-wide text-muted-foreground uppercase"
>community</span >Сообщество BGP</span
> >
<p <p
class="mt-1 rounded bg-background px-1.5 py-0.5 font-mono text-xs break-all" class="mt-1 rounded bg-background px-1.5 py-0.5 text-xs break-words"
title={entry.community !== 'none' ? entry.community : undefined}
> >
{entry.community} {entry.community_label?.trim() || entry.community}
</p> </p>
</div> </div>
<div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2"> <div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2">
<span <span
class="text-[11px] tracking-wide text-muted-foreground uppercase" class="text-[11px] tracking-wide text-muted-foreground uppercase"
>prefixes</span >Префиксы</span
> >
<p class="mt-1 rounded bg-background px-1.5 py-0.5 font-mono text-xs"> <p class="mt-1 rounded bg-background px-1.5 py-0.5 font-mono text-xs">
{entry.prefix_count} {entry.prefix_count}
@@ -292,7 +294,7 @@
{#if entry.sample && entry.sample.length > 0} {#if entry.sample && entry.sample.length > 0}
<div class="space-y-1.5"> <div class="space-y-1.5">
<p class="text-[11px] tracking-wide text-muted-foreground uppercase"> <p class="text-[11px] tracking-wide text-muted-foreground uppercase">
sample Примеры
</p> </p>
<div class="rounded-md border bg-muted/35 p-2.5"> <div class="rounded-md border bg-muted/35 p-2.5">
<div class="space-y-1.5"> <div class="space-y-1.5">
@@ -326,7 +328,7 @@
<p class="text-sm font-medium">Операции по модулю</p> <p class="text-sm font-medium">Операции по модулю</p>
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
{#if jobReport.module} {#if jobReport.module}
<Badge variant="outline">{jobReport.module.type}</Badge> <Badge variant="outline">{moduleTypeRu(jobReport.module.type)}</Badge>
<Badge variant="secondary">{jobReport.module.name}</Badge> <Badge variant="secondary">{jobReport.module.name}</Badge>
{/if} {/if}
{#if jobReport.revisionId} {#if jobReport.revisionId}
@@ -377,7 +379,7 @@
class="text-chart-4 flex items-center gap-1 text-[11px] font-medium uppercase" class="text-chart-4 flex items-center gap-1 text-[11px] font-medium uppercase"
> >
<Link2 class="size-3.5 shrink-0" aria-hidden="true" /> <Link2 class="size-3.5 shrink-0" aria-hidden="true" />
CDN / IP Range CDN / IP-диапазоны
</p> </p>
<p class="text-sm font-semibold"> <p class="text-sm font-semibold">
{jobReport.cdn.length}/{jobReport.ipRanges.length} {jobReport.cdn.length}/{jobReport.ipRanges.length}
@@ -389,7 +391,7 @@
<p class="text-xs text-muted-foreground">Результат агрегации по типам</p> <p class="text-xs text-muted-foreground">Результат агрегации по типам</p>
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
{#each jobReport.aggregationByKind as row (`${job.job_id}-agg-${row.kind}`)} {#each jobReport.aggregationByKind as row (`${job.job_id}-agg-${row.kind}`)}
<Badge variant="outline">{row.kind}: {row.prefixCount}</Badge> <Badge variant="outline">{logKindRu(row.kind)}: {row.prefixCount}</Badge>
{/each} {/each}
</div> </div>
</div> </div>
@@ -425,7 +427,7 @@
/> />
</div> </div>
<div class="min-w-0 space-y-1.5 rounded-lg border p-3"> <div class="min-w-0 space-y-1.5 rounded-lg border p-3">
<p class="text-sm font-medium">IP range: итог по статическим диапазонам</p> <p class="text-sm font-medium">IP-диапазоны: итог по статическим диапазонам</p>
<JobReportTableBlock <JobReportTableBlock
rows={jobReport.ipRanges as RowData[]} rows={jobReport.ipRanges as RowData[]}
columns={reportCols} columns={reportCols}
@@ -437,7 +439,7 @@
{/if} {/if}
<div class="space-y-1"> <div class="space-y-1">
<p class="text-xs text-muted-foreground">Meta (raw JSON)</p> <p class="text-xs text-muted-foreground">Meta (JSON)</p>
<div class="rounded-md border bg-muted/30 p-3"> <div class="rounded-md border bg-muted/30 p-3">
<pre <pre
class="font-mono text-xs [overflow-wrap:anywhere] whitespace-pre-wrap">{JSON.stringify( class="font-mono text-xs [overflow-wrap:anywhere] whitespace-pre-wrap">{JSON.stringify(
@@ -51,13 +51,13 @@
<Play class="text-chart-1 size-5" /> <Play class="text-chart-1 size-5" />
</div> </div>
<div class="min-w-0 flex-1 space-y-1"> <div class="min-w-0 flex-1 space-y-1">
<p class="font-semibold">Apply all speakers</p> <p class="font-semibold">Применить ко всем спикерам</p>
<p class="text-muted-foreground max-w-[42ch] text-sm">Применить текущую конфигурацию на всех спикерах</p> <p class="text-muted-foreground max-w-[42ch] text-sm">Применить текущую конфигурацию на всех BIRD-спикерах</p>
</div> </div>
</div> </div>
<Button class="w-full shrink-0 self-start sm:w-auto sm:self-auto" onclick={onApply} disabled={applying}> <Button class="w-full shrink-0 self-start sm:w-auto sm:self-auto" onclick={onApply} disabled={applying}>
<Play class="size-4" aria-hidden="true" /> <Play class="size-4" aria-hidden="true" />
Apply Применить
</Button> </Button>
</div> </div>
</Card> </Card>
@@ -76,7 +76,7 @@
<RotateCcw class="text-chart-4 size-5" /> <RotateCcw class="text-chart-4 size-5" />
</div> </div>
<div class="min-w-0 flex-1 space-y-1"> <div class="min-w-0 flex-1 space-y-1">
<p class="font-semibold">BIRD Reload</p> <p class="font-semibold">Перезагрузка BIRD</p>
<p class="text-muted-foreground max-w-[42ch] text-sm">Перезагрузить конфигурацию BIRD на всех спикерах</p> <p class="text-muted-foreground max-w-[42ch] text-sm">Перезагрузить конфигурацию BIRD на всех спикерах</p>
</div> </div>
</div> </div>
@@ -87,7 +87,7 @@
disabled={reloading} disabled={reloading}
> >
<RotateCcw class="size-4" aria-hidden="true" /> <RotateCcw class="size-4" aria-hidden="true" />
Reload Перезагрузить
</Button> </Button>
</div> </div>
</Card> </Card>
@@ -127,7 +127,7 @@
<span class="font-medium">{birdStatus.bgp_established}</span> <span class="font-medium">{birdStatus.bgp_established}</span>
<span class="text-muted-foreground">/</span> <span class="text-muted-foreground">/</span>
<span class="font-medium">{birdStatus.bgp_sessions_total}</span> <span class="font-medium">{birdStatus.bgp_sessions_total}</span>
<span class="text-muted-foreground"> Established / всего</span> <span class="text-muted-foreground"> установлено / всего</span>
</p> </p>
{/if} {/if}
{:else} {:else}
@@ -4,6 +4,8 @@ export type JobLogEntry = {
kind: string; kind: string;
source: string; source: string;
community: string; community: string;
/** Человекочитаемое имя из справочника (title или BGP community). */
community_label?: string;
prefix_count: number; prefix_count: number;
sample?: string[]; sample?: string[];
message: string; message: string;
+69
View File
@@ -0,0 +1,69 @@
/** Русские подписи для enum из API (задачи, модули, логи refresh). */
export function jobStatusRu(status: string): string {
switch (status) {
case 'queued':
return 'В очереди';
case 'running':
return 'Выполняется';
case 'succeeded':
return 'Успешно';
case 'failed':
return 'Ошибка';
case 'cancelled':
return 'Отменена';
default:
return status;
}
}
/** Подпись типа задачи для фильтров (значения API те же). */
export function jobKindFilterRu(kind: string): string {
switch (kind) {
case 'module_refresh':
return 'Обновление модуля';
case 'deploy_apply':
return 'Применение конфигурации';
case 'revision_rollback':
return 'Откат ревизии';
case 'bird_reload':
return 'Перезагрузка BIRD';
default:
return kind;
}
}
export function moduleTypeRu(type: string): string {
switch (type) {
case 'AS_PREFIXES':
return 'AS (номера)';
case 'CDN_CIDRS':
return 'CDN CIDR';
case 'DOMAINS':
return 'Домены';
case 'IP_RANGES':
return 'IP-диапазоны';
default:
return type;
}
}
/** Тип строки в логе агрегации префиксов (поле kind). */
export function logKindRu(kind: string): string {
switch (kind) {
case 'asn':
return 'ASN';
case 'domain':
return 'Домен';
case 'cdn':
return 'CDN';
case 'ip_range':
return 'Статические диапазоны';
case 'unknown':
return 'Неизвестно';
case 'source':
return 'Источник';
default:
return kind;
}
}
+12 -3
View File
@@ -16,6 +16,7 @@ const resolve = (path: string) => path as any;
import Activity from '@lucide/svelte/icons/activity'; import Activity from '@lucide/svelte/icons/activity';
import Clock from '@lucide/svelte/icons/clock'; import Clock from '@lucide/svelte/icons/clock';
import ArrowRight from '@lucide/svelte/icons/arrow-right'; import ArrowRight from '@lucide/svelte/icons/arrow-right';
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
let healthy = $state<boolean | null>(null); let healthy = $state<boolean | null>(null);
let modules = $state(0); let modules = $state(0);
@@ -128,9 +129,17 @@ const resolve = (path: string) => path as any;
</script> </script>
<div class="space-y-6"> <div class="space-y-6">
<div> <div class="flex items-start gap-3">
<h1 class="text-2xl font-semibold tracking-tight">Обзор</h1> <div
<p class="text-muted-foreground mt-1 text-sm">Состояние EvoBGP control plane.</p> class="bg-primary/10 text-primary flex size-11 shrink-0 items-center justify-center rounded-xl"
aria-hidden="true"
>
<LayoutDashboard class="size-6" />
</div>
<div class="min-w-0">
<h1 class="text-2xl font-semibold tracking-tight">Обзор</h1>
<p class="text-muted-foreground mt-1 text-sm">Состояние панели управления EvoBGP.</p>
</div>
</div> </div>
<!-- Health --> <!-- Health -->
+21 -12
View File
@@ -45,6 +45,7 @@
import Pencil from '@lucide/svelte/icons/pencil'; import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2'; import Trash2 from '@lucide/svelte/icons/trash-2';
import RefreshCw from '@lucide/svelte/icons/refresh-cw'; import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import BookOpen from '@lucide/svelte/icons/book-open';
// --- Communities --- // --- Communities ---
let communities = $state<BgpCommunity[]>([]); let communities = $state<BgpCommunity[]>([]);
@@ -114,10 +115,10 @@
const body = { ...commForm, title: commForm.title?.trim() || undefined }; const body = { ...commForm, title: commForm.title?.trim() || undefined };
if (commEdit) { if (commEdit) {
await apiMutate(`/v1/communities/${commEdit.id}`, 'PATCH', body); await apiMutate(`/v1/communities/${commEdit.id}`, 'PATCH', body);
toast.success('Community обновлена'); toast.success('Запись сообщества обновлена');
} else { } else {
await apiMutate('/v1/communities', 'POST', body); await apiMutate('/v1/communities', 'POST', body);
toast.success('Community создана'); toast.success('Сообщество создано');
} }
commDialog = false; commDialog = false;
await loadComm(); await loadComm();
@@ -183,14 +184,22 @@
</script> </script>
<div class="space-y-6"> <div class="space-y-6">
<div> <div class="flex items-start gap-3">
<h1 class="text-2xl font-semibold tracking-tight">Справочники</h1> <div
<p class="text-muted-foreground mt-1 text-sm">BGP Communities и DoH-профили для резолвинга доменов.</p> class="bg-chart-2/15 text-chart-2 flex size-11 shrink-0 items-center justify-center rounded-xl"
aria-hidden="true"
>
<BookOpen class="size-6" />
</div>
<div class="min-w-0">
<h1 class="text-2xl font-semibold tracking-tight">Справочники</h1>
<p class="text-muted-foreground mt-1 text-sm">Сообщества BGP и DoH-профили для резолвинга доменов.</p>
</div>
</div> </div>
<Tabs value="communities"> <Tabs value="communities">
<TabsList> <TabsList>
<TabsTrigger value="communities">Communities</TabsTrigger> <TabsTrigger value="communities">Сообщества BGP</TabsTrigger>
<TabsTrigger value="doh">DoH профили</TabsTrigger> <TabsTrigger value="doh">DoH профили</TabsTrigger>
</TabsList> </TabsList>
@@ -199,7 +208,7 @@
<Card> <Card>
<CardHeader class="flex flex-row items-center justify-between pb-2"> <CardHeader class="flex flex-row items-center justify-between pb-2">
<div> <div>
<CardTitle class="text-base">BGP Communities</CardTitle> <CardTitle class="text-base">Сообщества BGP</CardTitle>
<CardDescription>Используются для тегирования префиксов</CardDescription> <CardDescription>Используются для тегирования префиксов</CardDescription>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
@@ -213,7 +222,7 @@
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Community</TableHead> <TableHead>Код сообщества</TableHead>
<TableHead>Название</TableHead> <TableHead>Название</TableHead>
<TableHead>ID</TableHead> <TableHead>ID</TableHead>
<TableHead class="w-20"></TableHead> <TableHead class="w-20"></TableHead>
@@ -235,7 +244,7 @@
{:else} {:else}
<TableRow> <TableRow>
<TableCell colspan={4} class="text-muted-foreground text-center py-8"> <TableCell colspan={4} class="text-muted-foreground text-center py-8">
{commLoading ? 'Загрузка…' : 'Нет communities. Создайте первую.'} {commLoading ? 'Загрузка…' : 'Нет записей. Создайте первую.'}
</TableCell> </TableCell>
</TableRow> </TableRow>
{/each} {/each}
@@ -302,11 +311,11 @@
<Dialog bind:open={commDialog}> <Dialog bind:open={commDialog}>
<DialogContent class="sm:max-w-sm"> <DialogContent class="sm:max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle>{commEdit ? 'Редактировать' : 'Новая'} community</DialogTitle> <DialogTitle>{commEdit ? 'Редактировать сообщество BGP' : 'Новое сообщество BGP'}</DialogTitle>
</DialogHeader> </DialogHeader>
<div class="space-y-4 py-2"> <div class="space-y-4 py-2">
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="c-community">Community</Label> <Label for="c-community">Код сообщества</Label>
<Input id="c-community" bind:value={commForm.community} placeholder="65001:120" /> <Input id="c-community" bind:value={commForm.community} placeholder="65001:120" />
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
@@ -324,7 +333,7 @@
<AlertDialog open={!!commDeleteTarget} onOpenChange={(v) => { if (!v) commDeleteTarget = null; }}> <AlertDialog open={!!commDeleteTarget} onOpenChange={(v) => { if (!v) commDeleteTarget = null; }}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Удалить community «{commDisplay(commDeleteTarget)}»?</AlertDialogTitle> <AlertDialogTitle>Удалить сообщество «{commDisplay(commDeleteTarget)}»?</AlertDialogTitle>
<AlertDialogDescription>Это приведёт к удалению привязки во всех модулях.</AlertDialogDescription> <AlertDialogDescription>Это приведёт к удалению привязки во всех модулях.</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
+14 -4
View File
@@ -46,6 +46,8 @@
import RefreshCw from '@lucide/svelte/icons/refresh-cw'; import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import ExternalLink from '@lucide/svelte/icons/external-link'; import ExternalLink from '@lucide/svelte/icons/external-link';
import Trash2 from '@lucide/svelte/icons/trash-2'; import Trash2 from '@lucide/svelte/icons/trash-2';
import { moduleTypeRu } from '$lib/ui-labels.js';
import Boxes from '@lucide/svelte/icons/boxes';
let rows = $state<ModuleRow[]>([]); let rows = $state<ModuleRow[]>([]);
let loading = $state(false); let loading = $state(false);
@@ -155,9 +157,17 @@
<div class="space-y-6"> <div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-3"> <div class="flex flex-wrap items-start justify-between gap-3">
<div> <div class="flex min-w-0 items-start gap-3">
<h1 class="text-2xl font-semibold tracking-tight">Модули префиксов</h1> <div
<p class="text-muted-foreground mt-1 text-sm">Управление модулями — AS, CDN, домены, IP-диапазоны.</p> class="bg-chart-1/15 text-chart-1 flex size-11 shrink-0 items-center justify-center rounded-xl"
aria-hidden="true"
>
<Boxes class="size-6" />
</div>
<div class="min-w-0">
<h1 class="text-2xl font-semibold tracking-tight">Модули префиксов</h1>
<p class="text-muted-foreground mt-1 text-sm">Управление модулями — AS, CDN, домены, IP-диапазоны.</p>
</div>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onclick={load} disabled={loading}> <Button variant="outline" size="sm" onclick={load} disabled={loading}>
@@ -223,7 +233,7 @@
</TableCell> </TableCell>
<TableCell class="font-medium">{m.name}</TableCell> <TableCell class="font-medium">{m.name}</TableCell>
<TableCell> <TableCell>
<Badge variant={typeBadgeVariant(m.type)}>{m.type}</Badge> <Badge variant={typeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
</TableCell> </TableCell>
<TableCell class="text-muted-foreground">{m.priority}</TableCell> <TableCell class="text-muted-foreground">{m.priority}</TableCell>
<TableCell class="text-muted-foreground font-mono text-xs"> <TableCell class="text-muted-foreground font-mono text-xs">
@@ -74,6 +74,7 @@
import Save from '@lucide/svelte/icons/save'; import Save from '@lucide/svelte/icons/save';
import Upload from '@lucide/svelte/icons/upload'; import Upload from '@lucide/svelte/icons/upload';
import Download from '@lucide/svelte/icons/download'; import Download from '@lucide/svelte/icons/download';
import { moduleTypeRu } from '$lib/ui-labels.js';
const moduleId = $derived(page.params.moduleId); const moduleId = $derived(page.params.moduleId);
@@ -751,7 +752,7 @@
<div class="min-w-0"> <div class="min-w-0">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<h1 class="min-w-0 break-words text-2xl font-semibold tracking-tight">{mod.name}</h1> <h1 class="min-w-0 break-words text-2xl font-semibold tracking-tight">{mod.name}</h1>
<Badge variant="outline">{mod.type}</Badge> <Badge variant="outline">{moduleTypeRu(mod.type)}</Badge>
{#if mod.enabled} {#if mod.enabled}
<Badge variant="default" class="text-xs">вкл</Badge> <Badge variant="default" class="text-xs">вкл</Badge>
{:else} {:else}
@@ -764,7 +765,7 @@
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onclick={refreshMod} disabled={refreshing}> <Button variant="outline" size="sm" onclick={refreshMod} disabled={refreshing}>
<RefreshCw class={refreshing ? 'animate-spin' : ''} /> <RefreshCw class={refreshing ? 'animate-spin' : ''} />
Refresh Обновить
</Button> </Button>
<Button variant="outline" size="sm" onclick={openEditMod}> <Button variant="outline" size="sm" onclick={openEditMod}>
<Pencil /> <Pencil />
+17 -8
View File
@@ -6,6 +6,7 @@
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '$lib/components/ui/card/index.js'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '$lib/components/ui/card/index.js';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import RefreshCw from '@lucide/svelte/icons/refresh-cw'; import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Gauge from '@lucide/svelte/icons/gauge';
type HealthStatus = { status: string }; type HealthStatus = { status: string };
type ReadyStatus = { status: string; checks?: Record<string, unknown> }; type ReadyStatus = { status: string; checks?: Record<string, unknown> };
@@ -42,10 +43,18 @@
</script> </script>
<div class="space-y-6"> <div class="space-y-6">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between gap-4">
<div> <div class="flex min-w-0 items-start gap-3">
<h1 class="text-2xl font-semibold tracking-tight">Мониторинг</h1> <div
<p class="text-muted-foreground mt-1 text-sm">Доступность API и версия сборки.</p> class="bg-info/15 text-info flex size-11 shrink-0 items-center justify-center rounded-xl"
aria-hidden="true"
>
<Gauge class="size-6" />
</div>
<div class="min-w-0">
<h1 class="text-2xl font-semibold tracking-tight">Мониторинг</h1>
<p class="text-muted-foreground mt-1 text-sm">Доступность API и версия сборки.</p>
</div>
</div> </div>
<Button variant="outline" size="sm" onclick={load} disabled={loading}> <Button variant="outline" size="sm" onclick={load} disabled={loading}>
<RefreshCw class={loading ? 'animate-spin' : ''} /> <RefreshCw class={loading ? 'animate-spin' : ''} />
@@ -57,17 +66,17 @@
<!-- Health --> <!-- Health -->
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle class="text-base">Liveness</CardTitle> <CardTitle class="text-base">Доступность (liveness)</CardTitle>
<CardDescription>GET /v1/health</CardDescription> <CardDescription>GET /v1/health</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{#if health === null} {#if health === null}
<Badge variant="outline"></Badge> <Badge variant="outline"></Badge>
{:else if health.ok} {:else if health.ok}
<Badge variant="default">OK</Badge> <Badge variant="default">ОК</Badge>
{#if health.status}<p class="text-muted-foreground mt-1 text-xs">{health.status}</p>{/if} {#if health.status}<p class="text-muted-foreground mt-1 text-xs">{health.status}</p>{/if}
{:else} {:else}
<Badge variant="destructive">FAIL</Badge> <Badge variant="destructive">Сбой</Badge>
{/if} {/if}
</CardContent> </CardContent>
</Card> </Card>
@@ -75,7 +84,7 @@
<!-- Ready --> <!-- Ready -->
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle class="text-base">Readiness</CardTitle> <CardTitle class="text-base">Готовность (readiness)</CardTitle>
<CardDescription>GET /v1/ready</CardDescription> <CardDescription>GET /v1/ready</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
+12 -3
View File
@@ -48,6 +48,7 @@
import Trash2 from '@lucide/svelte/icons/trash-2'; import Trash2 from '@lucide/svelte/icons/trash-2';
import RefreshCw from '@lucide/svelte/icons/refresh-cw'; import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Play from '@lucide/svelte/icons/play'; import Play from '@lucide/svelte/icons/play';
import NetworkIcon from '@lucide/svelte/icons/network';
// --- Peers --- // --- Peers ---
let peers = $state<PeerRow[]>([]); let peers = $state<PeerRow[]>([]);
@@ -208,9 +209,17 @@
</script> </script>
<div class="space-y-6"> <div class="space-y-6">
<div> <div class="flex items-start gap-3">
<h1 class="text-2xl font-semibold tracking-tight">Сеть</h1> <div
<p class="text-muted-foreground mt-1 text-sm">BGP-пиры и спикеры (BIRD-агенты).</p> class="bg-chart-3/15 text-chart-3 flex size-11 shrink-0 items-center justify-center rounded-xl"
aria-hidden="true"
>
<NetworkIcon class="size-6" />
</div>
<div class="min-w-0">
<h1 class="text-2xl font-semibold tracking-tight">Сеть</h1>
<p class="text-muted-foreground mt-1 text-sm">BGP-пиры и спикеры (BIRD-агенты).</p>
</div>
</div> </div>
<Tabs value="peers"> <Tabs value="peers">
+39 -20
View File
@@ -22,6 +22,7 @@
ModulesResponse ModulesResponse
} from '$lib/api/types.js'; } from '$lib/api/types.js';
import { jobKindTitle } from '$lib/operations/job-kind-label.js'; import { jobKindTitle } from '$lib/operations/job-kind-label.js';
import { jobStatusRu } from '$lib/ui-labels.js';
import { Badge } from '$lib/components/ui/badge/index.js'; import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs/index.js'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs/index.js';
@@ -64,6 +65,7 @@
} from '$lib/dialog-layout.js'; } from '$lib/dialog-layout.js';
import { cn } from '$lib/utils.js'; import { cn } from '$lib/utils.js';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import Zap from '@lucide/svelte/icons/zap';
// Revisions // Revisions
let revisions = $state<RevisionRow[]>([]); let revisions = $state<RevisionRow[]>([]);
@@ -324,7 +326,7 @@
rollingBack = true; rollingBack = true;
try { try {
await apiMutate(`/v1/revisions/${rollbackTarget.id}/rollback`, 'POST', {}); await apiMutate(`/v1/revisions/${rollbackTarget.id}/rollback`, 'POST', {});
toast.success('Rollback выполнен'); toast.success('Откат выполнен');
rollbackTarget = null; rollbackTarget = null;
await loadRevisions(); await loadRevisions();
} catch (e) { } catch (e) {
@@ -338,7 +340,7 @@
try { try {
const revId = revisions[0]?.id; const revId = revisions[0]?.id;
if (!revId) { if (!revId) {
toast.error('Нет ревизий — сначала refresh модуля или дождитесь задачи render'); toast.error('Нет ревизий — сначала обновите модуль или дождитесь задачи render');
return; return;
} }
const res = await apiMutate<{ job_id: string; status?: string }>('/v1/apply', 'POST', { const res = await apiMutate<{ job_id: string; status?: string }>('/v1/apply', 'POST', {
@@ -352,10 +354,12 @@
const job = await waitForJob(res.job_id, { timeoutMs: 180000 }); const job = await waitForJob(res.job_id, { timeoutMs: 180000 });
const extra = summarizeJobBirdMeta(job); const extra = summarizeJobBirdMeta(job);
if (job.status === 'succeeded') { if (job.status === 'succeeded') {
toast.success(extra ? `Apply успешно. ${extra}` : 'Apply успешно завершён'); toast.success(extra ? `Применение успешно. ${extra}` : 'Конфигурация успешно применена');
} else { } else {
toast.error( toast.error(
job.error ? `${job.status}: ${job.error}` : `Задача завершилась со статусом ${job.status}` job.error
? `${jobStatusRu(job.status)}: ${job.error}`
: `Задача завершилась со статусом ${jobStatusRu(job.status)}`
); );
} }
await loadJobs(); await loadJobs();
@@ -379,10 +383,12 @@
const job = await waitForJob(res.job_id, { timeoutMs: 120000 }); const job = await waitForJob(res.job_id, { timeoutMs: 120000 });
const extra = summarizeJobBirdMeta(job); const extra = summarizeJobBirdMeta(job);
if (job.status === 'succeeded') { if (job.status === 'succeeded') {
toast.success(extra ? `Reload успешно. ${extra}` : 'birdc configure выполнен'); toast.success(extra ? `Перезагрузка успешна. ${extra}` : 'Команда birdc configure выполнена');
} else { } else {
toast.error( toast.error(
job.error ? `${job.status}: ${job.error}` : `Задача завершилась со статусом ${job.status}` job.error
? `${jobStatusRu(job.status)}: ${job.error}`
: `Задача завершилась со статусом ${jobStatusRu(job.status)}`
); );
} }
await loadJobs(); await loadJobs();
@@ -625,10 +631,15 @@
const sample = Array.isArray(row.sample) const sample = Array.isArray(row.sample)
? row.sample.filter((value): value is string => typeof value === 'string') ? row.sample.filter((value): value is string => typeof value === 'string')
: undefined; : undefined;
const communityLabel =
typeof row.community_label === 'string' && row.community_label.trim().length > 0
? row.community_label.trim()
: undefined;
parsed.push({ parsed.push({
kind, kind,
source, source,
community, community,
...(communityLabel ? { community_label: communityLabel } : {}),
prefix_count: prefixCount, prefix_count: prefixCount,
message, message,
...(sample && sample.length > 0 ? { sample } : {}) ...(sample && sample.length > 0 ? { sample } : {})
@@ -668,18 +679,26 @@
} }
function birdHealthyShortLabel(h: boolean | null | undefined): string { function birdHealthyShortLabel(h: boolean | null | undefined): string {
if (h === true) return 'OK'; if (h === true) return 'ОК';
if (h === false) return 'Проблема'; if (h === false) return 'Проблема';
return 'Н/Д'; return 'Н/Д';
} }
</script> </script>
<div class="space-y-6"> <div class="space-y-6">
<div> <div class="flex items-start gap-3">
<h1 class="text-2xl font-semibold tracking-tight">Операции</h1> <div
<p class="mt-1 text-sm text-muted-foreground"> class="bg-chart-2/15 text-chart-2 flex size-11 shrink-0 items-center justify-center rounded-xl"
Деплой конфигурации, управление ревизиями и задачами. aria-hidden="true"
</p> >
<Zap class="size-6" />
</div>
<div class="min-w-0">
<h1 class="text-2xl font-semibold tracking-tight">Операции</h1>
<p class="mt-1 text-sm text-muted-foreground">
Деплой конфигурации, управление ревизиями и задачами.
</p>
</div>
</div> </div>
<OperationsQuickActions <OperationsQuickActions
@@ -699,7 +718,7 @@
<div class="overflow-x-auto pb-1 [scrollbar-gutter:stable]"> <div class="overflow-x-auto pb-1 [scrollbar-gutter:stable]">
<TabsList class="inline-flex min-w-max"> <TabsList class="inline-flex min-w-max">
<TabsTrigger value="revisions">Ревизии</TabsTrigger> <TabsTrigger value="revisions">Ревизии</TabsTrigger>
<TabsTrigger value="diff">Diff</TabsTrigger> <TabsTrigger value="diff">Сравнение</TabsTrigger>
<TabsTrigger value="jobs">Задачи</TabsTrigger> <TabsTrigger value="jobs">Задачи</TabsTrigger>
</TabsList> </TabsList>
</div> </div>
@@ -774,7 +793,7 @@
<AlertDialog bind:open={applyConfirm}> <AlertDialog bind:open={applyConfirm}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Запустить Apply на всех спикерах?</AlertDialogTitle> <AlertDialogTitle>Применить конфигурацию на всех спикерах?</AlertDialogTitle>
<AlertDialogDescription <AlertDialogDescription
>Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль operator.</AlertDialogDescription >Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль operator.</AlertDialogDescription
> >
@@ -782,7 +801,7 @@
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel> <AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction onclick={doApply} disabled={applying} <AlertDialogAction onclick={doApply} disabled={applying}
>{applying ? 'Apply…' : 'Применить'}</AlertDialogAction >{applying ? 'Применение…' : 'Применить'}</AlertDialogAction
> >
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -792,7 +811,7 @@
<AlertDialog bind:open={reloadConfirm}> <AlertDialog bind:open={reloadConfirm}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Reload BIRD?</AlertDialogTitle> <AlertDialogTitle>Перезагрузить BIRD?</AlertDialogTitle>
<AlertDialogDescription <AlertDialogDescription
>BIRD перезагрузит конфигурацию. Требуется роль operator.</AlertDialogDescription >BIRD перезагрузит конфигурацию. Требуется роль operator.</AlertDialogDescription
> >
@@ -800,7 +819,7 @@
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel> <AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction onclick={doBirdReload} disabled={reloading} <AlertDialogAction onclick={doBirdReload} disabled={reloading}
>{reloading ? 'Reload…' : 'Reload'}</AlertDialogAction >{reloading ? 'Перезагрузка…' : 'Перезагрузить'}</AlertDialogAction
> >
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -861,7 +880,7 @@
<DialogHeader class={dialogHeaderDocument}> <DialogHeader class={dialogHeaderDocument}>
<DialogTitle>Ревизия {previewRevision?.id.slice(0, 8)}</DialogTitle> <DialogTitle>Ревизия {previewRevision?.id.slice(0, 8)}</DialogTitle>
<DialogDescription> <DialogDescription>
Срендеренный конфиг BIRD 2 (фрагменты из control plane) и материализованные префиксы. Сгенерированный конфиг BIRD 2 (фрагменты из control plane) и материализованные префиксы.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{#if previewLoading} {#if previewLoading}
@@ -964,7 +983,7 @@
>{jobDetail.job_id}</span >{jobDetail.job_id}</span
> >
<span class="text-muted-foreground">Статус</span><span <span class="text-muted-foreground">Статус</span><span
><Badge variant={jobStatusVariant(jobDetail.status)}>{jobDetail.status}</Badge></span ><Badge variant={jobStatusVariant(jobDetail.status)}>{jobStatusRu(jobDetail.status)}</Badge></span
> >
<span class="text-muted-foreground">Создана</span><span <span class="text-muted-foreground">Создана</span><span
>{formatDate(jobDetail.created_at)}</span >{formatDate(jobDetail.created_at)}</span
@@ -983,7 +1002,7 @@
</div> </div>
{#if jobDetail.meta && Object.keys(jobDetail.meta).length > 0} {#if jobDetail.meta && Object.keys(jobDetail.meta).length > 0}
<div class="mt-4 space-y-2"> <div class="mt-4 space-y-2">
<p class="text-sm font-medium text-muted-foreground">Meta</p> <p class="text-sm font-medium text-muted-foreground">Метаданные</p>
<ScrollPreBlock <ScrollPreBlock
variant="wrap" variant="wrap"
text={JSON.stringify(jobDetail.meta, null, 2)} text={JSON.stringify(jobDetail.meta, null, 2)}
+28 -14
View File
@@ -15,6 +15,8 @@
} from '$lib/components/ui/table/index.js'; } from '$lib/components/ui/table/index.js';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import RefreshCw from '@lucide/svelte/icons/refresh-cw'; import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import CalendarClock from '@lucide/svelte/icons/calendar-clock';
import { jobKindFilterRu, jobStatusRu, moduleTypeRu } from '$lib/ui-labels.js';
let modules = $state<ModuleRow[]>([]); let modules = $state<ModuleRow[]>([]);
let jobs = $state<JobRow[]>([]); let jobs = $state<JobRow[]>([]);
@@ -47,7 +49,7 @@
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
}); });
if (res.status === 204) toast.message('Refresh не требуется (IP_RANGES)'); if (res.status === 204) toast.message('Обновление не требуется (тип IP_RANGES)');
else if (res.status === 202) { toast.success('Задача поставлена в очередь'); await load(); } else if (res.status === 202) { toast.success('Задача поставлена в очередь'); await load(); }
else toast.error(`HTTP ${res.status}`); else toast.error(`HTTP ${res.status}`);
} catch (e) { } catch (e) {
@@ -77,10 +79,18 @@
</script> </script>
<div class="space-y-6"> <div class="space-y-6">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between gap-4">
<div> <div class="flex min-w-0 items-start gap-3">
<h1 class="text-2xl font-semibold tracking-tight">Расписание и задачи</h1> <div
<p class="text-muted-foreground mt-1 text-sm">Интервалы обновления модулей и ручной запуск refresh.</p> class="bg-chart-4/15 text-chart-4 flex size-11 shrink-0 items-center justify-center rounded-xl"
aria-hidden="true"
>
<CalendarClock class="size-6" />
</div>
<div class="min-w-0">
<h1 class="text-2xl font-semibold tracking-tight">Расписание и задачи</h1>
<p class="text-muted-foreground mt-1 text-sm">Интервалы обновления модулей и ручной запуск обновления.</p>
</div>
</div> </div>
<Button variant="outline" size="sm" onclick={load} disabled={loading}> <Button variant="outline" size="sm" onclick={load} disabled={loading}>
<RefreshCw class={loading ? 'animate-spin' : ''} /> <RefreshCw class={loading ? 'animate-spin' : ''} />
@@ -106,7 +116,9 @@
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle class="text-base">Модули</CardTitle> <CardTitle class="text-base">Модули</CardTitle>
<CardDescription>Запустить refresh вручную (CDN/домены/AS → очередь; IP_RANGES → 204)</CardDescription> <CardDescription
>Запустить обновление вручную (CDN, домены, AS — в очередь; для IP_RANGES ответ 204)</CardDescription
>
</CardHeader> </CardHeader>
<CardContent class="p-0"> <CardContent class="p-0">
<Table> <Table>
@@ -123,13 +135,13 @@
{#each modules as m (m.id)} {#each modules as m (m.id)}
<TableRow> <TableRow>
<TableCell class="font-medium">{m.name}</TableCell> <TableCell class="font-medium">{m.name}</TableCell>
<TableCell><Badge variant="outline">{m.type}</Badge></TableCell> <TableCell><Badge variant="outline">{moduleTypeRu(m.type)}</Badge></TableCell>
<TableCell>{intervalLabel(m.refresh_interval_sec)}</TableCell> <TableCell>{intervalLabel(m.refresh_interval_sec)}</TableCell>
<TableCell class="font-mono text-xs">{m.cron_expr || '—'}</TableCell> <TableCell class="font-mono text-xs">{m.cron_expr || '—'}</TableCell>
<TableCell class="text-right"> <TableCell class="text-right">
<Button size="xs" variant="secondary" disabled={!!refreshing[m.id]} onclick={() => refreshModule(m.id)}> <Button size="xs" variant="secondary" disabled={!!refreshing[m.id]} onclick={() => refreshModule(m.id)}>
<RefreshCw class={refreshing[m.id] ? 'animate-spin' : ''} /> <RefreshCw class={refreshing[m.id] ? 'animate-spin' : ''} />
{refreshing[m.id] ? '…' : 'Refresh'} {refreshing[m.id] ? '…' : 'Обновить'}
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -163,8 +175,8 @@
<TableBody> <TableBody>
{#each jobs as j (j.job_id)} {#each jobs as j (j.job_id)}
<TableRow> <TableRow>
<TableCell class="font-medium">{j.kind}</TableCell> <TableCell class="font-medium">{jobKindFilterRu(j.kind)}</TableCell>
<TableCell><Badge variant={jobStatusVariant(j.status)}>{j.status}</Badge></TableCell> <TableCell><Badge variant={jobStatusVariant(j.status)}>{jobStatusRu(j.status)}</Badge></TableCell>
<TableCell class="text-xs text-muted-foreground">{j.created_at ? new Date(j.created_at).toLocaleString('ru') : '—'}</TableCell> <TableCell class="text-xs text-muted-foreground">{j.created_at ? new Date(j.created_at).toLocaleString('ru') : '—'}</TableCell>
<TableCell class="text-xs text-destructive max-w-xs truncate">{j.error ?? ''}</TableCell> <TableCell class="text-xs text-destructive max-w-xs truncate">{j.error ?? ''}</TableCell>
</TableRow> </TableRow>
@@ -183,7 +195,7 @@
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle class="text-base">Операции обновления модулей</CardTitle> <CardTitle class="text-base">Операции обновления модулей</CardTitle>
<CardDescription>Отдельная лента задач `module_refresh` для контроля по модулям.</CardDescription> <CardDescription>Отдельная лента задач обновления модулей для контроля по модулям.</CardDescription>
</CardHeader> </CardHeader>
<CardContent class="p-0"> <CardContent class="p-0">
<Table> <Table>
@@ -191,19 +203,21 @@
<TableRow> <TableRow>
<TableHead>Статус</TableHead> <TableHead>Статус</TableHead>
<TableHead>Создана</TableHead> <TableHead>Создана</TableHead>
<TableHead>revision_id</TableHead> <TableHead>Ревизия</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{#each refreshJobs as j (j.job_id)} {#each refreshJobs as j (j.job_id)}
<TableRow> <TableRow>
<TableCell><Badge variant={jobStatusVariant(j.status)}>{j.status}</Badge></TableCell> <TableCell><Badge variant={jobStatusVariant(j.status)}>{jobStatusRu(j.status)}</Badge></TableCell>
<TableCell class="text-xs">{j.created_at ? new Date(j.created_at).toLocaleString('ru') : '—'}</TableCell> <TableCell class="text-xs">{j.created_at ? new Date(j.created_at).toLocaleString('ru') : '—'}</TableCell>
<TableCell class="font-mono text-xs">{typeof j.meta?.revision_id === 'string' ? `${j.meta.revision_id.slice(0, 12)}` : '—'}</TableCell> <TableCell class="font-mono text-xs">{typeof j.meta?.revision_id === 'string' ? `${j.meta.revision_id.slice(0, 12)}` : '—'}</TableCell>
</TableRow> </TableRow>
{:else} {:else}
<TableRow> <TableRow>
<TableCell colspan={3} class="text-muted-foreground text-center py-6">Нет задач module_refresh</TableCell> <TableCell colspan={3} class="text-muted-foreground text-center py-6"
>Нет задач обновления модулей</TableCell
>
</TableRow> </TableRow>
{/each} {/each}
</TableBody> </TableBody>
+12 -3
View File
@@ -11,6 +11,7 @@
import { Textarea } from '$lib/components/ui/textarea/index.js'; import { Textarea } from '$lib/components/ui/textarea/index.js';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import Save from '@lucide/svelte/icons/save'; import Save from '@lucide/svelte/icons/save';
import SettingsIcon from '@lucide/svelte/icons/settings';
let token = $state(''); let token = $state('');
let apiSettings = $state<AppSettings | null>(null); let apiSettings = $state<AppSettings | null>(null);
@@ -63,9 +64,17 @@
</script> </script>
<div class="mx-auto max-w-2xl space-y-6"> <div class="mx-auto max-w-2xl space-y-6">
<div> <div class="flex items-start gap-3">
<h1 class="text-2xl font-semibold tracking-tight">Настройки</h1> <div
<p class="text-muted-foreground mt-1 text-sm">Токен доступа и параметры API.</p> class="bg-muted text-muted-foreground flex size-11 shrink-0 items-center justify-center rounded-xl"
aria-hidden="true"
>
<SettingsIcon class="size-6" />
</div>
<div class="min-w-0">
<h1 class="text-2xl font-semibold tracking-tight">Настройки</h1>
<p class="text-muted-foreground mt-1 text-sm">Токен доступа и параметры API.</p>
</div>
</div> </div>
<!-- Token --> <!-- Token -->