Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b13c233679 |
@@ -10,4 +10,3 @@ export { DashboardOperationsFlowCard } from './dashboard-operations-flow-card'
|
|||||||
export { DashboardPlatformCard } from './dashboard-platform-card'
|
export { DashboardPlatformCard } from './dashboard-platform-card'
|
||||||
export { MonitoringHealthCard } from './monitoring-health-card'
|
export { MonitoringHealthCard } from './monitoring-health-card'
|
||||||
export { NetworkOverviewAnalyticsCard } from './network-overview-analytics-card'
|
export { NetworkOverviewAnalyticsCard } from './network-overview-analytics-card'
|
||||||
export { OperationsAnalyticsCard } from './operations-analytics-card'
|
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
|
||||||
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
|
|
||||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
|
||||||
import { jobStatusBreakdown } from '@/lib/metrics'
|
|
||||||
import type { JobRow, RevisionRow } from '@/types/api'
|
|
||||||
|
|
||||||
export function OperationsAnalyticsCard({
|
|
||||||
jobs,
|
|
||||||
revisions,
|
|
||||||
loading,
|
|
||||||
}: {
|
|
||||||
jobs: JobRow[]
|
|
||||||
revisions: RevisionRow[]
|
|
||||||
loading?: boolean
|
|
||||||
}) {
|
|
||||||
const running = jobs.filter((j) => ['running', 'queued'].includes(j.status.toLowerCase())).length
|
|
||||||
const failed = jobs.filter((j) =>
|
|
||||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
|
||||||
).length
|
|
||||||
const slices = jobStatusBreakdown(jobs)
|
|
||||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnalyticsCardShell
|
|
||||||
title="Операции и задачи"
|
|
||||||
description="Статистика ревизий и фоновых jobs"
|
|
||||||
info="Данные из GET /v1/jobs и /v1/revisions."
|
|
||||||
>
|
|
||||||
<AnalyticsKpiRow
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: 'Ревизий',
|
|
||||||
value: loading ? '—' : String(revisions.length),
|
|
||||||
delta: { direction: 'neutral', label: 'в выборке', tone: 'muted' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Активных задач',
|
|
||||||
value: loading ? '—' : String(running),
|
|
||||||
delta: {
|
|
||||||
direction: running > 0 ? 'up' : 'neutral',
|
|
||||||
label: running > 0 ? 'выполняются' : 'очередь пуста',
|
|
||||||
tone: (running > 0 ? 'warning' : 'muted') as 'warning' | 'muted',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'С ошибкой',
|
|
||||||
value: loading ? '—' : String(failed),
|
|
||||||
delta: {
|
|
||||||
direction: failed > 0 ? 'down' : 'up',
|
|
||||||
label: failed > 0 ? 'требуют внимания' : 'в норме',
|
|
||||||
tone: failed > 0 ? 'destructive' : 'success',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
|
||||||
Загрузка…
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ChartDonutMetric slices={slices} centerLabel="Задачи" centerValue={total} />
|
|
||||||
)}
|
|
||||||
</AnalyticsCardShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { KeyboardEvent, ReactNode } from 'react'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
@@ -12,16 +12,23 @@ import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
|||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
import { kpiCols } from './kpi-cols'
|
import { kpiCols } from './kpi-cols'
|
||||||
|
|
||||||
export interface QuickActionItem {
|
type QuickActionBase = {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
to: string
|
|
||||||
search?: Record<string, unknown>
|
|
||||||
icon?: ReactNode
|
icon?: ReactNode
|
||||||
iconClassName?: string
|
iconClassName?: string
|
||||||
|
/** Override badge label (default: «Перейти» for links, «Выполнить» for onClick). */
|
||||||
|
badge?: string
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type QuickActionItem = QuickActionBase &
|
||||||
|
(
|
||||||
|
| { to: string; search?: Record<string, unknown>; onClick?: never }
|
||||||
|
| { onClick: () => void; to?: never; search?: never }
|
||||||
|
)
|
||||||
|
|
||||||
interface QuickActionGridProps {
|
interface QuickActionGridProps {
|
||||||
actions: QuickActionItem[]
|
actions: QuickActionItem[]
|
||||||
title?: string
|
title?: string
|
||||||
@@ -31,6 +38,18 @@ interface QuickActionGridProps {
|
|||||||
|
|
||||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||||
|
|
||||||
|
function resolveBadge(action: QuickActionItem): string {
|
||||||
|
if (action.badge) return action.badge
|
||||||
|
return action.onClick ? 'Выполнить' : 'Перейти'
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleActionKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
onActivate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function QuickActionBody({ action }: { action: QuickActionItem }) {
|
function QuickActionBody({ action }: { action: QuickActionItem }) {
|
||||||
return (
|
return (
|
||||||
<div className="relative z-10 flex h-full items-start gap-3">
|
<div className="relative z-10 flex h-full items-start gap-3">
|
||||||
@@ -51,7 +70,7 @@ function QuickActionBody({ action }: { action: QuickActionItem }) {
|
|||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<span className="text-foreground text-sm font-medium">{action.title}</span>
|
<span className="text-foreground text-sm font-medium">{action.title}</span>
|
||||||
<Badge variant="outline" size="sm" className="shrink-0">
|
<Badge variant="outline" size="sm" className="shrink-0">
|
||||||
Перейти
|
{resolveBadge(action)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||||
@@ -62,6 +81,15 @@ function QuickActionBody({ action }: { action: QuickActionItem }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function panelClassName(disabled?: boolean) {
|
||||||
|
return cn(
|
||||||
|
'relative isolate flex h-full flex-col transition-colors',
|
||||||
|
disabled
|
||||||
|
? 'cursor-not-allowed opacity-60'
|
||||||
|
: 'hover:bg-muted/40 focus-within:ring-ring cursor-pointer focus-within:ring-2',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KPI-like quick actions strip (horizontal Frame tiles).
|
* KPI-like quick actions strip (horizontal Frame tiles).
|
||||||
* Preview: https://reui.io/preview/base/stats-12
|
* Preview: https://reui.io/preview/base/stats-12
|
||||||
@@ -83,21 +111,51 @@ export function QuickActionGrid({
|
|||||||
</FrameHeader>
|
</FrameHeader>
|
||||||
)}
|
)}
|
||||||
<div className={cn('grid gap-2', kpiCols(actions.length))}>
|
<div className={cn('grid gap-2', kpiCols(actions.length))}>
|
||||||
{actions.map((action) => (
|
{actions.map((action) => {
|
||||||
<FramePanel
|
const label = `${action.title}: ${action.description}`
|
||||||
key={action.id}
|
|
||||||
className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2"
|
if ('to' in action && action.to) {
|
||||||
>
|
return (
|
||||||
<Link
|
<FramePanel key={action.id} className={panelClassName(action.disabled)}>
|
||||||
to={action.to}
|
{action.disabled ? (
|
||||||
search={action.search}
|
<div aria-disabled aria-label={label}>
|
||||||
className="focus-visible:outline-none"
|
<QuickActionBody action={action} />
|
||||||
aria-label={`${action.title}: ${action.description}`}
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to={action.to}
|
||||||
|
search={action.search}
|
||||||
|
className="focus-visible:outline-none"
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
<QuickActionBody action={action} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClick = action.onClick
|
||||||
|
const onActivate = () => {
|
||||||
|
if (action.disabled || !onClick) return
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FramePanel
|
||||||
|
key={action.id}
|
||||||
|
className={panelClassName(action.disabled)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={action.disabled ? -1 : 0}
|
||||||
|
aria-disabled={action.disabled || undefined}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={onActivate}
|
||||||
|
onKeyDown={(e) => handleActionKeyDown(onActivate, e)}
|
||||||
>
|
>
|
||||||
<QuickActionBody action={action} />
|
<QuickActionBody action={action} />
|
||||||
</Link>
|
</FramePanel>
|
||||||
</FramePanel>
|
)
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Frame>
|
</Frame>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { RefreshCw } from 'lucide-react'
|
import { AlertTriangle, GitBranch, ListTodo, Play, RefreshCw, RotateCcw } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useState, useMemo } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { PanelCard } from '@/components/panel-card'
|
import { PanelCard } from '@/components/panel-card'
|
||||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||||
import { FrameDataGrid } from '@/components/reui-kit'
|
import {
|
||||||
import { OperationsAnalyticsCard } from '@/components/analytics'
|
FrameDataGrid,
|
||||||
|
KpiStatGrid,
|
||||||
|
QuickActionGrid,
|
||||||
|
type KpiStatItem,
|
||||||
|
type QuickActionItem,
|
||||||
|
} from '@/components/reui-kit'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { SelectMenu } from '@/components/select-field'
|
import { SelectMenu } from '@/components/select-field'
|
||||||
import { OperationsJobsCard } from '@/components/operations/operations-jobs-card'
|
import { OperationsJobsCard } from '@/components/operations/operations-jobs-card'
|
||||||
import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid'
|
import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid'
|
||||||
@@ -16,9 +22,14 @@ import { PageHeader } from '@/components/page-header'
|
|||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
|
||||||
import { operationsJobsQueryOptions, operationsRevisionsQueryOptions, operationsDiffQueryOptions } from '@/queries/operations'
|
import {
|
||||||
|
operationsDiffQueryOptions,
|
||||||
|
operationsJobsQueryOptions,
|
||||||
|
operationsRevisionsQueryOptions,
|
||||||
|
} from '@/queries/operations'
|
||||||
import { moduleNameById, overviewModulesQueryOptions } from '@/queries/overview'
|
import { moduleNameById, overviewModulesQueryOptions } from '@/queries/overview'
|
||||||
import { apiMutate, waitForJob } from '@/lib/api-client'
|
import { apiMutate, waitForJob } from '@/lib/api-client'
|
||||||
|
import type { JobRow, RevisionRow } from '@/types/api'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/operations')({
|
export const Route = createFileRoute('/_auth/operations')({
|
||||||
component: OperationsComponent,
|
component: OperationsComponent,
|
||||||
@@ -30,20 +41,74 @@ export const Route = createFileRoute('/_auth/operations')({
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function buildOperationsKpiItems(revisions: RevisionRow[], jobs: JobRow[]): KpiStatItem[] {
|
||||||
|
const running = jobs.filter((j) => ['running', 'queued'].includes(j.status.toLowerCase())).length
|
||||||
|
const failed = jobs.filter((j) =>
|
||||||
|
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||||
|
).length
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'revisions',
|
||||||
|
label: 'Ревизий',
|
||||||
|
value: revisions.length,
|
||||||
|
icon: <GitBranch className="size-4" aria-hidden />,
|
||||||
|
iconClassName: 'text-primary',
|
||||||
|
footer: (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
в выборке
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'active-jobs',
|
||||||
|
label: 'Активных задач',
|
||||||
|
value: running,
|
||||||
|
icon: <ListTodo className="size-4" aria-hidden />,
|
||||||
|
iconClassName: running > 0 ? 'text-warning' : 'text-muted-foreground',
|
||||||
|
variant: running > 0 ? 'warning' : 'default',
|
||||||
|
footer: (
|
||||||
|
<Badge variant={running > 0 ? 'warning-light' : 'outline'} size="sm">
|
||||||
|
{running > 0 ? 'выполняются' : 'очередь пуста'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'failed-jobs',
|
||||||
|
label: 'С ошибкой',
|
||||||
|
value: failed,
|
||||||
|
icon: <AlertTriangle className="size-4" aria-hidden />,
|
||||||
|
iconClassName: failed > 0 ? 'text-destructive' : 'text-muted-foreground',
|
||||||
|
variant: failed > 0 ? 'destructive' : 'default',
|
||||||
|
footer: (
|
||||||
|
<Badge variant={failed > 0 ? 'destructive-light' : 'success-light'} size="sm">
|
||||||
|
{failed > 0 ? 'требуют внимания' : 'в норме'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
function OperationsComponent() {
|
function OperationsComponent() {
|
||||||
const search = useSearch({ from: '/_auth/operations' })
|
const search = useSearch({ from: '/_auth/operations' })
|
||||||
const navigate = Route.useNavigate()
|
const navigate = Route.useNavigate()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const [applyOpen, setApplyOpen] = useState(false)
|
||||||
|
const [reloadOpen, setReloadOpen] = useState(false)
|
||||||
|
|
||||||
const revisionsQ = useQuery(operationsRevisionsQueryOptions())
|
const revisionsQ = useQuery(operationsRevisionsQueryOptions())
|
||||||
const jobsQ = useQuery(operationsJobsQueryOptions())
|
const jobsQ = useQuery(operationsJobsQueryOptions())
|
||||||
const modulesQ = useQuery(overviewModulesQueryOptions())
|
const modulesQ = useQuery(overviewModulesQueryOptions())
|
||||||
|
|
||||||
const revisions = revisionsQ.data?.items ?? []
|
const revisions = useMemo(() => revisionsQ.data?.items ?? [], [revisionsQ.data?.items])
|
||||||
const jobs = jobsQ.data?.items ?? []
|
const jobs = useMemo(() => jobsQ.data?.items ?? [], [jobsQ.data?.items])
|
||||||
const nameById = moduleNameById(modulesQ.data?.items ?? [])
|
const nameById = moduleNameById(modulesQ.data?.items ?? [])
|
||||||
|
|
||||||
const refreshing = revisionsQ.isFetching || jobsQ.isFetching
|
const refreshing = revisionsQ.isFetching || jobsQ.isFetching
|
||||||
|
const kpiLoading = revisionsQ.isLoading || jobsQ.isLoading
|
||||||
|
|
||||||
|
const kpiItems = useMemo(() => buildOperationsKpiItems(revisions, jobs), [revisions, jobs])
|
||||||
|
|
||||||
function refetchAll() {
|
function refetchAll() {
|
||||||
void revisionsQ.refetch()
|
void revisionsQ.refetch()
|
||||||
@@ -63,6 +128,7 @@ function OperationsComponent() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Конфигурация успешно применена')
|
toast.success('Конфигурация успешно применена')
|
||||||
|
setApplyOpen(false)
|
||||||
void qc.invalidateQueries({ queryKey: ['operations'] })
|
void qc.invalidateQueries({ queryKey: ['operations'] })
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось применить'),
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось применить'),
|
||||||
@@ -76,12 +142,41 @@ function OperationsComponent() {
|
|||||||
if (job.status !== 'succeeded') throw new Error(job.error ?? job.status)
|
if (job.status !== 'succeeded') throw new Error(job.error ?? job.status)
|
||||||
return job
|
return job
|
||||||
},
|
},
|
||||||
onSuccess: () => toast.success('Команда birdc configure выполнена'),
|
onSuccess: () => {
|
||||||
|
toast.success('Команда birdc configure выполнена')
|
||||||
|
setReloadOpen(false)
|
||||||
|
},
|
||||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось перезагрузить'),
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось перезагрузить'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const quickActions: QuickActionItem[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'apply',
|
||||||
|
title: 'Применить',
|
||||||
|
description: 'Деплой текущей ревизии на все BIRD-агенты.',
|
||||||
|
icon: <Play aria-hidden />,
|
||||||
|
iconClassName: 'text-primary',
|
||||||
|
badge: 'Выполнить',
|
||||||
|
disabled: applyMutation.isPending || revisions.length === 0,
|
||||||
|
onClick: () => setApplyOpen(true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bird-reload',
|
||||||
|
title: 'Перезагрузка BIRD',
|
||||||
|
description: 'Выполнить birdc configure на локальном демоне.',
|
||||||
|
icon: <RotateCcw aria-hidden />,
|
||||||
|
iconClassName: 'text-warning',
|
||||||
|
badge: 'Выполнить',
|
||||||
|
disabled: birdReloadMutation.isPending,
|
||||||
|
onClick: () => setReloadOpen(true),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[applyMutation.isPending, birdReloadMutation.isPending, revisions.length],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-4 md:gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Ревизии и операции"
|
title="Ревизии и операции"
|
||||||
description="Деплой конфигурации, управление ревизиями и задачами"
|
description="Деплой конфигурации, управление ревизиями и задачами"
|
||||||
@@ -93,36 +188,31 @@ function OperationsComponent() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<KpiStatGrid items={kpiItems} isLoading={kpiLoading} skeletonCount={3} />
|
||||||
<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 ? (
|
<QuickActionGrid
|
||||||
<OperationsAnalyticsCard jobs={[]} revisions={[]} loading />
|
actions={quickActions}
|
||||||
) : (
|
description="Деплой конфигурации и управление локальным BIRD"
|
||||||
<OperationsAnalyticsCard jobs={jobs} revisions={revisions} />
|
/>
|
||||||
)}
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={applyOpen}
|
||||||
|
onOpenChange={setApplyOpen}
|
||||||
|
title="Применить конфигурацию на всех спикерах?"
|
||||||
|
description="Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль оператора."
|
||||||
|
confirmLabel="Применить"
|
||||||
|
confirmLoading={applyMutation.isPending}
|
||||||
|
onConfirm={() => applyMutation.mutate()}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={reloadOpen}
|
||||||
|
onOpenChange={setReloadOpen}
|
||||||
|
title="Перезагрузить BIRD?"
|
||||||
|
description="BIRD перезагрузит конфигурацию. Требуется роль оператора."
|
||||||
|
confirmLabel="Перезагрузить"
|
||||||
|
confirmLoading={birdReloadMutation.isPending}
|
||||||
|
onConfirm={() => birdReloadMutation.mutate()}
|
||||||
|
/>
|
||||||
|
|
||||||
<BadgeTabs
|
<BadgeTabs
|
||||||
value={search.tab}
|
value={search.tab}
|
||||||
@@ -177,7 +267,7 @@ function OperationsComponent() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[] }) {
|
function DiffTab({ revisions }: { revisions: RevisionRow[] }) {
|
||||||
const [a, setA] = useState('')
|
const [a, setA] = useState('')
|
||||||
const [b, setB] = useState('')
|
const [b, setB] = useState('')
|
||||||
const diffQ = useQuery(operationsDiffQueryOptions(a, b))
|
const diffQ = useQuery(operationsDiffQueryOptions(a, b))
|
||||||
@@ -192,41 +282,41 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelCard title="Сравнение ревизий" contentClassName="flex flex-col gap-4 py-4">
|
<PanelCard title="Сравнение ревизий" contentClassName="flex flex-col gap-4 py-4">
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<div className="flex w-full max-w-xs flex-col gap-1">
|
<div className="flex w-full max-w-xs flex-col gap-1">
|
||||||
<span className="text-xs text-muted-foreground">Ревизия A</span>
|
<span className="text-xs text-muted-foreground">Ревизия A</span>
|
||||||
<SelectMenu
|
<SelectMenu
|
||||||
items={revisionItems}
|
items={revisionItems}
|
||||||
value={a}
|
value={a}
|
||||||
placeholder="Выберите"
|
placeholder="Выберите"
|
||||||
onValueChange={(v) => v && setA(v)}
|
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>
|
</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
|
<QueryState
|
||||||
data={diffQ.data}
|
data={diffQ.data}
|
||||||
isLoading={diffQ.isFetching}
|
isLoading={diffQ.isFetching}
|
||||||
isError={diffQ.isError}
|
isError={diffQ.isError}
|
||||||
error={diffQ.error}
|
error={diffQ.error}
|
||||||
empty={!diffQ.data}
|
empty={!diffQ.data}
|
||||||
emptyTitle="Выберите две ревизии"
|
emptyTitle="Выберите две ревизии"
|
||||||
onRetry={() => diffQ.refetch()}
|
onRetry={() => diffQ.refetch()}
|
||||||
>
|
>
|
||||||
{(diff) => <DiffView diff={diff} />}
|
{(diff) => <DiffView diff={diff} />}
|
||||||
</QueryState>
|
</QueryState>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user