Files
EvoBGP/apps/web/src/routes/_auth/monitoring.tsx
T
Denozordec 09580bbcab feat(web): add job run timeline and code-block revision diff
Раскрытие строки задачи показывает вертикальный ReUI Timeline:
создана (с триггером) -> взята в работу -> результат с текстом
ошибки. Diff ревизий переведён с самодельных <pre> на официальный
@reui/code-block: единый блок с разметкой добавленных и удалённых
строк (знаки +/- и тонировка), нумерацией, копированием и разворотом
длинных списков. ResourcePage (filtered-вариант) получил поддержку
expandedContent/getRowCanExpand — колонка раскрытия строится так же,
как во FrameDataGrid. Точечные упрощения: прогресс BGP-сессий в
мониторинге переведён на Progress из кита вместо самодельного бара;
удалены @deprecated-константы ui-surface.ts, мёртвый
data-grid-defaults.ts и неиспользуемый sparkline-ряд дашборда.
2026-09-25 21:46:57 +07:00

184 lines
6.2 KiB
TypeScript

import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Bird, RefreshCw } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Progress } from '@evobgp/ui/components/progress'
import { MonitoringHealthCard } from '@/components/analytics'
import { MonitoringReadyGrid } from '@/components/monitoring/monitoring-ready-grid'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { FrameSection } from '@/components/reui-kit'
import { StatusBadge } from '@/components/status-badge'
import { jobKindRu } from '@/lib/ui-labels'
import {
monitoringHealthQueryOptions,
monitoringReadyQueryOptions,
monitoringVersionQueryOptions,
type VersionInfo,
} from '@/queries/monitoring'
import { networkBirdQueryOptions } from '@/queries/network'
import { operationsJobsQueryOptions } from '@/queries/operations'
export const Route = createFileRoute('/_auth/monitoring')({
component: MonitoringComponent,
})
function MonitoringComponent() {
const healthQ = useQuery(monitoringHealthQueryOptions())
const readyQ = useQuery(monitoringReadyQueryOptions())
const versionQ = useQuery(monitoringVersionQueryOptions())
const birdQ = useQuery(networkBirdQueryOptions())
const jobsQ = useQuery(operationsJobsQueryOptions())
const refreshing =
healthQ.isFetching ||
readyQ.isFetching ||
versionQ.isFetching ||
birdQ.isFetching ||
jobsQ.isFetching
const jobs = jobsQ.data?.items ?? []
const versionText = formatVersion(versionQ.data)
function refetchAll() {
void healthQ.refetch()
void readyQ.refetch()
void versionQ.refetch()
void birdQ.refetch()
void jobsQ.refetch()
}
const failedJobs = jobs
.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
.slice(0, 5)
return (
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader
title="Мониторинг"
description={`Состояние API, BGP и задач для диагностики инцидентов${
versionText !== '—'
? ` · версия ${versionText}${
versionQ.data?.git_sha ? ` (${versionQ.data.git_sha.slice(0, 8)})` : ''
}`
: ''
}`}
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
}
/>
<div className="grid items-stretch gap-4 md:gap-6 lg:grid-cols-2">
<MonitoringHealthCard
healthOk={healthQ.data?.ok ?? false}
ready={readyQ.data}
loading={healthQ.isLoading || readyQ.isLoading}
/>
<FrameSection
title={
<span className="flex items-center gap-2">
<Bird className="size-4" />
BGP на API-хосте
</span>
}
description="Статус BIRD на хосте API"
className="h-full"
contentClassName="px-5 py-4"
>
<QueryState
data={birdQ.data}
isLoading={birdQ.isLoading}
isError={birdQ.isError}
error={birdQ.error}
skeleton={<div className="h-40" />}
onRetry={() => birdQ.refetch()}
>
{(bird) => <BirdSummary bird={bird} />}
</QueryState>
</FrameSection>
</div>
<QueryState
data={readyQ.data}
isLoading={readyQ.isLoading}
isError={readyQ.isError}
error={readyQ.error}
skeleton={<div className="h-40" />}
onRetry={() => readyQ.refetch()}
>
{(ready) => <MonitoringReadyGrid health={healthQ.data} ready={ready} />}
</QueryState>
{failedJobs.length > 0 ? (
<FrameSection title="Последние ошибки" contentClassName="px-5 py-4">
<ul className="flex flex-col gap-2">
{failedJobs.map((job) => (
<li key={job.job_id} className="rounded-lg border px-3 py-2 text-sm">
<div className="flex items-start justify-between gap-2">
<p className="font-medium">{jobKindRu(job.kind)}</p>
<StatusBadge status={job.status} />
</div>
{job.error ? (
<p className="mt-1 text-xs text-muted-foreground">
{job.error.slice(0, 120)}
{job.error.length > 120 ? '…' : ''}
</p>
) : null}
</li>
))}
</ul>
</FrameSection>
) : null}
</div>
)
}
function formatVersion(version?: VersionInfo | null): string {
if (!version) return '—'
return version.version ?? version.app ?? '—'
}
function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
if (!bird.birdc_configured) {
return (
<p className="text-sm text-muted-foreground">
{bird.message ?? 'birdc не настроен на API-хосте (EVOBGP_BIRDC_SOCKET).'}
</p>
)
}
const ratio =
bird.bgp_sessions_total > 0
? Math.round((bird.bgp_established / bird.bgp_sessions_total) * 100)
: null
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Установлено / всего</span>
<span className="font-medium tabular-nums">
{bird.bgp_established} / {bird.bgp_sessions_total}
{ratio !== null ? <span className="text-muted-foreground"> ({ratio}%)</span> : null}
</span>
</div>
{ratio !== null ? (
<Progress
value={ratio}
aria-label="Установлено BGP-сессий"
className={
ratio >= 100
? '[&_[data-slot=progress-indicator]]:bg-success'
: ratio >= 50
? '[&_[data-slot=progress-indicator]]:bg-warning'
: '[&_[data-slot=progress-indicator]]:bg-destructive'
}
/>
) : null}
{bird.error ? <p className="text-xs text-destructive">{bird.error}</p> : null}
</div>
)
}