feat(analytics): remove OperationsAnalyticsCard and update imports
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]>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user