Files
EvoBGP/apps/web/src/routes/_auth/operations.tsx
T
DenozordecandCursor c3369059af feat(web): align ops screens with ReUI PRO kit and Frame surface
OpsDashboard+afterKpi на dashboard; FrameDataGrid вместо DataGridCard; KpiStatGrid вместо SectionCards; SettingsShell без Separator. Preview: dashboard-1, stats-12, data-grid-filtering-2, settings-16.

Co-authored-by: Cursor <[email protected]>
2026-07-31 12:15:59 +07:00

254 lines
9.6 KiB
TypeScript

import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { useState, useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { PanelCard } from '@/components/panel-card'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { FrameDataGrid } from '@/components/reui-kit'
import { OperationsAnalyticsCard } from '@/components/analytics'
import { SelectMenu } from '@/components/select-field'
import { OperationsJobsCard } from '@/components/operations/operations-jobs-card'
import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { operationsJobsQueryOptions, operationsRevisionsQueryOptions, operationsDiffQueryOptions } from '@/queries/operations'
import { moduleNameById, overviewModulesQueryOptions } from '@/queries/overview'
import { apiMutate, waitForJob } from '@/lib/api-client'
export const Route = createFileRoute('/_auth/operations')({
component: OperationsComponent,
validateSearch: (search: Record<string, unknown>) => ({
tab: (search.tab === 'diff' || search.tab === 'jobs' ? search.tab : 'revisions') as
| 'revisions'
| 'diff'
| 'jobs',
}),
})
function OperationsComponent() {
const search = useSearch({ from: '/_auth/operations' })
const navigate = Route.useNavigate()
const qc = useQueryClient()
const revisionsQ = useQuery(operationsRevisionsQueryOptions())
const jobsQ = useQuery(operationsJobsQueryOptions())
const modulesQ = useQuery(overviewModulesQueryOptions())
const revisions = revisionsQ.data?.items ?? []
const jobs = jobsQ.data?.items ?? []
const nameById = moduleNameById(modulesQ.data?.items ?? [])
const refreshing = revisionsQ.isFetching || jobsQ.isFetching
function refetchAll() {
void revisionsQ.refetch()
void jobsQ.refetch()
void modulesQ.refetch()
}
const applyMutation = useMutation({
mutationFn: async () => {
const revId = revisions[0]?.id
if (!revId) throw new Error('Нет ревизий')
const res = await apiMutate<{ job_id: string }>('/v1/apply', 'POST', { revision_id: revId })
if (!res.job_id) throw new Error('Ответ API без job_id')
const job = await waitForJob(res.job_id, { timeoutMs: 180_000 })
if (job.status !== 'succeeded') throw new Error(job.error ?? job.status)
return job
},
onSuccess: () => {
toast.success('Конфигурация успешно применена')
void qc.invalidateQueries({ queryKey: ['operations'] })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось применить'),
})
const birdReloadMutation = useMutation({
mutationFn: async () => {
const res = await apiMutate<{ job_id: string }>('/v1/bird/reload', 'POST', {})
if (!res.job_id) throw new Error('Ответ API без job_id')
const job = await waitForJob(res.job_id, { timeoutMs: 120_000 })
if (job.status !== 'succeeded') throw new Error(job.error ?? job.status)
return job
},
onSuccess: () => toast.success('Команда birdc configure выполнена'),
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось перезагрузить'),
})
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Ревизии и операции"
description="Деплой конфигурации, управление ревизиями и задачами"
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
}
/>
<div className="flex flex-wrap gap-2">
<ConfirmDialog
trigger={
<Button variant="default" size="sm" disabled={applyMutation.isPending}>
Применить
</Button>
}
title="Применить конфигурацию на всех спикерах?"
description="Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль оператора."
confirmLabel="Применить"
onConfirm={() => applyMutation.mutate()}
/>
<ConfirmDialog
trigger={
<Button variant="outline" size="sm" disabled={birdReloadMutation.isPending}>
Перезагрузка BIRD
</Button>
}
title="Перезагрузить BIRD?"
description="BIRD перезагрузит конфигурацию. Требуется роль оператора."
confirmLabel="Перезагрузить"
onConfirm={() => birdReloadMutation.mutate()}
/>
</div>
{revisionsQ.isLoading ? (
<OperationsAnalyticsCard jobs={[]} revisions={[]} loading />
) : (
<OperationsAnalyticsCard jobs={jobs} revisions={revisions} />
)}
<BadgeTabs
value={search.tab}
onValueChange={(tab) =>
navigate({ search: { tab: tab as 'revisions' | 'diff' | 'jobs' } })
}
items={[
{ value: 'revisions', label: 'Ревизии', count: revisions.length },
{ value: 'diff', label: 'Сравнение' },
{ value: 'jobs', label: 'Задачи', count: jobs.length, badgeVariant: 'info-light' },
]}
>
<TabsContent value="revisions" className="mt-0">
<FrameDataGrid title="История ревизий">
<QueryState
data={revisions}
isLoading={revisionsQ.isLoading}
isError={revisionsQ.isError}
error={revisionsQ.error}
empty={revisions.length === 0}
emptyTitle="Нет ревизий"
onRetry={() => revisionsQ.refetch()}
>
{(items) => (
<OperationsRevisionsGrid
items={items}
qc={qc}
isLoading={revisionsQ.isFetching && !revisionsQ.isLoading}
/>
)}
</QueryState>
</FrameDataGrid>
</TabsContent>
<TabsContent value="diff" className="mt-0">
<DiffTab revisions={revisions} />
</TabsContent>
<TabsContent value="jobs" className="mt-0">
<OperationsJobsCard
jobs={jobs}
nameById={nameById}
qc={qc}
isLoading={jobsQ.isLoading}
isError={jobsQ.isError}
error={jobsQ.error}
onRetry={() => jobsQ.refetch()}
/>
</TabsContent>
</BadgeTabs>
</div>
)
}
function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[] }) {
const [a, setA] = useState('')
const [b, setB] = useState('')
const diffQ = useQuery(operationsDiffQueryOptions(a, b))
const revisionItems = useMemo(
() =>
revisions.map((r) => ({
value: r.id,
label: `${r.id.slice(0, 12)}…`,
})),
[revisions],
)
return (
<PanelCard title="Сравнение ревизий" contentClassName="flex flex-col gap-4 py-4">
<div className="flex flex-wrap items-end gap-3">
<div className="flex w-full max-w-xs flex-col gap-1">
<span className="text-xs text-muted-foreground">Ревизия A</span>
<SelectMenu
items={revisionItems}
value={a}
placeholder="Выберите"
onValueChange={(v) => v && setA(v)}
/>
</div>
<div className="flex w-full max-w-xs flex-col gap-1">
<span className="text-xs text-muted-foreground">Ревизия B</span>
<SelectMenu
items={revisionItems}
value={b}
placeholder="Выберите"
onValueChange={(v) => v && setB(v)}
/>
</div>
<Button onClick={() => diffQ.refetch()} disabled={!a || !b || diffQ.isFetching}>
Сравнить
</Button>
</div>
<QueryState
data={diffQ.data}
isLoading={diffQ.isFetching}
isError={diffQ.isError}
error={diffQ.error}
empty={!diffQ.data}
emptyTitle="Выберите две ревизии"
onRetry={() => diffQ.refetch()}
>
{(diff) => <DiffView diff={diff} />}
</QueryState>
</PanelCard>
)
}
function DiffView({ diff }: { diff: import('@/types/api').RevisionDiff }) {
const added = diff.prefixes?.added ?? (diff.added as string[]) ?? []
const removed = diff.prefixes?.removed ?? (diff.removed as string[]) ?? []
return (
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="mb-2 text-sm font-medium text-success">Добавлено: {added.length}</p>
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
{added.join('\n')}
</pre>
</div>
<div>
<p className="mb-2 text-sm font-medium text-destructive">Удалено: {removed.length}</p>
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
{removed.join('\n')}
</pre>
</div>
</div>
)
}