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]>
254 lines
9.6 KiB
TypeScript
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>
|
|
)
|
|
}
|