CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m0s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m37s
Updated the DataGridPagination component to map available sizes into Select items for better user experience. This change allows for dynamic selection of page sizes based on the provided props.
458 lines
17 KiB
TypeScript
458 lines
17 KiB
TypeScript
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { AlertTriangle, Clock, Activity, Info, RefreshCw } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { useState, useMemo } from 'react'
|
|
|
|
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
|
import { Button } from '@evobgp/ui/components/button'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@evobgp/ui/components/select'
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@evobgp/ui/components/table'
|
|
|
|
import { PageHeader } from '@/components/page-header'
|
|
import { QueryState } from '@/components/query-state'
|
|
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
|
import { SectionCardsSkeleton } from '@/components/skeletons'
|
|
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'
|
|
import type { JobRow } from '@/types/api'
|
|
|
|
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 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
|
|
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
|
|
const failed = jobs.filter(
|
|
(j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
|
).length
|
|
|
|
const items: SectionCardItem[] = [
|
|
{
|
|
label: 'Ревизий',
|
|
value: revisions.length,
|
|
icon: <Activity className="size-4" />,
|
|
hint: 'история конфигов',
|
|
},
|
|
{
|
|
label: 'Активных задач',
|
|
value: running,
|
|
icon: <Clock className="size-4" />,
|
|
hint: 'queued и running',
|
|
},
|
|
{
|
|
label: 'Задач с ошибкой',
|
|
value: failed,
|
|
icon: <AlertTriangle className="size-4" />,
|
|
hint: failed > 0 ? 'требуют внимания' : 'критичных сбоев нет',
|
|
variant: failed > 0 ? 'warning' : 'default',
|
|
},
|
|
]
|
|
|
|
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>
|
|
}
|
|
/>
|
|
|
|
<Alert className="border-info/30 bg-info/5">
|
|
<Info className="text-info" />
|
|
<AlertTitle>Три раздела на одной странице</AlertTitle>
|
|
<AlertDescription>
|
|
<strong>Ревизии</strong> — история конфигов и откат; <strong>Сравнение</strong> — diff
|
|
префиксов; <strong>Задачи</strong> — ingest, apply, rollback.
|
|
</AlertDescription>
|
|
</Alert>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<ConfirmDialog
|
|
trigger={
|
|
<Button variant="default" size="sm" disabled={applyMutation.isPending}>
|
|
Apply
|
|
</Button>
|
|
}
|
|
title="Применить конфигурацию на всех спикерах?"
|
|
description="Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль operator."
|
|
confirmLabel="Применить"
|
|
onConfirm={() => applyMutation.mutate()}
|
|
/>
|
|
<ConfirmDialog
|
|
trigger={
|
|
<Button variant="outline" size="sm" disabled={birdReloadMutation.isPending}>
|
|
BIRD reload
|
|
</Button>
|
|
}
|
|
title="Перезагрузить BIRD?"
|
|
description="BIRD перезагрузит конфигурацию. Требуется роль operator."
|
|
confirmLabel="Перезагрузить"
|
|
onConfirm={() => birdReloadMutation.mutate()}
|
|
/>
|
|
</div>
|
|
|
|
{revisionsQ.isLoading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
|
|
|
|
<Tabs defaultValue={search.tab}>
|
|
<TabsList>
|
|
<TabsTrigger value="revisions">Ревизии ({revisions.length})</TabsTrigger>
|
|
<TabsTrigger value="diff">Сравнение</TabsTrigger>
|
|
<TabsTrigger value="jobs">Задачи ({jobs.length})</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="revisions" className="mt-4">
|
|
<Card>
|
|
<CardHeader className="border-b py-3">
|
|
<CardTitle className="text-base">История ревизий</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<QueryState
|
|
data={revisions}
|
|
isLoading={revisionsQ.isLoading}
|
|
isError={revisionsQ.isError}
|
|
error={revisionsQ.error}
|
|
empty={revisions.length === 0}
|
|
emptyTitle="Нет ревизий"
|
|
onRetry={() => revisionsQ.refetch()}
|
|
>
|
|
{(items) => <RevisionsTable items={items} qc={qc} />}
|
|
</QueryState>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="diff" className="mt-4">
|
|
<DiffTab revisions={revisions} />
|
|
</TabsContent>
|
|
|
|
<TabsContent value="jobs" className="mt-4">
|
|
<Card>
|
|
<CardHeader className="border-b py-3">
|
|
<CardTitle className="text-base">Задачи</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<QueryState
|
|
data={jobs}
|
|
isLoading={jobsQ.isLoading}
|
|
isError={jobsQ.isError}
|
|
error={jobsQ.error}
|
|
empty={jobs.length === 0}
|
|
emptyTitle="Нет задач"
|
|
onRetry={() => jobsQ.refetch()}
|
|
>
|
|
{(items) => <JobsTable items={items} nameById={nameById} qc={qc} />}
|
|
</QueryState>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function RevisionsTable({
|
|
items,
|
|
qc,
|
|
}: {
|
|
items: import('@/types/api').RevisionRow[]
|
|
qc: import('@tanstack/react-query').QueryClient
|
|
}) {
|
|
const rollbackMutation = useMutation({
|
|
mutationFn: (id: string) =>
|
|
apiMutate(`/v1/revisions/${id}/rollback`, 'POST', {}).then(() => id),
|
|
onSuccess: () => {
|
|
toast.success('Откат выполнен')
|
|
void qc.invalidateQueries({ queryKey: ['operations'] })
|
|
},
|
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось откатить'),
|
|
})
|
|
|
|
return (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>ID</TableHead>
|
|
<TableHead>Создана</TableHead>
|
|
<TableHead>Префиксов</TableHead>
|
|
<TableHead className="w-24" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{items.map((r) => (
|
|
<TableRow key={r.id}>
|
|
<TableCell className="font-mono text-xs">{r.id.slice(0, 12)}…</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground">
|
|
{new Date(r.created_at).toLocaleString('ru-RU')}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-sm tabular-nums">
|
|
{r.materialized_prefix_count}
|
|
</TableCell>
|
|
<TableCell>
|
|
<ConfirmDialog
|
|
trigger={
|
|
<Button variant="ghost" size="icon-sm" className="text-destructive">
|
|
<RefreshCw className="size-3.5" />
|
|
</Button>
|
|
}
|
|
title={`Откатиться к ревизии ${r.id.slice(0, 8)}…?`}
|
|
description="Будет создана новая ревизия на основе выбранной. Требуется роль operator."
|
|
confirmLabel="Откатить"
|
|
destructive
|
|
onConfirm={() => rollbackMutation.mutate(r.id)}
|
|
/>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)
|
|
}
|
|
|
|
function JobsTable({
|
|
items,
|
|
nameById,
|
|
qc,
|
|
}: {
|
|
items: JobRow[]
|
|
nameById: Map<string, string>
|
|
qc: import('@tanstack/react-query').QueryClient
|
|
}) {
|
|
const cancelMutation = useMutation({
|
|
mutationFn: (jobId: string) => apiMutate(`/v1/jobs/${jobId}/cancel`, 'POST', {}),
|
|
onSuccess: () => {
|
|
toast.success('Задача отменена')
|
|
void qc.invalidateQueries({ queryKey: ['operations'] })
|
|
},
|
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отменить'),
|
|
})
|
|
|
|
return (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Вид</TableHead>
|
|
<TableHead>Статус</TableHead>
|
|
<TableHead>Создана</TableHead>
|
|
<TableHead>Завершена</TableHead>
|
|
<TableHead className="w-20" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{items.map((j) => (
|
|
<TableRow key={j.job_id}>
|
|
<TableCell className="font-medium">
|
|
<div className="flex flex-col gap-0.5">
|
|
<span>{j.kind}</span>
|
|
{j.meta?.module_id ? (
|
|
<span className="text-xs text-muted-foreground">
|
|
{nameById.get(String(j.meta.module_id)) ?? String(j.meta.module_id)}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<StatusBadgeColored status={j.status} />
|
|
</TableCell>
|
|
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
|
{j.created_at ? new Date(j.created_at).toLocaleString('ru-RU') : '—'}
|
|
</TableCell>
|
|
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
|
{j.finished_at ? new Date(j.finished_at).toLocaleString('ru-RU') : '—'}
|
|
</TableCell>
|
|
<TableCell>
|
|
{j.status === 'running' || j.status === 'queued' ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="text-destructive"
|
|
onClick={() => cancelMutation.mutate(j.job_id)}
|
|
>
|
|
✕
|
|
</Button>
|
|
) : null}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)
|
|
}
|
|
|
|
function StatusBadgeColored({ status }: { status: string }) {
|
|
const cls =
|
|
status === 'succeeded'
|
|
? 'text-success'
|
|
: status === 'failed' || status === 'cancelled'
|
|
? 'text-destructive'
|
|
: 'text-info'
|
|
return <span className={`text-sm font-medium ${cls}`}>{status}</span>
|
|
}
|
|
|
|
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 (
|
|
<Card>
|
|
<CardHeader className="border-b py-3">
|
|
<CardTitle className="text-base">Сравнение ревизий</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-4 p-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>
|
|
<Select items={revisionItems} value={a} onValueChange={(v) => v && setA(v)}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Выберите" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{revisions.map((r) => (
|
|
<SelectItem key={r.id} value={r.id}>
|
|
{r.id.slice(0, 12)}…
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex w-full max-w-xs flex-col gap-1">
|
|
<span className="text-xs text-muted-foreground">Ревизия B</span>
|
|
<Select items={revisionItems} value={b} onValueChange={(v) => v && setB(v)}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Выберите" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{revisions.map((r) => (
|
|
<SelectItem key={r.id} value={r.id}>
|
|
{r.id.slice(0, 12)}…
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</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>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
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>
|
|
)
|
|
}
|