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 { MonitoringHealthCard } from './monitoring-health-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 {
Frame,
@@ -12,16 +12,23 @@ import { Item, ItemMedia } from '@evobgp/ui/components/item'
import { cn } from '@evobgp/ui/lib/utils'
import { kpiCols } from './kpi-cols'
export interface QuickActionItem {
type QuickActionBase = {
id: string
title: string
description: string
to: string
search?: Record<string, unknown>
icon?: ReactNode
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 {
actions: QuickActionItem[]
title?: string
@@ -31,6 +38,18 @@ interface QuickActionGridProps {
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 }) {
return (
<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">
<span className="text-foreground text-sm font-medium">{action.title}</span>
<Badge variant="outline" size="sm" className="shrink-0">
Перейти
{resolveBadge(action)}
</Badge>
</div>
<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).
* Preview: https://reui.io/preview/base/stats-12
@@ -83,21 +111,51 @@ export function QuickActionGrid({
</FrameHeader>
)}
<div className={cn('grid gap-2', kpiCols(actions.length))}>
{actions.map((action) => (
<FramePanel
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"
>
<Link
to={action.to}
search={action.search}
className="focus-visible:outline-none"
aria-label={`${action.title}: ${action.description}`}
{actions.map((action) => {
const label = `${action.title}: ${action.description}`
if ('to' in action && action.to) {
return (
<FramePanel key={action.id} className={panelClassName(action.disabled)}>
{action.disabled ? (
<div aria-disabled aria-label={label}>
<QuickActionBody action={action} />
</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} />
</Link>
</FramePanel>
))}
</FramePanel>
)
})}
</div>
</Frame>
)
+162 -72
View File
@@ -1,14 +1,20 @@
import { createFileRoute, useSearch } from '@tanstack/react-router'
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 { useState, useMemo } from 'react'
import { useMemo, useState } 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 {
FrameDataGrid,
KpiStatGrid,
QuickActionGrid,
type KpiStatItem,
type QuickActionItem,
} from '@/components/reui-kit'
import { Badge } from '@/components/reui/badge'
import { SelectMenu } from '@/components/select-field'
import { OperationsJobsCard } from '@/components/operations/operations-jobs-card'
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 { 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 { apiMutate, waitForJob } from '@/lib/api-client'
import type { JobRow, RevisionRow } from '@/types/api'
export const Route = createFileRoute('/_auth/operations')({
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() {
const search = useSearch({ from: '/_auth/operations' })
const navigate = Route.useNavigate()
const qc = useQueryClient()
const [applyOpen, setApplyOpen] = useState(false)
const [reloadOpen, setReloadOpen] = useState(false)
const revisionsQ = useQuery(operationsRevisionsQueryOptions())
const jobsQ = useQuery(operationsJobsQueryOptions())
const modulesQ = useQuery(overviewModulesQueryOptions())
const revisions = revisionsQ.data?.items ?? []
const jobs = jobsQ.data?.items ?? []
const revisions = useMemo(() => revisionsQ.data?.items ?? [], [revisionsQ.data?.items])
const jobs = useMemo(() => jobsQ.data?.items ?? [], [jobsQ.data?.items])
const nameById = moduleNameById(modulesQ.data?.items ?? [])
const refreshing = revisionsQ.isFetching || jobsQ.isFetching
const kpiLoading = revisionsQ.isLoading || jobsQ.isLoading
const kpiItems = useMemo(() => buildOperationsKpiItems(revisions, jobs), [revisions, jobs])
function refetchAll() {
void revisionsQ.refetch()
@@ -63,6 +128,7 @@ function OperationsComponent() {
},
onSuccess: () => {
toast.success('Конфигурация успешно применена')
setApplyOpen(false)
void qc.invalidateQueries({ queryKey: ['operations'] })
},
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)
return job
},
onSuccess: () => toast.success('Команда birdc configure выполнена'),
onSuccess: () => {
toast.success('Команда birdc configure выполнена')
setReloadOpen(false)
},
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 (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader
title="Ревизии и операции"
description="Деплой конфигурации, управление ревизиями и задачами"
@@ -93,36 +188,31 @@ function OperationsComponent() {
}
/>
<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>
<KpiStatGrid items={kpiItems} isLoading={kpiLoading} skeletonCount={3} />
{revisionsQ.isLoading ? (
<OperationsAnalyticsCard jobs={[]} revisions={[]} loading />
) : (
<OperationsAnalyticsCard jobs={jobs} revisions={revisions} />
)}
<QuickActionGrid
actions={quickActions}
description="Деплой конфигурации и управление локальным BIRD"
/>
<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
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 [b, setB] = useState('')
const diffQ = useQuery(operationsDiffQueryOptions(a, b))
@@ -192,41 +282,41 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
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 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>
<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>
)
}
File diff suppressed because one or more lines are too long