@@ -1,21 +1,54 @@
< script lang = "ts" >
import { onMount } from 'svelte' ;
import { resolve } from '$app/paths' ;
import { apiJSON , apiFetch } from '$lib/api/client.js' ;
import type { BirdStatus , JobsResponse } from '$lib/api/types.js' ;
import type { BirdStatus , JobRow , JobsResponse } from '$lib/api/types.js' ;
import {
formatVersionHeadline ,
resolveVersionString ,
type VersionInfo
} from '$lib/api/version-info.js' ;
import { Badge } from '$lib/components/ui/badge/index .js' ;
import { Button } from '$lib/components/ui/button/index .js' ;
import { jobKindTitle } from '$lib/operations/job-kind-label .js' ;
import { jobStatusRu } from '$lib/ui-labels .js' ;
import {
checkDisplayName ,
checkStatusBadge ,
deriveOverallStatus ,
livenessBadge ,
overallBadgeClass ,
overallBadgeVariant ,
overallStatusHint ,
overallStatusLabel ,
readinessBadge ,
summarizeJobsStatuses ,
toErrorMessage ,
type HealthStatus ,
type JobsKpi ,
type ReadyStatus
} from '$lib/monitoring/status.js' ;
import { Badge } from '$lib/ui/core/badge/index.js' ;
import { Button } from '$lib/ui/core/button/index.js' ;
import {
Card ,
CardContent ,
CardHeader ,
CardTitle ,
CardDescription
} from '$lib/components/ui /card/index.js' ;
} from '$lib/ui/core /card/index.js' ;
import { Alert , AlertDescription , AlertTitle } from '$lib/ui/core/alert/index.js' ;
import { Separator } from '$lib/ui/core/separator/index.js' ;
import {
Table ,
TableBody ,
TableCell ,
TableHead ,
TableHeader ,
TableRow
} from '$lib/ui/core/table/index.js' ;
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte' ;
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte' ;
import PageHeader from '$lib/ui/app/page-header/page-header.svelte' ;
import { cn } from '$lib/utils.js' ;
import RefreshCw from '@lucide/svelte/icons/refresh-cw' ;
import Gauge from '@lucide/svelte/icons/gauge' ;
import Activity from '@lucide/svelte/icons/activity' ;
@@ -23,80 +56,86 @@
import Server from '@lucide/svelte/icons/server' ;
import Hash from '@lucide/svelte/icons/hash' ;
import AlertTriangle from '@lucide/svelte/icons/alert-triangle' ;
import PageHeader from '$lib/ui/app/page-header/page-header.svelt e' ;
import CheckCircle from '@lucide/svelte/icons/check-circl e' ;
import XCircle from '@lucide/svelte/icons/x-circle' ;
import ArrowRight from '@lucide/svelte/icons/arrow-right' ;
import HeartPulse from '@lucide/svelte/icons/heart-pulse' ;
import ShieldCheck from '@lucide/svelte/icons/shield-check' ;
import Database from '@lucide/svelte/icons/database' ;
import HardDrive from '@lucide/svelte/icons/hard-drive' ;
import ListTodo from '@lucide/svelte/icons/list-todo' ;
import type { Component } from 'svelte' ;
type ReadyStatus = { status : string ; checks? : Record < string , unknown > } ;
type JobsKpi = { running : number ; failed : number ; total : number };
let health = $state < { ok : boolean ; status? : string ; error? : string } | null > ( null );
let health = $state < HealthStatus | null >( null ) ;
let ready = $state < ReadyStatus | null >( null );
let version = $state < VersionInfo | null >( null );
let bird = $state < BirdStatus | null >( null );
let jobs = $state < JobsKpi | null >( null );
let jobItems = $state < JobRow [] >([]);
let readyError = $state < string | null >( null );
let versionError = $state < string | null >( null );
let birdError = $state < string | null >( null );
let jobsError = $state < string | null >( null );
let lastUpdated = $state < Date | null >( null );
let loading = $state ( false );
let initialLoading = $state ( true );
let refreshing = $state ( false );
function toErrorMessage ( error : unknown ) : string {
if ( error instanceof Error && error . message . trim () !== '' ) return error . message ;
return 'Ошибка запроса ';
}
function summarizeJobsStatuses ( items : Array < { status? : string } > ) : JobsKpi {
let running = 0 ;
let failed = 0 ;
for ( const row of items ) {
const status = String ( row . status ?? '' ). toLowerCase ();
if ( status === 'queued' || status === 'running' || status === 'cancel_requested' ) {
running += 1 ;
}
if ( status === 'failed' || status === 'error' || status === 'canceled' ) {
failed += 1 ;
}
const statAccents = [
{
border : 'border-l-chart-1 ',
bg : 'bg-chart-1/5' ,
iconBg : 'bg-chart-1/15' ,
iconText : 'text-chart-1'
},
{
border : 'border-l-chart-2' ,
bg : 'bg-chart-2/5' ,
iconBg : 'bg-chart-2/15' ,
iconText : 'text-chart-2'
},
{
border : 'border-l-chart-4' ,
bg : 'bg-chart-4/5' ,
iconBg : 'bg-chart-4/15' ,
iconText : 'text-chart-4'
},
{
border : 'border-l-info' ,
bg : 'bg-info/10' ,
iconBg : 'bg-info/15' ,
iconText : 'text-info'
}
return { running , failed , total : items.length } ;
}
] as const ;
function overallBadgeVariant ( status : 'ok' | 'warn' | 'error' | 'unknown' ) {
if ( status === 'ok' ) return 'default' ;
if ( status === 'warn' ) return 'secondary' ;
if ( status === 'error' ) return 'destructive' ;
return 'outline' ;
}
const overallStatus = $derived . by (() =>
deriveOverallStatus ({
health ,
ready ,
jobs ,
birdConfigured : bird?.birdc_configured ?? false ,
birdHealthy : bird?.healthy ?? null
})
);
const overallStatus = $derived . by (() => {
if ( health === null && ready === null && bird === null && jobs === null ) return 'unknown' ;
if ( ! health ? . ok ) return 'error' ;
if ( ready !== null && ready . status !== 'ready' ) return 'error' ;
if ( jobs !== null && jobs . failed > 0 ) return 'warn' ;
if ( bird !== null && bird . birdc_configured && bird . healthy === false ) return 'warn' ;
return 'ok' ;
});
const overallHint = $derived . by (() => {
if ( overallStatus === 'unknown' ) return 'Нет данных. Запустите обновление.' ;
if ( overallStatus === 'error' ) {
if ( ! health ? . ok ) return 'Проверьте доступность API и логи сервиса.' ;
return 'Readiness не в норме: проверьте postgres/store/jobs.' ;
}
if ( overallStatus === 'warn' ) {
if ( jobs && jobs . failed > 0 )
return 'Есть ошибки в задачах: откройте операции и последние jobs.' ;
return 'Проверьте BGP-сессии и вывод birdc.' ;
}
return 'Критичных отклонений не обнаружено.' ;
});
const overallHint = $derived . by (() =>
overallStatusHint ( overallStatus , {
healthOk : health?.ok ,
jobsFailed : jobs?.failed ?? 0
})
) ;
const bgpText = $derived . by (() => {
if ( ! bird ) return '—' ;
if ( ! bird . birdc_configured ) return 'birdc не настроен ' ;
if ( bird . error ) return 'ошибка birdc ' ;
if ( ! bird . birdc_configured ) return '— ' ;
if ( bird . error ) return '— ' ;
return ` ${ bird . bgp_established } / ${ bird . bgp_sessions_total } ` ;
});
const bgpRatio = $derived . by (() => {
if ( ! bird ? . birdc_configured || bird . bgp_sessions_total <= 0 ) return null ;
return Math . round (( bird . bgp_established / bird . bgp_sessions_total ) * 100 );
});
const versionText = $derived ( formatVersionHeadline ( version ));
const versionFooter = $derived . by (() => {
@@ -109,46 +148,156 @@
return parts . join ( ' · ' );
});
const failedJobs = $derived . by (() =>
jobItems
. filter (( j ) => {
const s = String ( j . status ?? '' ). toLowerCase ();
return s === 'failed' || s === 'error' || s === 'canceled' ;
})
. slice ( 0 , 5 )
);
const checkIconByKey : Record < string , Component > = {
postgres : Database ,
store : HardDrive ,
jobs : ListTodo
};
const kpiCards = $derived . by (() => [
{
id : 'overall' ,
label : 'Общий статус' ,
value : initialLoading ? '—' : overallStatusLabel ( overallStatus ),
description : overallHint ,
icon : Server ,
accent : statAccents [ 0 ],
badge : overallStatusLabel ( overallStatus ),
badgeVariant : overallBadgeVariant ( overallStatus ),
badgeClass : overallBadgeClass ( overallStatus ),
error : null as string | null ,
href : undefined
},
{
id : 'bgp' ,
label : 'BGP сессии' ,
value : initialLoading ? '—' : bgpText ,
description : bird?.birdc_configured
? 'Established / total на API-хосте'
: ( bird ? . message ?? 'birdc не настроен на API-хосте' ),
icon : Bird ,
accent : statAccents [ 1 ],
badge : ! bird ? '—' : ! bird . birdc_configured ? 'N/A' : bird . healthy ? 'В норме' : 'Деградация' ,
badgeVariant : ! bird ? . birdc_configured
? ( 'outline' as const )
: bird ? . healthy
? ( 'default' as const )
: ( 'secondary' as const ),
badgeClass :
bird?.birdc_configured && bird ? . healthy
? 'border-success/30 bg-success/15 text-success'
: undefined ,
error : birdError ?? bird ? . error ?? null ,
href : '/network' as const
},
{
id : 'jobs' ,
label : 'Задачи' ,
value : initialLoading ? '—' : String ( jobs ? . running ?? '—' ),
description : `Активных из ${ jobs ? . total ?? '—' } последних` ,
icon : Activity ,
accent : statAccents [ 2 ],
badge : jobs && jobs . failed > 0 ? `ошибок ${ jobs . failed } ` : 'без ошибок' ,
badgeVariant : jobs && jobs . failed > 0 ? ( 'secondary' as const ) : ( 'default' as const ),
badgeClass :
jobs && jobs . failed === 0 ? 'border-success/30 bg-success/15 text-success' : undefined ,
error : jobsError ,
href : jobs && jobs . failed > 0 ? ( '/operations' as const ) : undefined
},
{
id : 'version' ,
label : 'Версия' ,
value : initialLoading ? '—' : versionText ,
description : versionFooter || 'GET /v1/version' ,
icon : Hash ,
accent : statAccents [ 3 ],
badge : version ? 'Загружена' : '—' ,
badgeVariant : version ? ( 'outline' as const ) : ( 'secondary' as const ),
badgeClass : undefined ,
error : versionError ,
href : undefined
}
]);
async function fetchHealth () : Promise < HealthStatus > {
const h = await apiFetch ( '/v1/health' );
const hj = ( await h . json (). catch (() => ({}))) as { status? : string };
return { ok : h.ok , status : hj?.status , error : h.ok ? undefined : `HTTP ${ h . status } ` };
}
async function load() {
loading = true ;
if ( initialLoading ) {
/* keep skeleton */
} else {
refreshing = true ;
}
readyError = null ;
versionError = null ;
birdError = null ;
jobsError = null ;
try {
const h = await apiFetch ( '/v1/health' );
const hj = await h . json (). catch (() => ({}));
health = { ok : h.ok , status : hj?.status , error : h.ok ? undefined : `HTTP ${ h . status } ` };
} catch ( error ) {
health = { ok : false , error : toErrorMessage ( error ) };
const [ healthRes , readyRes , versionRes , birdRes , jobsRes ] = await Promise . allSettled ([
fetchHealth (),
apiJSON < ReadyStatus >( '/v1/ready' ),
apiJSON < VersionInfo >( '/v1/version' ),
apiJSON < BirdStatus >( '/v1/bird/status' ),
apiJSON < JobsResponse >( '/v1/jobs?limit=100' )
]);
if ( healthRes . status === 'fulfilled' ) {
health = healthRes . value ;
} else {
health = { ok : false , error : toErrorMessage ( healthRes . reason ) };
}
try {
ready = await apiJSON < ReadyStatus >( '/v1/ready' );
} catch ( error ) {
if ( readyRes . status === 'fulfilled' ) {
ready = readyRes . value ;
} else {
ready = null ;
readyError = toErrorMessage ( error );
readyError = toErrorMessage ( readyRes . reason );
}
try {
version = await apiJSON < VersionInfo >( '/v1/version' );
} catch ( error ) {
if ( versionRes . status === 'fulfilled' ) {
version = versionRes . value ;
} else {
version = null ;
versionError = toErrorMessage ( error );
versionError = toErrorMessage ( versionRes . reason );
}
try {
bird = await apiJSON < BirdStatus >( '/v1/bird/status' );
} catch ( error ) {
if ( birdRes . status === 'fulfilled' ) {
bird = birdRes . value ;
} else {
bird = null ;
birdError = toErrorMessage ( error );
birdError = toErrorMessage ( birdRes . reason );
}
try {
const jobsPage = await apiJSON < JobsResponse >( '/v1/jobs?limit=100' );
jobs = summarizeJobsStatuses ( jobsPage . items ?? []) ;
} catch ( error ) {
if ( jobsRes . status === 'fulfilled' ) {
const items = jobsRes . value . items ?? [];
jobItems = items ;
jobs = summarizeJobsStatuses ( items );
} else {
jobItems = [];
jobs = null ;
jobsError = toErrorMessage ( error );
jobsError = toErrorMessage ( jobsRes . reason );
}
lastUpdated = new Date ();
loading = false ;
initialLoading = false ;
refreshing = false ;
}
function truncateError ( error : string | null | undefined , max = 120 ) : string {
if ( ! error ) return '' ;
return error . length > max ? ` ${ error . slice ( 0 , max ) } …` : error ;
}
onMount ( load );
@@ -164,189 +313,394 @@
iconClass = "bg-info/15 text-info"
>
{ # snippet actions ()}
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
<RefreshCw class={loading ? 'animate-spin' : ''} />
< Button variant = "outline" size = "sm" onclick = { load } disabled= { refreshing } >
< RefreshCw class = { refreshing ? 'animate-spin' : '' } / >
Обновить
</ Button >
{ /snippet }
</ PageHeader >
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Card class="border-l-4 border-l-chart-1 bg-chart-1/5 shadow-sm">
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Server class="size-4" />
Общий статус
</CardTitle>
<CardDescription>Liveness + readiness + jobs/BGP</CardDescription>
</CardHeader>
<CardContent>
<div class="flex items-center justify-between gap-2">
<p class="text-2xl font-semibold uppercase">{overallStatus}</p>
<Badge variant={overallBadgeVariant(overallStatus)}>{overallStatus}</Badge>
</div>
<p class="mt-2 text-xs text-muted-foreground">{overallHint}</p>
</CardContent>
</Card>
{ #if ! initialLoading }
{ #if overallStatus === 'ok' }
< Alert class = "border-success/30 bg-success/5" >
< CheckCircle class = "text-success" />
< AlertTitle > Система в норме</ AlertTitle >
< AlertDescription > { overallHint } </ AlertDescription >
</ Alert >
{ :else if overallStatus === 'warn' }
< Alert class = "border-warning/30 bg-warning/5" >
< AlertTriangle class = "text-warning" />
< AlertTitle > Требуется внимание</ AlertTitle >
< AlertDescription > { overallHint } </ AlertDescription >
</ Alert >
{ :else if overallStatus === 'error' }
< Alert variant = "destructive" >
< XCircle />
< AlertTitle > Обнаружена проблема</ AlertTitle >
< AlertDescription > { overallHint } </ AlertDescription >
</ Alert >
{ /if }
{ /if }
<Card class="border-l-4 border-l-chart-2 bg-chart-2/5 shadow-sm">
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Bird class="size-4" />
BGP сессии
</CardTitle>
<CardDescription>GET /v1/bird/status</CardDescription>
</CardHeader>
<CardContent>
<div class="flex items-center justify-between gap-2">
<p class="text-2xl font-semibold">{bgpText}</p>
{#if bi rd?.birdc_configured}
<Badge variant={bird.healthy ? 'default' : 'secondary'}
>{bird.healthy ? 'healthy' : 'degraded'}</Badge
< div class = "grid gap-4 sm:grid-cols-2 xl:grid-cols-4" >
{ #if initialLoading }
{ #each Array ( 4 ) as _ , i ( i )}
< CardSkeleton />
{ /each }
{ : else }
{ #each kpiCards as card ( card . id )}
{ @const Icon = card . icon }
{ @const a = card . accent }
< Card
class = { cn (
'overflow-hidden bo rder-l-4 shadow-sm transition-colors' ,
a . border ,
a . bg ,
card . href ? 'hover:border-primary/35' : ''
)}
>
< CardHeader class = "pb-2" >
< div class = "flex items-center justify-between gap-2" >
< CardDescription class = "flex min-w-0 items-center gap-2" >
< span
class = { cn (
'flex size-9 shrink-0 items-center justify-center rounded-lg' ,
a . iconBg
)}
aria-hidden="true"
>
< Icon class = { cn ( 'size-4' , a . iconText )} / >
</ span >
< span class = "truncate" > { card . label } </ span >
</ CardDescription >
{ #if card . href }
< Button variant = "ghost" size = "icon-sm" href = { resolve ( card . href )} >
< ArrowRight class = "size-3.5" aria-hidden = "true" />
</ Button >
{ /if }
</ div >
< CardTitle
class = { cn (
'font-bold tabular-nums' ,
card . id === 'version' ? 'font-mono text-xl' : 'text-3xl'
)}
>
{:else}
<Badge variant="outline">n/a</Badge>
{/if}
</div>
{#if birdError}
<p class="mt-2 text-xs text-destructive">{birdError}</p>
{:else if bird && !bird.birdc_configured}
<p class="mt-2 text-xs text-muted-foreground">
{bird.message ?? 'birdc не настроен на API-хосте.'}
</p>
{:else if bird?.error}
<p class="mt-2 text-xs text-destructive">{bird.error}</p>
{:else}
<p class="mt-2 text-xs text-muted-foreground">Established / total на API-хосте.</p>
{/if}
</CardContent>
</Card>
<Card class="border-l-4 border-l-chart-4 bg-chart-4/5 shadow-sm">
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Activity class="size-4" />
Задачи
</CardTitle>
<CardDescription>GET /v1/jobs?limit=100</CardDescription>
</CardHeader>
<CardContent>
<div class="flex items-center justify-between gap-2">
<p class="text-2xl font-semibold">{jobs ? jobs.running : '—'}</p>
<Badge variant={jobs && jobs.failed > 0 ? 'secondary' : 'default'}>
{jobs && jobs.failed > 0 ? ` ошибок $ { jobs . failed } ` : 'без ошибок'}
</Badge>
</div>
{#if jobsError}
<p class="mt-2 text-xs text-destructive">{jobsError}</p>
{:else}
<p class="mt-2 text-xs text-muted-foreground">
Активных: {jobs?.running ?? '—'} из {jobs?.total ?? '—'} последних.
</p>
{/if}
</CardContent>
</Card>
<Card class="border-l-4 border-l-info bg-info/10 shadow-sm">
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Hash class="size-4" />
Версия
</CardTitle>
<CardDescription>GET /v1/version</CardDescription>
</CardHeader>
<CardContent>
<div class="flex items-center justify-between gap-2">
<p class="font-mono text-2xl font-semibold">{versionText}</p>
<Badge variant={version ? 'outline' : 'secondary'}>{version ? 'loaded' : '—'}</Badge>
</div>
{#if versionError}
<p class="mt-2 text-xs text-destructive">{versionError}</p>
{:else if versionFooter}
<p class="mt-2 truncate text-xs text-muted-foreground">{versionFooter}</p>
{/if}
</CardContent>
</Card>
{ card . value }
</ CardTitle >
</ CardHeader >
< CardContent class = "space-y-2" >
< div class = "flex items-center justify-between gap-2" >
< Badge variant = { card . badgeVariant } class= { card . badgeClass } > { card . badge } </ Badge >
</ div >
{ #if card . error }
< p class = "text-xs text-destructive" > { card . error } </ p >
{ : else }
< p class = "text-xs text-muted-foreground" > { card . description } </ p >
{ /if }
</ CardContent >
</ Card >
{ /each }
{ /if }
</ div >
< div class = "grid gap-4 lg:grid-cols-2" >
<Card>
<CardHeader>
<CardTitle class="text-base">Доступность и готовность</CardTitle>
<CardDescription>GET /v1/health и GET /v1/ready</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">Liveness:</span>
{#if health === null}
<Badge variant="outline">—</Badge>
{:else if health.ok}
<Badge variant="default">ok</Badge>
{:else}
<Badge variant="destructive">failed</Badge>
{ #if initialLoading }
< CardSkeleton />
< CardSkeleton />
{ : else }
< Card >
< CardHeader >
< CardTitle class = "text-base" > Доступность и готовность</ CardTitle >
< CardDescription > GET /v1/health · GET /v1/ready</ CardDescription >
</ CardHeader >
< CardContent class = "space-y-4" >
{ #if health ? . error || readyError }
< Alert variant = "destructive" >
< XCircle />
< AlertTitle > Ошибка проверки</ AlertTitle >
< AlertDescription >
{ #if health ? . error }{ health . error }{ /if }
{ #if health ? . error && readyError } < br /> { /if }
{ #if readyError }{ readyError }{ /if }
</ AlertDescription >
</ Alert >
{ /if }
{#if health?.status}
<span class="text-muted-foreground">{health.status}</span>
{/if}
</div>
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">Readiness:</span>
{#if ready === null}
<Badge variant="outline">—</Badge>
{:else}
<Badge variant={ready.status === 'ready' ? 'default' : 'destructive'}
>{ready.status}</Badge
>
{/if}
</div>
< Table >
< TableHeader >
< TableRow >
< TableHead class = "w-[55%]" > Проверка</ TableHead >
< TableHead > Статус</ TableHead >
</ TableRow >
</ TableHeader >
< TableBody >
{ @const liveBadge = livenessBadge ( health )}
< TableRow >
< TableCell >
< div class = "flex items-center gap-2" >
< HeartPulse class = "size-4 shrink-0 text-muted-foreground" aria-hidden = "true" />
< div >
< p class = "text-sm font-medium" > Liveness</ p >
< p class = "text-xs text-muted-foreground" > /v1/health</ p >
</ div >
</ div >
</ TableCell >
< TableCell >
< Badge variant = { liveBadge . variant } class= { liveBadge . class }
> { liveBadge . label } </ Badge
>
</ TableCell >
</ TableRow >
{ @const readyBadge = readinessBadge ( ready )}
< TableRow >
< TableCell >
< div class = "flex items-center gap-2" >
< ShieldCheck class = "size-4 shrink-0 text-muted-foreground" aria-hidden = "true" />
< div >
< p class = "text-sm font-medium" > Readiness</ p >
< p class = "text-xs text-muted-foreground" > /v1/ready</ p >
</ div >
</ div >
</ TableCell >
< TableCell >
< Badge variant = { readyBadge . variant } class= { readyBadge . class }
> { readyBadge . label } </ Badge
>
</ TableCell >
</ TableRow >
{ #if ready ? . checks && Object . keys ( ready . checks ). length > 0 }
< TableRow >
< TableCell colspan = { 2 } class="bg-muted/30 py-2 " >
< p class = "text-xs font-medium text-muted-foreground" > Зависимости</ p >
</ TableCell >
</ TableRow >
{ #each Object . entries ( ready . checks ) as [ key , value ] ( key )}
{ @const badge = checkStatusBadge ( value )}
{ @const CheckIcon = checkIconByKey [ key ] ?? ListTodo }
< TableRow >
< TableCell >
< div class = "flex items-center gap-2" >
< CheckIcon
class = "size-4 shrink-0 text-muted-foreground"
aria-hidden = "true"
/>
< div >
< p class = "text-sm font-medium" > { checkDisplayName ( key )} </ p >
< p class = "text-xs text-muted-foreground" > { key } </ p >
</ div >
</ div >
</ TableCell >
< TableCell >
< div class = "space-y-1" >
< Badge variant = { badge . variant } class= { badge . class } > { badge . label } </ Badge >
{ #if badge . hint }
< p class = "text-xs text-muted-foreground" > { badge . hint } </ p >
{ /if }
</ div >
</ TableCell >
</ TableRow >
{ /each }
{ /if }
</ TableBody >
</ Table >
{#if health?.error}
<p class="text-xs text-destructive">{health.error}</p>
{/if}
{#if readyError}
<p class="text-xs text-destructive">{readyError}</p>
{/if}
< p class = "text-xs text-muted-foreground" >
HTTP 503 на readiness означает недоступность одной из зависимостей в checks.
</ p >
</ CardContent >
</ Card >
{#if ready?.checks}
<div class="space-y-1">
{#each Object.entries(ready.checks) as [k, v] (k)}
<div class="grid grid-cols-[auto_1fr] items-center gap-2 text-xs">
<span class="text-muted-foreground">{k}</span>
<Badge variant="outline" class="justify-start font-normal">{String(v)}</Badge>
</div>
{/each}
< Card >
< CardHeader >
< div class = "flex items-start justify-between gap-2" >
< div >
< CardTitle class = "flex items-center gap-2 text-base" >
< Bird class = "size-4" />
BGP на API-хосте
</ CardTitle >
< CardDescription > GET /v1/bird/status</ CardDescription >
</ div >
< Button variant = "outline" size = "sm" href = { resolve ( '/network' )} > Пиры и спикеры </ Button >
</ div >
{/if}
</CardContent>
</Card>
</ CardHeader >
< CardContent class = "space-y-4" >
{ #if birdError }
< Alert variant = "destructive" >
< XCircle />
< AlertTitle > Ошибка birdc</ AlertTitle >
< AlertDescription > { birdError } </ AlertDescription >
</ Alert >
{ :else if bird && ! bird . birdc_configured }
< p class = "text-sm text-muted-foreground" >
{ bird . message ?? 'birdc не настроен на API-хосте (EVOBGP_BIRDC_SOCKET).' }
</ p >
{ :else if bird }
< div class = "space-y-2" >
< div class = "flex items-center justify-between text-sm" >
< span class = "text-muted-foreground" > Established / total</ span >
< span class = "font-medium tabular-nums" >
{ bird . bgp_established } / { bird . bgp_sessions_total }
{ #if bgpRatio !== null }
< span class = "text-muted-foreground" > ({ bgpRatio } %)</ span >
{ /if }
</ span >
</ div >
{ #if bgpRatio !== null }
< div class = "h-2 overflow-hidden rounded-full bg-muted" >
< div
class = { cn (
'h-full rounded-full transition-all' ,
bgpRatio >= 100
? 'bg-success'
: bgpRatio >= 50
? 'bg-warning'
: 'bg-destructive'
)}
style="width: { bgpRatio } %"
></ div >
</ div >
{ /if }
{ #if bird . error }
< p class = "text-xs text-destructive" > { bird . error } </ p >
{ /if }
</ div >
{ #if bird . protocols_excerpt }
< Separator />
< div class = "space-y-2" >
< p class = "text-sm font-medium" > Вывод birdc (protocols)</ p >
< ScrollPreBlock variant = "preserve" text = { bird . protocols_excerpt } class="max-h-48" />
</ div >
{ /if }
{ /if }
</ CardContent >
</ Card >
{ /if }
</ div >
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<AlertTriangle class="size-4 text-muted-foreground" />
Что проверять при деградации
</CardTitle>
<CardDescription>Короткая шпаргалка для triage</CardDescription>
</CardHeader>
<CardContent class="space-y-2 text-sm">
<p>
<span class="text-muted-foreground">Если </span><code>health=failed</code> — проверьте доступность
процесса API и его логи.
</p>
<p>
<span class="text-muted-foreground">Если </span><code>ready!=ready</code> — сначала ` postgres `,
затем ` store / jobs ` в checks.
</p>
<p>
<span class="text-muted-foreground">Если low BGP ratio</span> — проверьте ` bird / status `, затем
BGP peer состояния.
</p>
<p>
<span class="text-muted-foreground">Если ошибки jobs</span> — откройте операции и проверьте
последние неуспешные задачи.
</p>
</CardContent>
</Card>
< div class = "grid gap-4 lg:grid-cols-2" >
{ #if initialLoading }
< CardSkeleton />
< CardSkeleton />
{ : else }
< Card >
< CardHeader >
< div class = "flex items-start justify-between gap-2" >
< div >
< CardTitle class = "flex items-center gap-2 text-base" >
< Activity class = "size-4" />
Задачи
</ CardTitle >
< CardDescription > Последние 100 задач · GET /v1/jobs</ CardDescription >
</ div >
< Button variant = "outline" size = "sm" href = { resolve ( '/operations' )} > Все операции </ Button >
</ div >
</ CardHeader >
< CardContent class = "space-y-4" >
{ #if jobsError }
< Alert variant = "destructive" >
< XCircle />
< AlertTitle > Не удалось загрузить задачи</ AlertTitle >
< AlertDescription > { jobsError } </ AlertDescription >
</ Alert >
{ :else if jobs }
< div class = "flex flex-wrap gap-4 text-sm" >
< div >
< p class = "text-muted-foreground" > Активных</ p >
< p class = "text-2xl font-bold tabular-nums" > { jobs . running } </ p >
</ div >
< div >
< p class = "text-muted-foreground" > С ошибками</ p >
< p
class = { cn (
'text-2xl font-bold tabular-nums' ,
jobs . failed > 0 ? 'text-warning' : 'text-success'
)}
>
{ jobs . failed }
</ p >
</ div >
< div >
< p class = "text-muted-foreground" > В выборке</ p >
< p class = "text-2xl font-bold tabular-nums" > { jobs . total } </ p >
</ div >
</ div >
< Separator />
{ #if failedJobs . length > 0 }
< div class = "space-y-3" >
< p class = "text-sm font-medium" > Последние ошибки</ p >
< ul class = "space-y-2" >
{ #each failedJobs as job ( job . job_id )}
< li class = "rounded-lg border px-3 py-2 text-sm" >
< div class = "flex items-start justify-between gap-2" >
< p class = "font-medium" > { jobKindTitle ( job )} </ p >
< Badge variant = "destructive" > { jobStatusRu ( job . status )} </ Badge >
</ div >
{ #if job . error }
< p class = "mt-1 text-xs text-muted-foreground" >
{ truncateError ( job . error )}
</ p >
{ /if }
</ li >
{ /each }
</ ul >
</ div >
{ : else }
< p class = "text-sm text-muted-foreground" >
Критичных сбоев в последних 100 задачах нет.
</ p >
{ /if }
{ /if }
</ CardContent >
</ Card >
< Card >
< CardHeader >
< CardTitle class = "flex items-center gap-2 text-base" >
< AlertTriangle class = "size-4 text-muted-foreground" />
Что проверять при деградации
</ CardTitle >
< CardDescription > Короткая шпаргалка для triage</ CardDescription >
</ CardHeader >
< CardContent class = "space-y-3" >
< Alert >
< HeartPulse class = "size-4" />
< AlertTitle > API недоступен</ AlertTitle >
< AlertDescription >
Если < code class = "text-xs" > /v1/health</ code > возвращает ошибку — проверьте процесс API и
его логи.
</ AlertDescription >
</ Alert >
< Alert >
< Database class = "size-4" />
< AlertTitle > Readiness не «Готов»</ AlertTitle >
< AlertDescription >
Сначала < code class = "text-xs" > postgres</ code > , затем
< code class = "text-xs" > store</ code > и < code class = "text-xs" > jobs</ code > в checks.
</ AlertDescription >
</ Alert >
< Alert >
< Bird class = "size-4" />
< AlertTitle > Низкий ratio BGP</ AlertTitle >
< AlertDescription >
Проверьте < code class = "text-xs" > /v1/bird/status</ code > , затем состояние пиров в
< Button variant = "link" class = "h-auto p-0" href = { resolve ( '/network' )} > Сети</Button > .
</ AlertDescription >
</ Alert >
< Alert >
< ListTodo class = "size-4" />
< AlertTitle > Ошибки задач</ AlertTitle >
< AlertDescription >
Откройте
< Button variant = "link" class = "h-auto p-0" href = { resolve ( '/operations' )}
> Операции</Button
>
и проверьте последние неуспешные jobs.
</ AlertDescription >
</ Alert >
</ CardContent >
</ Card >
{ /if }
</ div >
</ div >