Compare commits

...
1 Commits
Author SHA1 Message Date
DenozordecandCursor b13c233679 feat(analytics): remove OperationsAnalyticsCard and update imports
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 44s
CI / web (push) Successful in 1m27s
CI / release (push) Successful in 4m25s
Deleted the OperationsAnalyticsCard component to streamline analytics functionality. Updated the analytics index file to reflect this change by removing the export for OperationsAnalyticsCard. Adjusted the QuickActionGrid component to enhance user interaction with new keyboard handling and badge functionality.

Co-authored-by: Cursor <[email protected]>
2026-07-31 13:07:26 +07:00
5 changed files with 239 additions and 157 deletions
@@ -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>
) )
+162 -72
View File
@@ -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