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.
This commit is contained in:
Vendored
+13
@@ -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 {};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
export const ssr = false;
|
||||
export const prerender = true;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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/{id}/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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user