diff --git a/apps/web/src/components/analytics/analytics-activity-list.tsx b/apps/web/src/components/analytics/analytics-activity-list.tsx new file mode 100644 index 0000000..0f9c985 --- /dev/null +++ b/apps/web/src/components/analytics/analytics-activity-list.tsx @@ -0,0 +1,54 @@ +import { AlertTriangle, CheckCircle, Info } from 'lucide-react' + +import { cn } from '@evobgp/ui/lib/utils' + +import { StatusBadge } from '@/components/status-badge' +import type { PlatformActivityItem } from '@/lib/metrics' + +const KIND_ICON = { + job: Info, + revision: CheckCircle, + network: AlertTriangle, +} as const + +const KIND_ICON_CLASS = { + job: 'text-info', + revision: 'text-success', + network: 'text-warning', +} as const + +export function AnalyticsActivityList({ + items, + className, +}: { + items: PlatformActivityItem[] + className?: string +}) { + if (items.length === 0) { + return

Нет недавних событий

+ } + + return ( + + ) +} diff --git a/apps/web/src/components/analytics/analytics-card-shell.tsx b/apps/web/src/components/analytics/analytics-card-shell.tsx new file mode 100644 index 0000000..030b6a6 --- /dev/null +++ b/apps/web/src/components/analytics/analytics-card-shell.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from 'react' +import { Info } from 'lucide-react' + +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@evobgp/ui/components/card' +import { cn } from '@evobgp/ui/lib/utils' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@evobgp/ui/components/tooltip' + +export function AnalyticsCardShell({ + title, + description, + info, + actions, + footer, + className, + children, +}: { + title: string + description?: string + info?: string + actions?: ReactNode + footer?: ReactNode + className?: string + children: ReactNode +}) { + return ( + + +
+ + {title} + {info ? ( + + + + + + {info} + + + ) : null} + + {description ? {description} : null} +
+ {actions ?
{actions}
: null} +
+ {children} + {footer ? {footer} : null} +
+ ) +} diff --git a/apps/web/src/components/analytics/analytics-kpi-row.tsx b/apps/web/src/components/analytics/analytics-kpi-row.tsx new file mode 100644 index 0000000..0a0d936 --- /dev/null +++ b/apps/web/src/components/analytics/analytics-kpi-row.tsx @@ -0,0 +1,50 @@ +import { Minus, TrendingDown, TrendingUp } from 'lucide-react' + +import { cn } from '@evobgp/ui/lib/utils' + +export type AnalyticsKpiItem = { + label: string + value: string + delta?: { + direction: 'up' | 'down' | 'neutral' + label: string + tone?: 'success' | 'warning' | 'destructive' | 'muted' + } +} + +const TONE_CLASS = { + success: 'text-success', + warning: 'text-warning', + destructive: 'text-destructive', + muted: 'text-muted-foreground', +} as const + +function DeltaIcon({ direction }: { direction: AnalyticsKpiItem['delta'] extends infer D ? D extends { direction: infer Dir } ? Dir : never : never }) { + if (direction === 'up') return + if (direction === 'down') return + return +} + +export function AnalyticsKpiRow({ items, className }: { items: AnalyticsKpiItem[]; className?: string }) { + return ( +
+ {items.map((item) => ( +
+

{item.label}

+

{item.value}

+ {item.delta ? ( +

+ + {item.delta.label} +

+ ) : null} +
+ ))} +
+ ) +} diff --git a/apps/web/src/components/analytics/analytics-progress.tsx b/apps/web/src/components/analytics/analytics-progress.tsx new file mode 100644 index 0000000..d9420cd --- /dev/null +++ b/apps/web/src/components/analytics/analytics-progress.tsx @@ -0,0 +1,31 @@ +import { + Progress, + ProgressIndicator, + ProgressTrack, +} from '@evobgp/ui/components/progress' +import { cn } from '@evobgp/ui/lib/utils' + +export function AnalyticsProgress({ + label, + value, + className, +}: { + label: string + value: number + className?: string +}) { + const clamped = Math.max(0, Math.min(100, value)) + return ( +
+
+ {label} + {clamped}% +
+ + + + + +
+ ) +} diff --git a/apps/web/src/components/analytics/analytics-segment-control.tsx b/apps/web/src/components/analytics/analytics-segment-control.tsx new file mode 100644 index 0000000..c630d91 --- /dev/null +++ b/apps/web/src/components/analytics/analytics-segment-control.tsx @@ -0,0 +1,35 @@ +import { Button } from '@evobgp/ui/components/button' +import { ButtonGroup } from '@evobgp/ui/components/button-group' +import { cn } from '@evobgp/ui/lib/utils' + +export function AnalyticsSegmentControl({ + value, + onChange, + options, + className, +}: { + value: T + onChange: (value: T) => void + options: { value: T; label: string }[] + className?: string +}) { + return ( + + {options.map((option) => ( + + ))} + + ) +} diff --git a/apps/web/src/components/analytics/chart-bar-strip.tsx b/apps/web/src/components/analytics/chart-bar-strip.tsx new file mode 100644 index 0000000..293c29e --- /dev/null +++ b/apps/web/src/components/analytics/chart-bar-strip.tsx @@ -0,0 +1,41 @@ +import { Bar, BarChart, XAxis } from 'recharts' + +import { ChartContainer, type ChartConfig } from '@evobgp/ui/components/chart' +import { cn } from '@evobgp/ui/lib/utils' + +import type { CapacityBar } from '@/lib/metrics' + +const chartConfig = { + value: { label: 'Загрузка', color: 'var(--color-chart-2)' }, +} satisfies ChartConfig + +export function ChartBarStrip({ + bars, + className, +}: { + bars: CapacityBar[] + className?: string +}) { + if (bars.length === 0) { + return ( +
+ Нет данных для графика +
+ ) + } + + const data = bars.map((bar, index) => ({ + ...bar, + slot: index + 1, + fill: bar.value >= 80 ? 'var(--color-chart-2)' : 'var(--color-chart-3)', + })) + + return ( + + + + + + + ) +} diff --git a/apps/web/src/components/analytics/chart-donut-metric.tsx b/apps/web/src/components/analytics/chart-donut-metric.tsx new file mode 100644 index 0000000..784c8e9 --- /dev/null +++ b/apps/web/src/components/analytics/chart-donut-metric.tsx @@ -0,0 +1,100 @@ +import { Cell, Label, Pie, PieChart } from 'recharts' + +import { + ChartContainer, + type ChartConfig, +} from '@evobgp/ui/components/chart' +import { cn } from '@evobgp/ui/lib/utils' + +import type { BreakdownSlice } from '@/lib/metrics' + +export function ChartDonutMetric({ + slices, + centerLabel, + centerValue, + className, +}: { + slices: BreakdownSlice[] + centerLabel: string + centerValue: string | number + className?: string +}) { + const chartConfig = slices.reduce((acc, slice) => { + acc[slice.key] = { label: slice.label, color: slice.color } + return acc + }, {}) + + const data = slices.map((slice) => ({ + ...slice, + fill: slice.color, + })) + + const total = slices.reduce((sum, slice) => sum + slice.count, 0) + + if (total === 0) { + return ( +
+ Нет данных +
+ ) + } + + return ( +
+ + + + {data.map((entry) => ( + + ))} + + + + +
    + {slices.map((slice) => { + const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0' + return ( +
  • +
    + + {slice.label} +
    +
    + {slice.count} + {pct}% +
    +
  • + ) + })} +
+
+ ) +} diff --git a/apps/web/src/components/analytics/dashboard-network-capacity-card.tsx b/apps/web/src/components/analytics/dashboard-network-capacity-card.tsx new file mode 100644 index 0000000..f0bf5ff --- /dev/null +++ b/apps/web/src/components/analytics/dashboard-network-capacity-card.tsx @@ -0,0 +1,107 @@ +import { useMemo, useState } from 'react' + +import { + Avatar, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, +} from '@evobgp/ui/components/avatar' + +import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell' +import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control' +import { ChartBarStrip } from '@/components/analytics/chart-bar-strip' +import { + capacityUtilization, + peerCapacityBars, + speakerCapacityBars, +} from '@/lib/metrics' +import { runningJobCount } from '@/queries/overview' +import type { JobRow, PeerRow, SpeakerRow } from '@/types/api' + +type CapacityMode = 'peers' | 'speakers' + +function speakerInitials(speaker: SpeakerRow): string { + const label = speaker.live?.label ?? speaker.agent_domain ?? speaker.endpoint ?? speaker.id + const parts = label.split(/[.\-_@/]/).filter(Boolean) + if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase() + return label.slice(0, 2).toUpperCase() +} + +export function DashboardNetworkCapacityCard({ + peers, + speakers, + jobs, + loading, +}: { + peers: PeerRow[] + speakers: SpeakerRow[] + jobs: JobRow[] + loading?: boolean +}) { + const [mode, setMode] = useState('peers') + + const bars = useMemo( + () => (mode === 'peers' ? peerCapacityBars(peers) : speakerCapacityBars(speakers)), + [mode, peers, speakers], + ) + const utilization = capacityUtilization(bars) + const queued = runningJobCount(jobs) + const previewSpeakers = speakers.slice(0, 3) + + const deltaLabel = + mode === 'peers' + ? `${peers.filter((p) => p.enabled !== false && p.session_state === 'Established').length} Established` + : `${speakers.filter((s) => s.live?.agent_ok).length} online` + + return ( + + } + > +
+

+ {loading ? '—' : `${utilization}%`} +

+

{loading ? '…' : `${deltaLabel} · снимок live`}

+
+ + {loading ? ( +
+ Загрузка… +
+ ) : ( + + )} + +
+

+ Активных задач: {loading ? '—' : queued} +

+
+ + {previewSpeakers.map((speaker) => ( + + {speakerInitials(speaker)} + + ))} + {speakers.length > 3 ? ( + +{speakers.length - 3} + ) : null} + + {speakers.length} спикеров +
+
+
+ ) +} diff --git a/apps/web/src/components/analytics/dashboard-operations-flow-card.tsx b/apps/web/src/components/analytics/dashboard-operations-flow-card.tsx new file mode 100644 index 0000000..1f1962f --- /dev/null +++ b/apps/web/src/components/analytics/dashboard-operations-flow-card.tsx @@ -0,0 +1,59 @@ +import { useMemo, useState } from 'react' + +import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell' +import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control' +import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric' +import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics' +import type { JobRow, ModuleRow } from '@/types/api' + +type FlowMode = 'jobs' | 'modules' + +export function DashboardOperationsFlowCard({ + jobs, + modules, + loading, +}: { + jobs: JobRow[] + modules: ModuleRow[] + loading?: boolean +}) { + const [mode, setMode] = useState('jobs') + + const slices = useMemo( + () => (mode === 'jobs' ? jobStatusBreakdown(jobs) : moduleTypeBreakdown(modules)), + [mode, jobs, modules], + ) + + const total = slices.reduce((sum, slice) => sum + slice.count, 0) + const centerLabel = mode === 'jobs' ? 'Задачи' : 'Модули' + + return ( + + } + > + {loading ? ( +
+ Загрузка… +
+ ) : ( + + )} +
+ ) +} diff --git a/apps/web/src/components/analytics/dashboard-platform-card.tsx b/apps/web/src/components/analytics/dashboard-platform-card.tsx new file mode 100644 index 0000000..f5170bd --- /dev/null +++ b/apps/web/src/components/analytics/dashboard-platform-card.tsx @@ -0,0 +1,141 @@ +import { useNavigate } from '@tanstack/react-router' +import { useMemo } from 'react' + +import { Button } from '@evobgp/ui/components/button' + +import { AnalyticsActivityList } from '@/components/analytics/analytics-activity-list' +import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell' +import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row' +import { AnalyticsProgress } from '@/components/analytics/analytics-progress' +import { + deploymentProgress, + recentPlatformActivity, +} from '@/lib/metrics' +import { runningJobCount } from '@/queries/overview' +import type { JobRow, ModuleRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api' + +export function DashboardPlatformCard({ + modules, + peers, + speakers, + jobs, + revisions, + loading, +}: { + modules: ModuleRow[] + peers: PeerRow[] + speakers: SpeakerRow[] + jobs: JobRow[] + revisions: RevisionRow[] + loading?: boolean +}) { + const navigate = useNavigate() + + const enabledModules = modules.filter((m) => m.enabled !== false).length + const peersEnabled = peers.filter((p) => p.enabled !== false).length + const peersEstablished = peers.filter( + (p) => p.enabled !== false && p.session_state === 'Established', + ).length + const peersMismatch = peers.filter((p) => p.session_mismatch).length + const speakersOnline = speakers.filter((s) => s.live?.agent_ok).length + const failedJobs = jobs.filter((j) => + ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()), + ).length + const running = runningJobCount(jobs) + const riskCount = peersMismatch + failedJobs + Math.max(0, speakers.length - speakersOnline) + + const bgpPct = + peersEnabled > 0 ? Math.round((peersEstablished / peersEnabled) * 100) : null + + const deploy = useMemo(() => deploymentProgress(speakers), [speakers]) + const activity = useMemo( + () => recentPlatformActivity(jobs, revisions, peers, speakers), + [jobs, revisions, peers, speakers], + ) + + const kpis = [ + { + label: 'Модули активны', + value: loading ? '—' : `${enabledModules}/${modules.length || 0}`, + delta: { + direction: 'neutral' as const, + label: `${modules.length} всего`, + tone: 'muted' as const, + }, + }, + { + label: 'BGP готовность', + value: loading || bgpPct === null ? '—' : `${bgpPct}%`, + delta: { + direction: (bgpPct !== null && bgpPct >= 90 ? 'up' : bgpPct !== null && bgpPct < 70 ? 'down' : 'neutral') as + | 'up' + | 'down' + | 'neutral', + label: + bgpPct === null + ? 'нет включённых пиров' + : `${peersEstablished} Established`, + tone: (bgpPct !== null && bgpPct >= 90 + ? 'success' + : bgpPct !== null && bgpPct < 70 + ? 'warning' + : 'muted') as 'success' | 'warning' | 'muted', + }, + }, + { + label: 'Риски', + value: loading ? '—' : String(riskCount), + delta: { + direction: (riskCount > 0 ? 'down' : 'up') as 'up' | 'down', + label: riskCount > 0 ? `${failedJobs} задач, ${peersMismatch} mismatch` : 'в норме', + tone: (riskCount > 0 ? 'destructive' : 'success') as 'destructive' | 'success', + }, + }, + ] + + const progressLabel = + deploy.mode === 'revision' + ? `Синхронизация ревизий (${deploy.synced}/${deploy.total})` + : `Спикеры online (${deploy.synced}/${deploy.total})` + + return ( + + + + + } + > + + +
+
+ Недавняя активность + {!loading ? ( + {running} активных задач + ) : null} +
+ {loading ? ( +

Загрузка…

+ ) : ( + + )} +
+
+ ) +} diff --git a/apps/web/src/components/analytics/index.ts b/apps/web/src/components/analytics/index.ts new file mode 100644 index 0000000..23e271d --- /dev/null +++ b/apps/web/src/components/analytics/index.ts @@ -0,0 +1,13 @@ +export { AnalyticsActivityList } from './analytics-activity-list' +export { AnalyticsCardShell } from './analytics-card-shell' +export { AnalyticsKpiRow, type AnalyticsKpiItem } from './analytics-kpi-row' +export { AnalyticsProgress } from './analytics-progress' +export { AnalyticsSegmentControl } from './analytics-segment-control' +export { ChartBarStrip } from './chart-bar-strip' +export { ChartDonutMetric } from './chart-donut-metric' +export { DashboardNetworkCapacityCard } from './dashboard-network-capacity-card' +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' diff --git a/apps/web/src/components/analytics/monitoring-health-card.tsx b/apps/web/src/components/analytics/monitoring-health-card.tsx new file mode 100644 index 0000000..d14d6e4 --- /dev/null +++ b/apps/web/src/components/analytics/monitoring-health-card.tsx @@ -0,0 +1,33 @@ +import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell' +import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric' +import { readinessBreakdown } from '@/lib/metrics' +import type { ReadyStatus } from '@/queries/monitoring' + +export function MonitoringHealthCard({ + healthOk, + ready, + loading, +}: { + healthOk: boolean + ready: ReadyStatus | null | undefined + loading?: boolean +}) { + const slices = readinessBreakdown(ready, healthOk) + const total = slices.reduce((sum, slice) => sum + slice.count, 0) + + return ( + + {loading ? ( +
+ Загрузка… +
+ ) : ( + + )} +
+ ) +} diff --git a/apps/web/src/components/analytics/network-overview-analytics-card.tsx b/apps/web/src/components/analytics/network-overview-analytics-card.tsx new file mode 100644 index 0000000..57acd50 --- /dev/null +++ b/apps/web/src/components/analytics/network-overview-analytics-card.tsx @@ -0,0 +1,66 @@ +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 { peerSessionBreakdown } from '@/lib/metrics' +import { aggregateNetworkMetrics } from '@/queries/overview' +import type { PeerRow, SpeakerRow } from '@/types/api' + +export function NetworkOverviewAnalyticsCard({ + peers, + speakers, + loading, +}: { + peers: PeerRow[] + speakers: SpeakerRow[] + loading?: boolean +}) { + const net = aggregateNetworkMetrics(peers, speakers) + const slices = peerSessionBreakdown(peers) + const total = slices.reduce((sum, slice) => sum + slice.count, 0) + + return ( + + 0 ? 'down' : 'up', + label: net.peersMismatch > 0 ? `${net.peersMismatch} mismatch` : 'сессии в норме', + tone: net.peersMismatch > 0 ? 'warning' : 'success', + }, + }, + { + label: 'Спикеры online', + value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`, + delta: { + direction: net.speakersOnline < net.speakersTotal ? 'down' : 'up', + label: + net.speakersOnline < net.speakersTotal + ? `${net.speakersTotal - net.speakersOnline} offline` + : 'все online', + tone: net.speakersOnline < net.speakersTotal ? 'warning' : 'success', + }, + }, + { + label: 'Пиры всего', + value: loading ? '—' : String(net.peersTotal), + delta: { direction: 'neutral', label: 'в каталоге', tone: 'muted' }, + }, + ]} + /> + {loading ? ( +
+ Загрузка… +
+ ) : ( + + )} +
+ ) +} diff --git a/apps/web/src/components/analytics/operations-analytics-card.tsx b/apps/web/src/components/analytics/operations-analytics-card.tsx new file mode 100644 index 0000000..8bbcc8b --- /dev/null +++ b/apps/web/src/components/analytics/operations-analytics-card.tsx @@ -0,0 +1,65 @@ +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 ( + + 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 ? ( +
+ Загрузка… +
+ ) : ( + + )} +
+ ) +} diff --git a/apps/web/src/components/skeletons.tsx b/apps/web/src/components/skeletons.tsx index 6fc49dc..543f3d0 100644 --- a/apps/web/src/components/skeletons.tsx +++ b/apps/web/src/components/skeletons.tsx @@ -1,6 +1,7 @@ -import { SectionCards } from './section-cards' -import { Skeleton } from '@evobgp/ui/components/skeleton' import { Card, CardContent } from '@evobgp/ui/components/card' +import { Skeleton } from '@evobgp/ui/components/skeleton' + +import { SectionCards } from './section-cards' export function SectionCardsSkeleton({ count = 4 }: { count?: number }) { return ( @@ -14,6 +15,38 @@ export function SectionCardsSkeleton({ count = 4 }: { count?: number }) { ) } +export function AnalyticsDashboardSkeleton() { + return ( +
+ + + +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ + +
+
+ + + + + + + + + + + + + +
+ ) +} + export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) { return ( diff --git a/apps/web/src/lib/metrics/deployment-progress.ts b/apps/web/src/lib/metrics/deployment-progress.ts new file mode 100644 index 0000000..1df26bf --- /dev/null +++ b/apps/web/src/lib/metrics/deployment-progress.ts @@ -0,0 +1,32 @@ +import type { SpeakerRow } from '@/types/api' + +import type { DeploymentProgress } from './types' + +export function deploymentProgress(speakers: SpeakerRow[]): DeploymentProgress { + if (speakers.length === 0) { + return { percent: 0, synced: 0, total: 0, mode: 'online' } + } + + const withRevision = speakers.filter( + (s) => s.published_revision_id && s.last_applied_revision_id, + ) + if (withRevision.length > 0) { + const synced = withRevision.filter( + (s) => s.published_revision_id === s.last_applied_revision_id, + ).length + return { + percent: Math.round((synced / withRevision.length) * 100), + synced, + total: withRevision.length, + mode: 'revision', + } + } + + const online = speakers.filter((s) => s.live?.agent_ok).length + return { + percent: Math.round((online / speakers.length) * 100), + synced: online, + total: speakers.length, + mode: 'online', + } +} diff --git a/apps/web/src/lib/metrics/index.ts b/apps/web/src/lib/metrics/index.ts new file mode 100644 index 0000000..5f0dc4b --- /dev/null +++ b/apps/web/src/lib/metrics/index.ts @@ -0,0 +1,8 @@ +export * from './types' +export * from './job-status-breakdown' +export * from './module-type-breakdown' +export * from './peer-capacity-bars' +export * from './deployment-progress' +export * from './readiness-breakdown' +export * from './peer-session-breakdown' +export * from './recent-platform-activity' diff --git a/apps/web/src/lib/metrics/job-status-breakdown.ts b/apps/web/src/lib/metrics/job-status-breakdown.ts new file mode 100644 index 0000000..6436578 --- /dev/null +++ b/apps/web/src/lib/metrics/job-status-breakdown.ts @@ -0,0 +1,52 @@ +import type { JobRow } from '@/types/api' + +import type { BreakdownSlice } from './types' + +const STATUS_BUCKETS: { keys: string[]; label: string; color: string }[] = [ + { keys: ['succeeded', 'success'], label: 'Успешно', color: 'var(--color-chart-2)' }, + { keys: ['running', 'queued'], label: 'Активные', color: 'var(--color-chart-1)' }, + { keys: ['failed', 'error', 'cancelled'], label: 'Ошибки', color: 'var(--color-destructive)' }, +] + +function bucketForStatus(status: string): string { + const s = status.toLowerCase() + for (const bucket of STATUS_BUCKETS) { + if (bucket.keys.includes(s)) return bucket.label + } + return 'Прочее' +} + +export function jobStatusBreakdown(jobs: JobRow[]): BreakdownSlice[] { + const counts = new Map() + for (const job of jobs) { + const label = bucketForStatus(job.status) + counts.set(label, (counts.get(label) ?? 0) + 1) + } + + const slices: BreakdownSlice[] = [] + for (const bucket of STATUS_BUCKETS) { + const count = counts.get(bucket.label) ?? 0 + if (count > 0) { + slices.push({ + key: bucket.label, + label: bucket.label, + count, + color: bucket.color, + }) + } + } + const other = counts.get('Прочее') ?? 0 + if (other > 0) { + slices.push({ + key: 'other', + label: 'Прочее', + count: other, + color: 'var(--color-chart-4)', + }) + } + return slices +} + +export function jobStatusTotal(jobs: JobRow[]): number { + return jobs.length +} diff --git a/apps/web/src/lib/metrics/module-type-breakdown.ts b/apps/web/src/lib/metrics/module-type-breakdown.ts new file mode 100644 index 0000000..cbf08b1 --- /dev/null +++ b/apps/web/src/lib/metrics/module-type-breakdown.ts @@ -0,0 +1,26 @@ +import type { ModuleRow, ModuleType } from '@/types/api' + +import type { BreakdownSlice } from './types' + +const TYPE_META: Record = { + AS_PREFIXES: { label: 'AS / префиксы', color: 'var(--color-chart-1)' }, + DOMAINS: { label: 'Домены', color: 'var(--color-chart-2)' }, + CDN_CIDRS: { label: 'CDN', color: 'var(--color-chart-3)' }, + IP_RANGES: { label: 'IP-диапазоны', color: 'var(--color-chart-4)' }, +} + +export function moduleTypeBreakdown(modules: ModuleRow[]): BreakdownSlice[] { + const counts = new Map() + for (const mod of modules) { + counts.set(mod.type, (counts.get(mod.type) ?? 0) + 1) + } + + return (Object.keys(TYPE_META) as ModuleType[]) + .map((type) => ({ + key: type, + label: TYPE_META[type].label, + count: counts.get(type) ?? 0, + color: TYPE_META[type].color, + })) + .filter((slice) => slice.count > 0) +} diff --git a/apps/web/src/lib/metrics/peer-capacity-bars.ts b/apps/web/src/lib/metrics/peer-capacity-bars.ts new file mode 100644 index 0000000..fd81173 --- /dev/null +++ b/apps/web/src/lib/metrics/peer-capacity-bars.ts @@ -0,0 +1,42 @@ +import type { PeerRow, SpeakerRow } from '@/types/api' + +import type { CapacityBar } from './types' + +function peerLabel(peer: PeerRow): string { + return peer.name?.trim() || peer.neighbor || peer.id.slice(0, 8) +} + +function speakerLabel(speaker: SpeakerRow): string { + return speaker.live?.label?.trim() || speaker.agent_domain || speaker.endpoint || speaker.id.slice(0, 8) +} + +export function peerCapacityBars(peers: PeerRow[], max = 24): CapacityBar[] { + return peers + .filter((p) => p.enabled !== false) + .slice(0, max) + .map((peer) => ({ + id: peer.id, + name: peerLabel(peer), + value: peer.session_state === 'Established' ? 100 : peer.session_state ? 40 : 10, + })) +} + +export function speakerCapacityBars(speakers: SpeakerRow[], max = 24): CapacityBar[] { + return speakers.slice(0, max).map((speaker) => { + const online = speaker.live?.agent_ok === true + const established = speaker.live?.bgp_established ?? 0 + const total = speaker.live?.bgp_sessions_total ?? 0 + const ratio = total > 0 ? Math.round((established / total) * 100) : online ? 100 : 15 + return { + id: speaker.id, + name: speakerLabel(speaker), + value: online ? ratio : 10, + } + }) +} + +export function capacityUtilization(bars: CapacityBar[]): number { + if (bars.length === 0) return 0 + const sum = bars.reduce((acc, bar) => acc + bar.value, 0) + return Math.round(sum / bars.length) +} diff --git a/apps/web/src/lib/metrics/peer-session-breakdown.ts b/apps/web/src/lib/metrics/peer-session-breakdown.ts new file mode 100644 index 0000000..c093c7f --- /dev/null +++ b/apps/web/src/lib/metrics/peer-session-breakdown.ts @@ -0,0 +1,41 @@ +import type { PeerRow } from '@/types/api' + +import type { BreakdownSlice } from './types' + +export function peerSessionBreakdown(peers: PeerRow[]): BreakdownSlice[] { + const enabled = peers.filter((p) => p.enabled !== false) + const established = enabled.filter((p) => p.session_state === 'Established').length + const pending = enabled.filter( + (p) => p.session_state && p.session_state !== 'Established', + ).length + const disabled = peers.length - enabled.length + + const slices: BreakdownSlice[] = [] + if (established > 0) { + slices.push({ + key: 'established', + label: 'Established', + count: established, + color: 'var(--color-chart-2)', + }) + } + if (pending > 0) { + slices.push({ + key: 'pending', + label: 'Не Established', + count: pending, + color: 'var(--color-warning)', + }) + } + if (disabled > 0) { + slices.push({ + key: 'disabled', + label: 'Выключены', + count: disabled, + color: 'var(--color-chart-4)', + }) + } + return slices.length > 0 + ? slices + : [{ key: 'empty', label: 'Нет пиров', count: 1, color: 'var(--color-muted-foreground)' }] +} diff --git a/apps/web/src/lib/metrics/readiness-breakdown.ts b/apps/web/src/lib/metrics/readiness-breakdown.ts new file mode 100644 index 0000000..fc50d47 --- /dev/null +++ b/apps/web/src/lib/metrics/readiness-breakdown.ts @@ -0,0 +1,71 @@ +import type { ReadyStatus } from '@/queries/monitoring' + +import type { BreakdownSlice } from './types' + +function checkOk(value: boolean | { ok?: boolean; error?: string } | undefined): boolean { + if (typeof value === 'boolean') return value + if (value && typeof value === 'object') return value.ok === true + return false +} + +export function readinessBreakdown( + ready: ReadyStatus | null | undefined, + healthOk: boolean, +): BreakdownSlice[] { + if (!healthOk) { + return [ + { + key: 'health-fail', + label: 'API недоступен', + count: 1, + color: 'var(--color-destructive)', + }, + ] + } + + const checks = ready?.checks ?? {} + let okCount = 0 + let failCount = 0 + + for (const value of Object.values(checks)) { + if (checkOk(value)) okCount += 1 + else failCount += 1 + } + + const slices: BreakdownSlice[] = [ + { + key: 'health', + label: 'Health OK', + count: 1, + color: 'var(--color-chart-2)', + }, + ] + + if (okCount > 0) { + slices.push({ + key: 'checks-ok', + label: 'Checks OK', + count: okCount, + color: 'var(--color-chart-1)', + }) + } + if (failCount > 0) { + slices.push({ + key: 'checks-fail', + label: 'Checks fail', + count: failCount, + color: 'var(--color-warning)', + }) + } + + if (slices.length === 1 && okCount === 0 && failCount === 0) { + slices.push({ + key: 'ready', + label: ready?.status === 'ok' ? 'Ready' : 'Ready pending', + count: 1, + color: 'var(--color-chart-4)', + }) + } + + return slices +} diff --git a/apps/web/src/lib/metrics/recent-platform-activity.ts b/apps/web/src/lib/metrics/recent-platform-activity.ts new file mode 100644 index 0000000..275c2e2 --- /dev/null +++ b/apps/web/src/lib/metrics/recent-platform-activity.ts @@ -0,0 +1,64 @@ +import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api' + +import type { PlatformActivityItem } from './types' + +const JOB_KIND_RU: Record = { + module_refresh: 'Обновление модуля', + apply: 'Применение конфигурации', + rollback: 'Откат ревизии', + bird_reload: 'Перезагрузка BIRD', +} + +function jobMessage(job: JobRow): string { + const kind = JOB_KIND_RU[job.kind] ?? job.kind + return `${kind} · ${job.status}` +} + +export function recentPlatformActivity( + jobs: JobRow[], + revisions: RevisionRow[], + peers: PeerRow[], + speakers: SpeakerRow[], + limit = 5, +): PlatformActivityItem[] { + const items: PlatformActivityItem[] = [] + + for (const job of jobs.slice(0, 3)) { + items.push({ + id: `job-${job.job_id}`, + message: jobMessage(job), + status: job.status, + kind: 'job', + }) + } + + for (const rev of revisions.slice(0, 2)) { + items.push({ + id: `rev-${rev.id}`, + message: `Ревизия ${rev.id.slice(0, 8)}… · ${rev.materialized_prefix_count} префиксов`, + status: 'ok', + statusLabel: 'Создана', + kind: 'revision', + }) + } + + for (const peer of peers.filter((p) => p.session_mismatch).slice(0, 2)) { + items.push({ + id: `peer-${peer.id}`, + message: `Mismatch сессии: ${peer.name ?? peer.neighbor}`, + status: 'mismatch', + kind: 'network', + }) + } + + for (const speaker of speakers.filter((s) => s.live?.bgp_poll_error || s.live?.agent_ok === false).slice(0, 2)) { + items.push({ + id: `speaker-${speaker.id}`, + message: `Нода недоступна: ${speaker.live?.label ?? speaker.endpoint}`, + status: speaker.live?.agent_ok === false ? 'error' : 'warning', + kind: 'network', + }) + } + + return items.slice(0, limit) +} diff --git a/apps/web/src/lib/metrics/types.ts b/apps/web/src/lib/metrics/types.ts new file mode 100644 index 0000000..22f766b --- /dev/null +++ b/apps/web/src/lib/metrics/types.ts @@ -0,0 +1,28 @@ +export type BreakdownSlice = { + key: string + label: string + count: number + color: string +} + +export type CapacityBar = { + id: string + name: string + /** 0–100 utilization */ + value: number +} + +export type PlatformActivityItem = { + id: string + message: string + status: string + statusLabel?: string + kind: 'job' | 'revision' | 'network' +} + +export type DeploymentProgress = { + percent: number + synced: number + total: number + mode: 'revision' | 'online' +} diff --git a/apps/web/src/queries/overview.ts b/apps/web/src/queries/overview.ts index c2bb85a..70b52eb 100644 --- a/apps/web/src/queries/overview.ts +++ b/apps/web/src/queries/overview.ts @@ -58,7 +58,7 @@ export function overviewRevisionsQueryOptions() { export function overviewJobsQueryOptions() { return queryOptions({ queryKey: overviewKeys.jobs(), - queryFn: () => apiJSON('/v1/jobs?limit=10'), + queryFn: () => apiJSON('/v1/jobs?limit=100'), staleTime: 15_000, }) } diff --git a/apps/web/src/routes/_auth/dashboard.tsx b/apps/web/src/routes/_auth/dashboard.tsx index 8d4f2b1..77d75e8 100644 --- a/apps/web/src/routes/_auth/dashboard.tsx +++ b/apps/web/src/routes/_auth/dashboard.tsx @@ -1,40 +1,31 @@ -import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' import { useQueries } from '@tanstack/react-query' -import { - Boxes, - CheckCircle, - Clock, - GitBranch, - Info, - Radio, - RefreshCw, - Activity, - XCircle, -} from 'lucide-react' +import { CheckCircle, Info, RefreshCw, XCircle } from 'lucide-react' import { useState } from 'react' import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert' import { Button } from '@evobgp/ui/components/button' import { Card, - CardContent, CardDescription, CardHeader, CardTitle, } from '@evobgp/ui/components/card' import { Skeleton } from '@evobgp/ui/components/skeleton' +import { + DashboardNetworkCapacityCard, + DashboardOperationsFlowCard, + DashboardPlatformCard, +} from '@/components/analytics' import { DataGridCard } from '@/components/data-grid-shell' -import { DashboardNetworkPanel } from '@/components/dashboard/dashboard-network-panel' import { DashboardQuickActions } from '@/components/dashboard/dashboard-quick-actions' import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid' import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid' import { PageHeader } from '@/components/page-header' -import { SectionCards, type SectionCardItem } from '@/components/section-cards' -import { SectionCardsSkeleton } from '@/components/skeletons' +import { AnalyticsDashboardSkeleton } from '@/components/skeletons' import { - aggregateNetworkMetrics, moduleNameById, overviewHealthQueryOptions, overviewJobsQueryOptions, @@ -42,7 +33,6 @@ import { overviewPeersQueryOptions, overviewRevisionsQueryOptions, overviewSpeakersQueryOptions, - runningJobCount, } from '@/queries/overview' export const Route = createFileRoute('/_auth/dashboard')({ @@ -50,7 +40,6 @@ export const Route = createFileRoute('/_auth/dashboard')({ }) function DashboardComponent() { - const navigate = useNavigate() const [lastUpdated, setLastUpdated] = useState(null) const results = useQueries({ @@ -83,60 +72,10 @@ function DashboardComponent() { const speakers = speakersQ.data?.items ?? [] const revisions = revisionsQ.data?.items ?? [] const jobs = jobsQ.data?.items ?? [] - const modulesHasMore = modulesQ.data?.has_more ?? false - const peersHasMore = peersQ.data?.has_more ?? false - const speakersHasMore = speakersQ.data?.has_more ?? false - const revisionsHasMore = revisionsQ.data?.has_more ?? false - - const net = aggregateNetworkMetrics(peers, speakers) - const running = runningJobCount(jobs) const nameById = moduleNameById(modules) - const countBadge = (n: number, hasMore: boolean, suffix: string) => (hasMore ? '200+' : suffix) - - const items: SectionCardItem[] = [ - { - label: 'Модули', - value: initialLoading ? '—' : String(modules.length), - icon: , - hint: countBadge(modules.length, modulesHasMore, 'AS, CDN, домены, IP'), - onClick: () => navigate({ to: '/modules' }), - }, - { - label: 'Пиры', - value: initialLoading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`, - icon: , - hint: countBadge(peers.length, peersHasMore, 'Established / включённых'), - badge: net.peersMismatch > 0 ? `mismatch ${net.peersMismatch}` : undefined, - variant: net.peersMismatch > 0 ? 'warning' : 'default', - onClick: () => navigate({ to: '/network', search: { tab: 'peers' } }), - }, - { - label: 'Спикеры', - value: initialLoading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`, - icon: , - hint: countBadge(speakers.length, speakersHasMore, 'online / всего'), - variant: net.speakersOnline < net.speakersTotal ? 'warning' : 'default', - onClick: () => navigate({ to: '/network', search: { tab: 'overview' } }), - }, - { - label: 'Ревизии', - value: initialLoading ? '—' : String(revisions.length), - icon: , - hint: countBadge(revisions.length, revisionsHasMore, 'configs'), - onClick: () => navigate({ to: '/operations', search: { tab: 'revisions' } }), - }, - { - label: 'Активных задач', - value: initialLoading ? '—' : String(running), - icon: , - hint: 'queued и running', - onClick: () => navigate({ to: '/operations', search: { tab: 'jobs' } }), - }, - ] - const activityLoading = - refreshing && jobs.length === 0 && revisions.length === 0 && peers.length === 0 && speakers.length === 0 + refreshing && jobs.length === 0 && revisions.length === 0 return (
@@ -170,9 +109,25 @@ function DashboardComponent() { loadError={modulesQ.isError || peersQ.isError ? 'Некоторые данные не загружены' : null} /> - {initialLoading ? : } + {initialLoading ? ( + + ) : ( +
+
+ +
+ + +
+ )} -
+
) : ( - + )} @@ -196,20 +151,6 @@ function DashboardComponent() { )} - - - - Состояние сети - BGP-сессии и спикеры - - - {activityLoading ? ( - - ) : ( - - )} - -
diff --git a/apps/web/src/routes/_auth/monitoring.tsx b/apps/web/src/routes/_auth/monitoring.tsx index f44ef93..e6265b8 100644 --- a/apps/web/src/routes/_auth/monitoring.tsx +++ b/apps/web/src/routes/_auth/monitoring.tsx @@ -1,6 +1,6 @@ import { createFileRoute, useSearch } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' -import { Activity, AlertTriangle, Bird, Database, Gauge, HeartPulse, Info, ListTodo, RefreshCw } from 'lucide-react' +import { Activity, AlertTriangle, Bird, Database, HeartPulse, Info, ListTodo, RefreshCw } from 'lucide-react' import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert' import { Badge } from '@evobgp/ui/components/badge' @@ -9,11 +9,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evob import { Separator } from '@evobgp/ui/components/separator' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' +import { + DashboardOperationsFlowCard, + MonitoringHealthCard, +} from '@/components/analytics' import { MonitoringReadyGrid } from '@/components/monitoring/monitoring-ready-grid' 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 { AnalyticsDashboardSkeleton } from '@/components/skeletons' import { monitoringHealthQueryOptions, @@ -24,6 +27,7 @@ import { } from '@/queries/monitoring' import { networkBirdQueryOptions } from '@/queries/network' import { operationsJobsQueryOptions } from '@/queries/operations' +import { overviewModulesQueryOptions } from '@/queries/overview' export const Route = createFileRoute('/_auth/monitoring')({ component: MonitoringComponent, @@ -42,53 +46,25 @@ function MonitoringComponent() { const versionQ = useQuery(monitoringVersionQueryOptions()) const birdQ = useQuery(networkBirdQueryOptions()) const jobsQ = useQuery(operationsJobsQueryOptions()) + const modulesQ = useQuery(overviewModulesQueryOptions()) const refreshing = healthQ.isFetching || readyQ.isFetching || versionQ.isFetching || birdQ.isFetching || - jobsQ.isFetching + jobsQ.isFetching || + modulesQ.isFetching const jobs = jobsQ.data?.items ?? [] - const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length + const modules = modulesQ.data?.items ?? [] const failed = jobs.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()), ).length const versionText = formatVersion(versionQ.data) - - const items: SectionCardItem[] = [ - { - label: 'Общий статус', - value: overallStatusLabel({ health: healthQ.data, ready: readyQ.data, jobsFailed: failed }), - icon: , - hint: overallHint({ health: healthQ.data, jobsFailed: failed }), - }, - { - label: 'BGP сессии', - value: birdQ.data - ? `${birdQ.data.bgp_established}/${birdQ.data.bgp_sessions_total}` - : '—', - icon: , - hint: birdQ.data?.birdc_configured - ? 'Established / total на API-хосте' - : 'birdc не настроен', - }, - { - label: 'Задачи', - value: running, - icon: , - hint: `активных из ${jobs.length}`, - variant: failed > 0 ? 'warning' : 'default', - }, - { - label: 'Версия', - value: versionText, - icon: , - hint: versionQ.data?.git_sha ?? versionQ.data?.build_time ?? 'GET /v1/version', - }, - ] + const analyticsLoading = + (healthQ.isLoading || readyQ.isLoading || jobsQ.isLoading) && jobs.length === 0 function refetchAll() { void healthQ.refetch() @@ -96,6 +72,7 @@ function MonitoringComponent() { void versionQ.refetch() void birdQ.refetch() void jobsQ.refetch() + void modulesQ.refetch() } const failedJobs = jobs @@ -123,7 +100,29 @@ function MonitoringComponent() { - {refreshing ? : } + {analyticsLoading ? ( + + ) : ( +
+ + +
+ )} + + + + + Версия API: {versionText} + {versionQ.data?.git_sha ? ` · ${versionQ.data.git_sha.slice(0, 8)}` : ''} + + + {overallHint({ health: healthQ.data, jobsFailed: failed })} + +
@@ -179,7 +178,7 @@ function MonitoringComponent() {
- + j.status === 'running' || j.status === 'queued').length} /> 0) return 'Внимание' - if (input.ready?.status && input.ready.status !== 'ok') return 'Внимание' - return 'В норме' -} - function overallHint(input: OverallInput): string { if (!input.health?.ok) return 'API недоступен или возвращает ошибку' if (input.jobsFailed > 0) return `Есть провальные задачи (${input.jobsFailed})` diff --git a/apps/web/src/routes/_auth/network.tsx b/apps/web/src/routes/_auth/network.tsx index 35d7c67..d809e30 100644 --- a/apps/web/src/routes/_auth/network.tsx +++ b/apps/web/src/routes/_auth/network.tsx @@ -6,6 +6,10 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evob import { Info, RefreshCw } from 'lucide-react' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' +import { + DashboardNetworkCapacityCard, + NetworkOverviewAnalyticsCard, +} from '@/components/analytics' import { DataGridCard } from '@/components/data-grid-shell' import { NetworkPeersGrid } from '@/components/network/network-peers-grid' import { NetworkSpeakersGrid } from '@/components/network/network-speakers-grid' @@ -13,7 +17,7 @@ import { PageHeader } from '@/components/page-header' import { QueryState } from '@/components/query-state' import { TableSkeleton } from '@/components/skeletons' import { networkBirdQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions } from '@/queries/network' -import { aggregateNetworkMetrics } from '@/queries/overview' +import { overviewJobsQueryOptions } from '@/queries/overview' export const Route = createFileRoute('/_auth/network')({ component: NetworkComponent, @@ -29,11 +33,13 @@ function NetworkComponent() { const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 }) const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 }) const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 }) + const jobsQ = useQuery(overviewJobsQueryOptions()) const refreshing = peersQ.isFetching || speakersQ.isFetching const peers = peersQ.data?.items ?? [] const speakers = speakersQ.data?.items ?? [] - const net = aggregateNetworkMetrics(peers, speakers) + const jobs = jobsQ.data?.items ?? [] + const overviewLoading = peersQ.isLoading || speakersQ.isLoading function refetchAll() { void peersQ.refetch() @@ -71,40 +77,37 @@ function NetworkComponent() { -
- - - Сводка сети - - - - - - - {net.peersMismatch > 0 ? ( - - ) : null} - - - - - BIRD (control plane) - Статус birdc на хосте API - - - } - onRetry={() => birdQ.refetch()} - > - {(bird) => } - - - +
+ +
+ + + BIRD (control plane) + Статус birdc на хосте API + + + } + onRetry={() => birdQ.refetch()} + > + {(bird) => } + + + diff --git a/apps/web/src/routes/_auth/operations.tsx b/apps/web/src/routes/_auth/operations.tsx index 2b9dfa4..876a9e8 100644 --- a/apps/web/src/routes/_auth/operations.tsx +++ b/apps/web/src/routes/_auth/operations.tsx @@ -1,6 +1,6 @@ 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 { Info, RefreshCw } from 'lucide-react' import { toast } from 'sonner' import { useState, useMemo } from 'react' @@ -9,14 +9,13 @@ import { Button } from '@evobgp/ui/components/button' import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' +import { OperationsAnalyticsCard } from '@/components/analytics' import { DataGridCard } from '@/components/data-grid-shell' import { SelectMenu } from '@/components/select-field' import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid' import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid' 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' @@ -46,32 +45,6 @@ function OperationsComponent() { 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: , - hint: 'история конфигов', - }, - { - label: 'Активных задач', - value: running, - icon: , - hint: 'queued и running', - }, - { - label: 'Задач с ошибкой', - value: failed, - icon: , - hint: failed > 0 ? 'требуют внимания' : 'критичных сбоев нет', - variant: failed > 0 ? 'warning' : 'default', - }, - ] function refetchAll() { void revisionsQ.refetch() @@ -155,7 +128,11 @@ function OperationsComponent() { />
- {revisionsQ.isLoading ? : } + {revisionsQ.isLoading ? ( + + ) : ( + + )} diff --git a/apps/web/tsconfig.tsbuildinfo b/apps/web/tsconfig.tsbuildinfo index dd491e2..545de94 100644 --- a/apps/web/tsconfig.tsbuildinfo +++ b/apps/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/data-grid-shell.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-quick-actions.tsx","./src/components/dashboard/dashboard-recent-jobs-grid.tsx","./src/components/dashboard/dashboard-recent-revisions-grid.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/examples/c-select-4.tsx","./src/components/firewall/firewall-clients-grid.tsx","./src/components/firewall/firewall-rules-grid.tsx","./src/components/layout/app-shell.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/frame.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/components/schedule/schedule-jobs-grid.tsx","./src/components/schedule/schedule-modules-grid.tsx","./src/components/settings/settings-kv-grid.tsx","./src/lib/api-client.ts","./src/lib/data-grid-defaults.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/access/api-key-labels.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/data-grid-shell.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/analytics/operations-analytics-card.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-quick-actions.tsx","./src/components/dashboard/dashboard-recent-jobs-grid.tsx","./src/components/dashboard/dashboard-recent-revisions-grid.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/examples/c-select-4.tsx","./src/components/firewall/firewall-clients-grid.tsx","./src/components/firewall/firewall-rules-grid.tsx","./src/components/layout/app-shell.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/frame.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/components/schedule/schedule-jobs-grid.tsx","./src/components/schedule/schedule-modules-grid.tsx","./src/components/settings/settings-kv-grid.tsx","./src/lib/api-client.ts","./src/lib/data-grid-defaults.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/access/api-key-labels.ts","./src/lib/metrics/deployment-progress.ts","./src/lib/metrics/index.ts","./src/lib/metrics/job-status-breakdown.ts","./src/lib/metrics/module-type-breakdown.ts","./src/lib/metrics/peer-capacity-bars.ts","./src/lib/metrics/peer-session-breakdown.ts","./src/lib/metrics/readiness-breakdown.ts","./src/lib/metrics/recent-platform-activity.ts","./src/lib/metrics/types.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/packages/ui/src/components/avatar.tsx b/packages/ui/src/components/avatar.tsx new file mode 100644 index 0000000..f5c16ee --- /dev/null +++ b/packages/ui/src/components/avatar.tsx @@ -0,0 +1,109 @@ +"use client" + +import * as React from "react" +import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar" + +import { cn } from "@evobgp/ui/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}: AvatarPrimitive.Root.Props & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: AvatarPrimitive.Fallback.Props) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarBadge, +} diff --git a/packages/ui/src/components/progress.tsx b/packages/ui/src/components/progress.tsx new file mode 100644 index 0000000..55b6435 --- /dev/null +++ b/packages/ui/src/components/progress.tsx @@ -0,0 +1,81 @@ +import { Progress as ProgressPrimitive } from "@base-ui/react/progress" + +import { cn } from "@evobgp/ui/lib/utils" + +function Progress({ + className, + children, + value, + ...props +}: ProgressPrimitive.Root.Props) { + return ( + + {children} + + + + + ) +} + +function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) { + return ( + + ) +} + +function ProgressIndicator({ + className, + ...props +}: ProgressPrimitive.Indicator.Props) { + return ( + + ) +} + +function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) { + return ( + + ) +} + +function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) { + return ( + + ) +} + +export { + Progress, + ProgressTrack, + ProgressIndicator, + ProgressLabel, + ProgressValue, +}