feat: enhance evobgp with new command-line tools for bundle management, including pull, verify, and apply functionalities. Update go.mod to include necessary dependencies and complete todos in architecture plan for improved observability and deployment practices.
CI / changes (push) Successful in 4s
CI / go (push) Failing after 6s
CI / bird2 (push) Has been skipped
CI / openapi (push) Has been skipped

This commit is contained in:
Denozordec
2026-04-05 14:07:45 +07:00
parent 272542b92a
commit bf52b21150
131 changed files with 9222 additions and 17 deletions
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { buttonVariants } from '$lib/components/ui/button/button.svelte';
import { cn } from '$lib/utils.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import Activity from '@lucide/svelte/icons/activity';
import Boxes from '@lucide/svelte/icons/boxes';
import CalendarClock from '@lucide/svelte/icons/calendar-clock';
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
import Network from '@lucide/svelte/icons/network';
import Settings from '@lucide/svelte/icons/settings';
import Gauge from '@lucide/svelte/icons/gauge';
const nav = [
{ href: '/', label: 'Обзор', icon: LayoutDashboard },
{ href: '/modules', label: 'Модули', icon: Boxes },
{ href: '/revisions', label: 'Ревизии', icon: Activity },
{ href: '/peers', label: 'Пиры и спикеры', icon: Network },
{ href: '/schedule', label: 'Расписание', icon: CalendarClock },
{ href: '/monitoring', label: 'Мониторинг', icon: Gauge },
{ href: '/settings', label: 'Настройки', icon: Settings }
] as const;
let { children } = $props();
</script>
<div class="bg-background flex min-h-screen">
<aside
class="border-border bg-card/30 flex w-56 shrink-0 flex-col border-r"
aria-label="Основная навигация"
>
<div class="p-4">
<a href={resolve('/')} class="text-foreground font-semibold tracking-tight">EvoBGP</a>
<p class="text-muted-foreground mt-0.5 text-xs">Control plane</p>
</div>
<Separator />
<nav class="flex flex-1 flex-col gap-0.5 p-2">
{#each nav as item (item.href)}
{@const Icon = item.icon}
<a
href={resolve(item.href)}
class={cn(
buttonVariants({
variant: page.url.pathname === item.href ? 'secondary' : 'ghost',
size: 'sm'
}),
'w-full justify-start gap-2 no-underline'
)}
>
<Icon class="size-4" />
{item.label}
</a>
{/each}
</nav>
</aside>
<main class="min-w-0 flex-1 p-6">
{@render children?.()}
</main>
</div>
+47
View File
@@ -0,0 +1,47 @@
import { browser } from '$app/environment';
export const TOKEN_STORAGE_KEY = 'evobgp_api_token';
export type Problem = {
title?: string;
status?: number;
detail?: string;
};
function mergeHeaders(init?: RequestInit): Headers {
const h = new Headers(init?.headers);
if (!h.has('Accept')) {
h.set('Accept', 'application/json');
}
if (browser) {
const t = localStorage.getItem(TOKEN_STORAGE_KEY);
if (t && !h.has('Authorization')) {
h.set('Authorization', `Bearer ${t}`);
}
}
return h;
}
export async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
if (!browser) {
throw new Error('API is only available in the browser');
}
return fetch(path, { ...init, headers: mergeHeaders(init) });
}
export async function apiJSON<T>(path: string, init?: RequestInit): Promise<T> {
const res = await apiFetch(path, init);
const text = await res.text();
if (!res.ok) {
let detail = text;
try {
const j = JSON.parse(text) as Problem;
if (j?.detail) detail = j.detail;
} catch {
/* plain text */
}
throw new Error(detail || `HTTP ${res.status}`);
}
if (!text) return undefined as T;
return JSON.parse(text) as T;
}
+77
View File
@@ -0,0 +1,77 @@
export type ModuleRow = {
id: string;
type: string;
name: string;
enabled: boolean;
priority: number;
refresh_interval_sec: number;
cron_expr: string;
default_community_id: string | null;
doh_profile_id: string | null;
};
export type ModulesResponse = {
items: ModuleRow[];
next_cursor: string | null;
has_more: boolean;
};
export type RevisionRow = {
id: string;
content_hash: string;
created_at: string;
parent_revision_id: string | null;
materialized_prefix_count: number;
module_id: string | null;
};
export type RevisionsResponse = {
items: RevisionRow[];
next_cursor: string | null;
has_more: boolean;
};
export type PeerRow = {
id: string;
name: string;
neighbor: string;
session_state: string;
bgp_speaker_id: string | null;
};
export type PeersResponse = {
items: PeerRow[];
next_cursor: string | null;
has_more: boolean;
};
export type SpeakerRow = {
id: string;
role: string;
endpoint: string;
last_applied_revision_id: string | null;
};
export type SpeakersResponse = {
items: SpeakerRow[];
next_cursor: string | null;
has_more: boolean;
};
export type JobRow = {
job_id: string;
kind: string;
status: string;
idempotency_key?: string | null;
created_at?: string;
started_at?: string | null;
finished_at?: string | null;
error?: string | null;
meta?: Record<string, unknown>;
};
export type JobsResponse = {
items: JobRow[];
next_cursor: string | null;
has_more: boolean;
};
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,38 @@
<script lang="ts" module>
import { type VariantProps, tv } from 'tailwind-variants';
export const badgeVariants = tv({
base: 'focus:ring-ring inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:ring-2 focus:ring-offset-2 focus:outline-none',
variants: {
variant: {
default: 'bg-primary text-primary-foreground border-transparent shadow hover:bg-primary/80',
secondary: 'bg-secondary text-secondary-foreground border-transparent hover:bg-secondary/80',
destructive:
'bg-destructive text-destructive-foreground border-transparent shadow hover:bg-destructive/80',
outline: 'text-foreground'
}
},
defaultVariants: {
variant: 'default'
}
});
export type BadgeVariant = VariantProps<typeof badgeVariants>['variant'];
</script>
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
class: className,
variant = 'default',
children,
...rest
}: HTMLAttributes<HTMLSpanElement> & {
variant?: BadgeVariant;
children?: import('svelte').Snippet;
} = $props();
</script>
<span class={cn(badgeVariants({ variant }), className)} {...rest}>{@render children?.()}</span>
+2
View File
@@ -0,0 +1,2 @@
import Root, { badgeVariants, type BadgeVariant } from './badge.svelte';
export { Root, Root as Badge, badgeVariants, type BadgeVariant };
@@ -0,0 +1,54 @@
<script lang="ts" module>
import { type VariantProps, tv } from 'tailwind-variants';
export const buttonVariants = tv({
base: 'focus-visible:border-ring focus-visible:ring-ring/50 inline-flex shrink-0 cursor-pointer select-none items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*="size-"])]:size-4',
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
});
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
</script>
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { HTMLButtonAttributes } from 'svelte/elements';
let {
class: className,
variant = 'default',
size = 'default',
type = 'button',
children,
...rest
}: HTMLButtonAttributes & {
variant?: ButtonVariant;
size?: ButtonSize;
children?: import('svelte').Snippet;
} = $props();
</script>
<button class={cn(buttonVariants({ variant, size }), className)} {type} {...rest}
>{@render children?.()}</button
>
@@ -0,0 +1,8 @@
import Root, { buttonVariants, type ButtonSize, type ButtonVariant } from './button.svelte';
export {
Root,
Root as Button,
buttonVariants,
type ButtonSize,
type ButtonVariant
};
@@ -0,0 +1,12 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
class: className,
children,
...rest
}: HTMLAttributes<HTMLDivElement> & { children?: import('svelte').Snippet } = $props();
</script>
<div class={cn('p-6 pt-0', className)} {...rest}>{@render children?.()}</div>
@@ -0,0 +1,12 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
class: className,
children,
...rest
}: HTMLAttributes<HTMLParagraphElement> & { children?: import('svelte').Snippet } = $props();
</script>
<p class={cn('text-muted-foreground text-sm', className)} {...rest}>{@render children?.()}</p>
@@ -0,0 +1,12 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
class: className,
children,
...rest
}: HTMLAttributes<HTMLDivElement> & { children?: import('svelte').Snippet } = $props();
</script>
<div class={cn('flex flex-col gap-1.5 p-6', className)} {...rest}>{@render children?.()}</div>
@@ -0,0 +1,14 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
class: className,
children,
...rest
}: HTMLAttributes<HTMLHeadingElement> & { children?: import('svelte').Snippet } = $props();
</script>
<h3 class={cn('leading-none font-semibold tracking-tight', className)} {...rest}>
{@render children?.()}
</h3>
@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
class: className,
children,
...rest
}: HTMLAttributes<HTMLDivElement> & { children?: import('svelte').Snippet } = $props();
</script>
<div
class={cn('bg-card text-card-foreground rounded-xl border shadow-sm', className)}
{...rest}
>
{@render children?.()}
</div>
+14
View File
@@ -0,0 +1,14 @@
import Root from './card.svelte';
import Header from './card-header.svelte';
import Title from './card-title.svelte';
import Description from './card-description.svelte';
import Content from './card-content.svelte';
export {
Root,
Root as Card,
Header as CardHeader,
Title as CardTitle,
Description as CardDescription,
Content as CardContent
};
+2
View File
@@ -0,0 +1,2 @@
import Root from './input.svelte';
export { Root, Root as Input };
@@ -0,0 +1,19 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { HTMLInputAttributes } from 'svelte/elements';
let {
class: className,
value = $bindable(),
...rest
}: HTMLInputAttributes = $props();
</script>
<input
class={cn(
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-base shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-[3px] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
bind:value
{...rest}
/>
+2
View File
@@ -0,0 +1,2 @@
import Root from './label.svelte';
export { Root, Root as Label };
@@ -0,0 +1,15 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { HTMLLabelAttributes } from 'svelte/elements';
let {
class: className,
children,
...rest
}: HTMLLabelAttributes & { children?: import('svelte').Snippet } = $props();
</script>
<label
class={cn('text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
{...rest}>{@render children?.()}</label
>
@@ -0,0 +1,2 @@
import Root from './scroll-area.svelte';
export { Root, Root as ScrollArea };
@@ -0,0 +1,12 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
class: className,
children,
...rest
}: HTMLAttributes<HTMLDivElement> & { children?: import('svelte').Snippet } = $props();
</script>
<div class={cn('relative overflow-auto', className)} {...rest}>{@render children?.()}</div>
@@ -0,0 +1,2 @@
import Root from './separator.svelte';
export { Root, Root as Separator };
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
class: className,
orientation = 'horizontal',
...rest
}: HTMLAttributes<HTMLDivElement> & { orientation?: 'horizontal' | 'vertical' } = $props();
</script>
<div
role="separator"
class={cn(
'bg-border shrink-0',
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
className
)}
{...rest}
></div>
+16
View File
@@ -0,0 +1,16 @@
import Root from './table.svelte';
import Header from './table-header.svelte';
import Body from './table-body.svelte';
import Row from './table-row.svelte';
import Head from './table-head.svelte';
import Cell from './table-cell.svelte';
export {
Root,
Root as Table,
Header as TableHeader,
Body as TableBody,
Row as TableRow,
Head as TableHead,
Cell as TableCell
};
@@ -0,0 +1,12 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
class: className,
children,
...rest
}: HTMLAttributes<HTMLTableSectionElement> & { children?: import('svelte').Snippet } = $props();
</script>
<tbody class={cn('[&_tr:last-child]:border-0', className)} {...rest}>{@render children?.()}</tbody>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
class: className,
colspan,
rowspan,
children,
...rest
}: HTMLAttributes<HTMLTableCellElement> & {
colspan?: number;
rowspan?: number;
children?: import('svelte').Snippet;
} = $props();
</script>
<td
class={cn('p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', className)}
{colspan}
{rowspan}
{...rest}>{@render children?.()}</td
>
@@ -0,0 +1,18 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
class: className,
children,
...rest
}: HTMLAttributes<HTMLTableCellElement> & { children?: import('svelte').Snippet } = $props();
</script>
<th
class={cn(
'text-muted-foreground h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className
)}
{...rest}>{@render children?.()}</th
>
@@ -0,0 +1,12 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
class: className,
children,
...rest
}: HTMLAttributes<HTMLTableSectionElement> & { children?: import('svelte').Snippet } = $props();
</script>
<thead class={cn('[&_tr]:border-b', className)} {...rest}>{@render children?.()}</thead>
@@ -0,0 +1,15 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
class: className,
children,
...rest
}: HTMLAttributes<HTMLTableRowElement> & { children?: import('svelte').Snippet } = $props();
</script>
<tr
class={cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', className)}
{...rest}>{@render children?.()}</tr
>
@@ -0,0 +1,14 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
class: className,
children,
...rest
}: HTMLAttributes<HTMLTableElement> & { children?: import('svelte').Snippet } = $props();
</script>
<div class="relative w-full overflow-auto">
<table class={cn('w-full caption-bottom text-sm', className)} {...rest}>{@render children?.()}</table>
</div>
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+13
View File
@@ -0,0 +1,13 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
+15
View File
@@ -0,0 +1,15 @@
<script lang="ts">
import './layout.css';
import favicon from '$lib/assets/favicon.svg';
import AppShell from '$lib/AppShell.svelte';
import { Toaster } from 'svelte-sonner';
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<title>EvoBGP</title>
</svelte:head>
<Toaster richColors position="top-right" />
<AppShell>{@render children()}</AppShell>
+2
View File
@@ -0,0 +1,2 @@
export const ssr = false;
export const prerender = true;
+97
View File
@@ -0,0 +1,97 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiFetch, apiJSON } from '$lib/api/client.js';
import type { ModulesResponse, PeersResponse, RevisionsResponse } from '$lib/api/types.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
let health = $state<'…' | 'ok' | 'err'>('…');
let modulesN = $state<number | '—'>('—');
let revN = $state<number | '—'>('—');
let peersN = $state<number | '—'>('—');
onMount(async () => {
try {
const h = await apiFetch('/v1/health');
health = h.ok ? 'ok' : 'err';
} catch {
health = 'err';
}
try {
const m = await apiJSON<ModulesResponse>('/v1/modules');
modulesN = m.items?.length ?? 0;
} catch {
modulesN = '—';
}
try {
const r = await apiJSON<RevisionsResponse>('/v1/revisions?limit=100');
revN = r.items?.length ?? 0;
} catch {
revN = '—';
}
try {
const p = await apiJSON<PeersResponse>('/v1/peers');
peersN = p.items?.length ?? 0;
} catch {
peersN = '—';
}
});
</script>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Обзор</h1>
<p class="text-muted-foreground mt-1 text-sm">
Краткая сводка по API. Укажите токен в разделе «Настройки», если запросы к защищённым путям
возвращают 401.
</p>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-base">Health</CardTitle>
<CardDescription>GET /v1/health</CardDescription>
</CardHeader>
<CardContent>
{#if health === 'ok'}
<Badge>ok</Badge>
{:else if health === 'err'}
<Badge variant="destructive">недоступно</Badge>
{:else}
<span class="text-muted-foreground text-sm">проверка…</span>
{/if}
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-base">Модули</CardTitle>
<CardDescription>GET /v1/modules</CardDescription>
</CardHeader>
<CardContent>
<p class="text-2xl font-semibold">{modulesN}</p>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-base">Ревизии</CardTitle>
<CardDescription>GET /v1/revisions</CardDescription>
</CardHeader>
<CardContent>
<p class="text-2xl font-semibold">{revN}</p>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-base">Пиры</CardTitle>
<CardDescription>GET /v1/peers</CardDescription>
</CardHeader>
<CardContent>
<p class="text-2xl font-semibold">{peersN}</p>
</CardContent>
</Card>
</div>
</div>
+120
View File
@@ -0,0 +1,120 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.269 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+84
View File
@@ -0,0 +1,84 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiJSON } from '$lib/api/client.js';
import type { ModuleRow, ModulesResponse } from '$lib/api/types.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '$lib/components/ui/table/index.js';
import { toast } from 'svelte-sonner';
let rows = $state<ModuleRow[]>([]);
let err = $state<string | null>(null);
onMount(async () => {
try {
const m = await apiJSON<ModulesResponse>('/v1/modules');
rows = m.items ?? [];
} catch (e) {
err = e instanceof Error ? e.message : String(e);
toast.error(err);
}
});
</script>
<div class="space-y-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Модули префиксов</h1>
<p class="text-muted-foreground mt-1 text-sm">Список экземпляров модулей tenant (из API).</p>
</div>
<Card>
<CardHeader>
<CardTitle class="text-base">Модули</CardTitle>
<CardDescription>GET /v1/modules</CardDescription>
</CardHeader>
<CardContent>
{#if err}
<p class="text-destructive text-sm">{err}</p>
{:else}
<ScrollArea class="max-h-[min(70vh,560px)]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Тип</TableHead>
<TableHead>Приоритет</TableHead>
<TableHead>Статус</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each rows as m}
<TableRow>
<TableCell class="font-mono text-xs">{m.name}</TableCell>
<TableCell>
<Badge variant="outline">{m.type}</Badge>
</TableCell>
<TableCell>{m.priority}</TableCell>
<TableCell>
{#if m.enabled}
<Badge>вкл</Badge>
{:else}
<Badge variant="secondary">выкл</Badge>
{/if}
</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={4} class="text-muted-foreground">Нет данных</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</ScrollArea>
{/if}
</CardContent>
</Card>
</div>
+148
View File
@@ -0,0 +1,148 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiFetch, apiJSON } from '$lib/api/client.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
type Ready = { status?: string; checks?: Record<string, string> };
type Version = { api_version?: string; git_sha?: string };
let healthBody = $state<string>('');
let healthOk = $state<boolean | null>(null);
let ready = $state<Ready | null>(null);
let version = $state<Version | null>(null);
let metricsSnippet = $state<string>('');
let metricsErr = $state<string | null>(null);
onMount(async () => {
try {
const h = await apiFetch('/v1/health');
healthOk = h.ok;
const txt = await h.text();
try {
healthBody = JSON.stringify(JSON.parse(txt), null, 2);
} catch {
healthBody = txt;
}
} catch {
healthOk = false;
healthBody = '';
}
try {
ready = await apiJSON<Ready>('/v1/ready');
} catch {
ready = null;
}
try {
version = await apiJSON<Version>('/v1/version');
} catch {
version = null;
}
});
async function loadMetrics() {
metricsErr = null;
metricsSnippet = '';
try {
const r = await apiFetch('/metrics');
const t = await r.text();
if (!r.ok) {
metricsErr = `HTTP ${r.status}`;
return;
}
const lines = t.split('\n').filter((l) => l && !l.startsWith('#'));
metricsSnippet = lines.slice(0, 40).join('\n');
} catch (e) {
metricsErr = e instanceof Error ? e.message : String(e);
}
}
</script>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Мониторинг</h1>
<p class="text-muted-foreground mt-1 text-sm">
Публичные системные эндпоинты и срез Prometheus-метрик (первые строки). Полный scrape обычно делает
Prometheus.
</p>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle class="text-base">Health</CardTitle>
<CardDescription>GET /v1/health</CardDescription>
</CardHeader>
<CardContent class="space-y-2">
{#if healthOk === true}
<Badge>OK</Badge>
{:else if healthOk === false}
<Badge variant="destructive">ошибка</Badge>
{:else}
<span class="text-muted-foreground text-sm"></span>
{/if}
<pre class="bg-muted max-h-32 overflow-auto rounded-md p-3 text-xs">{healthBody || '—'}</pre>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="text-base">Ready</CardTitle>
<CardDescription>GET /v1/ready</CardDescription>
</CardHeader>
<CardContent>
{#if ready}
<pre class="bg-muted max-h-48 overflow-auto rounded-md p-3 text-xs">{JSON.stringify(
ready,
null,
2
)}</pre>
{:else}
<p class="text-muted-foreground text-sm">Недоступно</p>
{/if}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="text-base">Версия</CardTitle>
<CardDescription>GET /v1/version</CardDescription>
</CardHeader>
<CardContent>
{#if version}
<dl class="space-y-1 text-sm">
<div class="flex gap-2">
<dt class="text-muted-foreground w-28 shrink-0">api_version</dt>
<dd class="font-mono">{version.api_version ?? '—'}</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground w-28 shrink-0">git_sha</dt>
<dd class="font-mono break-all">{version.git_sha ?? '—'}</dd>
</div>
</dl>
{:else}
<p class="text-muted-foreground text-sm">Недоступно</p>
{/if}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="text-base">Метрики</CardTitle>
<CardDescription>GET /metrics (Prometheus)</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
<Button type="button" variant="secondary" size="sm" onclick={loadMetrics}>Загрузить срез</Button>
{#if metricsErr}
<p class="text-destructive text-sm">{metricsErr}</p>
{/if}
<ScrollArea class="max-h-56">
<pre class="bg-muted rounded-md p-3 font-mono text-xs whitespace-pre-wrap">{metricsSnippet ||
'Нажмите «Загрузить срез»'}</pre>
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
+118
View File
@@ -0,0 +1,118 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiJSON } from '$lib/api/client.js';
import type { PeerRow, PeersResponse, SpeakerRow, SpeakersResponse } from '$lib/api/types.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '$lib/components/ui/table/index.js';
import { toast } from 'svelte-sonner';
let peers = $state<PeerRow[]>([]);
let speakers = $state<SpeakerRow[]>([]);
let err = $state<string | null>(null);
onMount(async () => {
try {
const [p, s] = await Promise.all([
apiJSON<PeersResponse>('/v1/peers'),
apiJSON<SpeakersResponse>('/v1/speakers')
]);
peers = p.items ?? [];
speakers = s.items ?? [];
} catch (e) {
err = e instanceof Error ? e.message : String(e);
toast.error(err);
}
});
</script>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Пиры и спикеры</h1>
<p class="text-muted-foreground mt-1 text-sm">BGP-пиры и экземпляры BIRD.</p>
</div>
{#if err}
<p class="text-destructive text-sm">{err}</p>
{/if}
<Card>
<CardHeader>
<CardTitle class="text-base">Спикеры</CardTitle>
<CardDescription>GET /v1/speakers</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea class="max-h-[320px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Роль</TableHead>
<TableHead>Endpoint</TableHead>
<TableHead>Последняя ревизия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each speakers as s}
<TableRow>
<TableCell><Badge variant="outline">{s.role}</Badge></TableCell>
<TableCell class="font-mono text-xs">{s.endpoint}</TableCell>
<TableCell class="max-w-[200px] truncate font-mono text-xs" title={s.last_applied_revision_id ?? ''}
>{s.last_applied_revision_id ?? '—'}</TableCell
>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={3} class="text-muted-foreground">Нет данных</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="text-base">Пиры</CardTitle>
<CardDescription>GET /v1/peers</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea class="max-h-[min(50vh,400px)]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Neighbor</TableHead>
<TableHead>Сессия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each peers as p}
<TableRow>
<TableCell>{p.name}</TableCell>
<TableCell class="font-mono text-xs">{p.neighbor}</TableCell>
<TableCell>
<Badge variant={p.session_state === 'Established' ? 'default' : 'secondary'}
>{p.session_state}</Badge
>
</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={3} class="text-muted-foreground">Нет данных</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
</div>
+80
View File
@@ -0,0 +1,80 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiJSON } from '$lib/api/client.js';
import type { RevisionRow, RevisionsResponse } from '$lib/api/types.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '$lib/components/ui/table/index.js';
import { toast } from 'svelte-sonner';
let rows = $state<RevisionRow[]>([]);
let err = $state<string | null>(null);
onMount(async () => {
try {
const r = await apiJSON<RevisionsResponse>('/v1/revisions?limit=100');
rows = r.items ?? [];
} catch (e) {
err = e instanceof Error ? e.message : String(e);
toast.error(err);
}
});
</script>
<div class="space-y-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Ревизии</h1>
<p class="text-muted-foreground mt-1 text-sm">История конфигурации BIRD.</p>
</div>
<Card>
<CardHeader>
<CardTitle class="text-base">Ревизии</CardTitle>
<CardDescription>GET /v1/revisions</CardDescription>
</CardHeader>
<CardContent>
{#if err}
<p class="text-destructive text-sm">{err}</p>
{:else}
<ScrollArea class="max-h-[min(70vh,560px)]">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Хэш</TableHead>
<TableHead>Префиксы</TableHead>
<TableHead>Создана</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each rows as r}
<TableRow>
<TableCell class="max-w-[140px] truncate font-mono text-xs" title={r.id}
>{r.id}</TableCell
>
<TableCell class="max-w-[180px] truncate font-mono text-xs" title={r.content_hash}
>{r.content_hash}</TableCell
>
<TableCell>{r.materialized_prefix_count ?? '—'}</TableCell>
<TableCell class="whitespace-nowrap text-xs">{r.created_at}</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={4} class="text-muted-foreground">Нет данных</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</ScrollArea>
{/if}
</CardContent>
</Card>
</div>
+161
View File
@@ -0,0 +1,161 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiFetch, apiJSON } from '$lib/api/client.js';
import type { JobsResponse, ModuleRow, ModulesResponse } from '$lib/api/types.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '$lib/components/ui/table/index.js';
import { toast } from 'svelte-sonner';
let modules = $state<ModuleRow[]>([]);
let jobs = $state<JobsResponse['items']>([]);
let loading = $state<Record<string, boolean>>({});
async function load() {
try {
const m = await apiJSON<ModulesResponse>('/v1/modules');
modules = m.items ?? [];
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
}
try {
const j = await apiJSON<JobsResponse>('/v1/jobs?limit=50');
jobs = j.items ?? [];
} catch {
jobs = [];
}
}
onMount(load);
async function refreshModule(id: string) {
loading = { ...loading, [id]: true };
try {
const res = await apiFetch(`/v1/modules/${id}/refresh`, { method: 'POST' });
if (res.status === 204) {
toast.message('Refresh не требуется (например IP_RANGES)');
} else if (res.status === 202) {
toast.success('Задача поставлена в очередь');
await load();
} else {
const t = await res.text();
toast.error(t || `HTTP ${res.status}`);
}
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
loading = { ...loading, [id]: false };
}
}
function intervalLabel(sec: number) {
if (!sec) return '—';
if (sec % 3600 === 0) return `${sec / 3600} ч`;
if (sec % 60 === 0) return `${sec / 60} мин`;
return `${sec} с`;
}
</script>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Расписание и задачи</h1>
<p class="text-muted-foreground mt-1 text-sm">
Интервалы обновления модулей (поля из API) и ручной refresh → ingest. Ниже — последние задачи из
<code class="bg-muted rounded px-1 text-xs">job_audit</code>.
</p>
</div>
<Card>
<CardHeader>
<CardTitle class="text-base">Модули и refresh</CardTitle>
<CardDescription>
POST /v1/modules/&#123;id&#125;/refresh (роль editor+). CDN/домены/AS — очередь; IP_RANGES — 204.
</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea class="max-h-[min(55vh,480px)]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Модуль</TableHead>
<TableHead>Тип</TableHead>
<TableHead>Интервал</TableHead>
<TableHead>Cron</TableHead>
<TableHead class="text-right">Действие</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each modules as m}
<TableRow>
<TableCell>{m.name}</TableCell>
<TableCell>
<Badge variant="outline">{m.type}</Badge>
</TableCell>
<TableCell>{intervalLabel(m.refresh_interval_sec)}</TableCell>
<TableCell class="font-mono text-xs">{m.cron_expr || '—'}</TableCell>
<TableCell class="text-right">
<Button
size="sm"
variant="secondary"
disabled={!!loading[m.id]}
onclick={() => refreshModule(m.id)}
>
{loading[m.id] ? '…' : 'Refresh'}
</Button>
</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={5} class="text-muted-foreground">Нет модулей</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="text-base">Последние задачи</CardTitle>
<CardDescription>GET /v1/jobs</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea class="max-h-[min(45vh,360px)]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Вид</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Создана</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each jobs as j}
<TableRow>
<TableCell class="font-mono text-xs">{j.kind}</TableCell>
<TableCell>
<Badge variant="outline">{j.status}</Badge>
</TableCell>
<TableCell class="text-xs whitespace-nowrap">{j.created_at ?? '—'}</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={3} class="text-muted-foreground">Нет задач</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
</div>
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
import { browser } from '$app/environment';
import { TOKEN_STORAGE_KEY } from '$lib/api/client.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { toast } from 'svelte-sonner';
let token = $state('');
$effect(() => {
if (browser) {
token = localStorage.getItem(TOKEN_STORAGE_KEY) ?? '';
}
});
function save() {
if (!browser) return;
const t = token.trim();
if (t) localStorage.setItem(TOKEN_STORAGE_KEY, t);
else localStorage.removeItem(TOKEN_STORAGE_KEY);
toast.success('Токен сохранён в этом браузере');
}
</script>
<div class="mx-auto max-w-lg space-y-6">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Настройки</h1>
<p class="text-muted-foreground mt-1 text-sm">
Bearer-токен для заголовка <code class="bg-muted rounded px-1 py-0.5 text-xs">Authorization</code>. Для
локального демо с
<code class="bg-muted rounded px-1 py-0.5 text-xs">EVOBGP_DEV_INSECURE=1</code>
можно использовать токен <code class="bg-muted rounded px-1 py-0.5 text-xs">dev</code>.
</p>
</div>
<Card>
<CardHeader>
<CardTitle class="text-base">API-ключ</CardTitle>
<CardDescription>Хранится только в localStorage</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="token">Токен</Label>
<Input id="token" type="password" autocomplete="off" bind:value={token} placeholder="Bearer …" />
</div>
<Button type="button" onclick={save}>Сохранить</Button>
</CardContent>
</Card>
</div>