Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e04fea657c | ||
|
|
452f6b2db0 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"pid": 39884,
|
"pid": 43636,
|
||||||
"version": "0.9.9",
|
"version": "0.9.9",
|
||||||
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
||||||
"startedAt": 1783489774882
|
"startedAt": 1783570299043
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,15 +11,9 @@ import {
|
|||||||
} from '@evobgp/ui/components/dialog'
|
} from '@evobgp/ui/components/dialog'
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
import { Label } from '@evobgp/ui/components/label'
|
import { Label } from '@evobgp/ui/components/label'
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@evobgp/ui/components/select'
|
|
||||||
|
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
import { SelectField } from '@/components/select-field'
|
||||||
import { API_KEY_ROLE_ITEMS } from '@/lib/access/api-key-labels'
|
import { API_KEY_ROLE_ITEMS } from '@/lib/access/api-key-labels'
|
||||||
import { useCreateApiKeyMutation } from '@/queries/api-keys'
|
import { useCreateApiKeyMutation } from '@/queries/api-keys'
|
||||||
import type { ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '@/types/api'
|
import type { ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '@/types/api'
|
||||||
@@ -89,25 +83,14 @@ export function ApiKeyCreateDialog({ open, onOpenChange, onCreated }: ApiKeyCrea
|
|||||||
placeholder="CI / оператор UI"
|
placeholder="CI / оператор UI"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<SelectField
|
||||||
<Label htmlFor="key-role">Роль</Label>
|
id="key-role"
|
||||||
<Select
|
label="Роль"
|
||||||
items={[...API_KEY_ROLE_ITEMS]}
|
items={[...API_KEY_ROLE_ITEMS]}
|
||||||
value={role}
|
value={role}
|
||||||
onValueChange={(v) => v && setRole(v as ApiKeyRole)}
|
placeholder="Выберите роль"
|
||||||
>
|
onValueChange={(v) => v && setRole(v as ApiKeyRole)}
|
||||||
<SelectTrigger id="key-role" className="w-full">
|
/>
|
||||||
<SelectValue placeholder="Выберите роль" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{API_KEY_ROLE_ITEMS.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="key-expires">Истекает (опционально)</Label>
|
<Label htmlFor="key-expires">Истекает (опционально)</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -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 <p className="text-sm text-muted-foreground">Нет недавних событий</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className={cn('space-y-3', className)}>
|
||||||
|
{items.map((item) => {
|
||||||
|
const Icon = KIND_ICON[item.kind]
|
||||||
|
return (
|
||||||
|
<li key={item.id} className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 items-start gap-2.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-muted/60',
|
||||||
|
KIND_ICON_CLASS[item.kind],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
<p className="text-sm leading-snug">{item.message}</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={item.status} label={item.statusLabel} />
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Card className={cn('flex h-full flex-col gap-0 overflow-hidden', className)}>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-3 border-b py-4">
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
{title}
|
||||||
|
{info ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
className="inline-flex text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
aria-label="Подробнее"
|
||||||
|
>
|
||||||
|
<Info className="size-3.5" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs text-xs">
|
||||||
|
{info}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</CardTitle>
|
||||||
|
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-1 flex-col gap-5 p-5">{children}</CardContent>
|
||||||
|
{footer ? <CardFooter className="gap-2 border-t p-4">{footer}</CardFooter> : null}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 <TrendingUp className="size-3" />
|
||||||
|
if (direction === 'down') return <TrendingDown className="size-3" />
|
||||||
|
return <Minus className="size-3" />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalyticsKpiRow({ items, className }: { items: AnalyticsKpiItem[]; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={cn('grid gap-4 sm:grid-cols-3', className)}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.label} className="min-w-0 space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground">{item.label}</p>
|
||||||
|
<p className="text-2xl font-semibold tracking-tight tabular-nums">{item.value}</p>
|
||||||
|
{item.delta ? (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 text-xs',
|
||||||
|
TONE_CLASS[item.delta.tone ?? 'muted'],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DeltaIcon direction={item.delta.direction} />
|
||||||
|
{item.delta.label}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className={cn('space-y-2', className)}>
|
||||||
|
<div className="flex items-center justify-between gap-2 text-sm">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="font-medium tabular-nums">{clamped}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={clamped} className="gap-0">
|
||||||
|
<ProgressTrack className="h-2">
|
||||||
|
<ProgressIndicator className="bg-foreground" />
|
||||||
|
</ProgressTrack>
|
||||||
|
</Progress>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<T extends string>({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
value: T
|
||||||
|
onChange: (value: T) => void
|
||||||
|
options: { value: T; label: string }[]
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ButtonGroup className={cn('rounded-lg bg-muted/50 p-0.5', className)}>
|
||||||
|
{options.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={value === option.value ? 'secondary' : 'ghost'}
|
||||||
|
className={cn(
|
||||||
|
'h-7 rounded-md px-2.5 text-xs',
|
||||||
|
value === option.value && 'bg-background shadow-sm',
|
||||||
|
)}
|
||||||
|
onClick={() => onChange(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</ButtonGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className={cn('flex h-36 items-center justify-center text-sm text-muted-foreground', className)}>
|
||||||
|
Нет данных для графика
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = bars.map((bar, index) => ({
|
||||||
|
...bar,
|
||||||
|
slot: index + 1,
|
||||||
|
fill: bar.value >= 80 ? 'var(--color-chart-2)' : 'var(--color-chart-3)',
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContainer config={chartConfig} className={cn('aspect-auto h-36 w-full', className)}>
|
||||||
|
<BarChart data={data} margin={{ top: 4, right: 0, left: 0, bottom: 0 }}>
|
||||||
|
<XAxis dataKey="slot" hide />
|
||||||
|
<Bar dataKey="value" radius={[3, 3, 0, 0]} maxBarSize={10} />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<ChartConfig>((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 (
|
||||||
|
<div className={cn('flex h-48 items-center justify-center text-sm text-muted-foreground', className)}>
|
||||||
|
Нет данных
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-6', className)}>
|
||||||
|
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
dataKey="count"
|
||||||
|
nameKey="label"
|
||||||
|
innerRadius={52}
|
||||||
|
outerRadius={72}
|
||||||
|
strokeWidth={2}
|
||||||
|
stroke="var(--color-card)"
|
||||||
|
>
|
||||||
|
{data.map((entry) => (
|
||||||
|
<Cell key={entry.key} fill={entry.fill} />
|
||||||
|
))}
|
||||||
|
<Label
|
||||||
|
content={({ viewBox }) => {
|
||||||
|
if (!viewBox || !('cx' in viewBox) || !('cy' in viewBox)) return null
|
||||||
|
const { cx, cy } = viewBox
|
||||||
|
return (
|
||||||
|
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle">
|
||||||
|
<tspan x={cx} y={(cy ?? 0) - 6} className="fill-muted-foreground text-xs">
|
||||||
|
{centerLabel}
|
||||||
|
</tspan>
|
||||||
|
<tspan x={cx} y={(cy ?? 0) + 14} className="fill-foreground text-xl font-semibold">
|
||||||
|
{centerValue}
|
||||||
|
</tspan>
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
|
||||||
|
<ul className="min-w-0 flex-1 space-y-3">
|
||||||
|
{slices.map((slice) => {
|
||||||
|
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
|
||||||
|
return (
|
||||||
|
<li key={slice.key} className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="size-2 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: slice.color }}
|
||||||
|
/>
|
||||||
|
<span className="truncate text-muted-foreground">{slice.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right tabular-nums">
|
||||||
|
<span className="font-semibold">{slice.count}</span>
|
||||||
|
<span className="ml-2 text-muted-foreground">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<CapacityMode>('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 (
|
||||||
|
<AnalyticsCardShell
|
||||||
|
title="Загрузка BGP"
|
||||||
|
description="Текущая утилизация сессий по пирам и спикерам"
|
||||||
|
info="Каждый столбец — enabled peer или speaker. Высота отражает Established/online."
|
||||||
|
actions={
|
||||||
|
<AnalyticsSegmentControl
|
||||||
|
value={mode}
|
||||||
|
onChange={setMode}
|
||||||
|
options={[
|
||||||
|
{ value: 'peers', label: 'Пиры' },
|
||||||
|
{ value: 'speakers', label: 'Спикеры' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-3xl font-semibold tracking-tight tabular-nums">
|
||||||
|
{loading ? '—' : `${utilization}%`}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-success">{loading ? '…' : `${deltaLabel} · снимок live`}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-36 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Загрузка…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ChartBarStrip bars={bars} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Активных задач: <span className="font-medium text-foreground">{loading ? '—' : queued}</span>
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AvatarGroup>
|
||||||
|
{previewSpeakers.map((speaker) => (
|
||||||
|
<Avatar key={speaker.id} size="sm">
|
||||||
|
<AvatarFallback>{speakerInitials(speaker)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
))}
|
||||||
|
{speakers.length > 3 ? (
|
||||||
|
<AvatarGroupCount>+{speakers.length - 3}</AvatarGroupCount>
|
||||||
|
) : null}
|
||||||
|
</AvatarGroup>
|
||||||
|
<span className="text-muted-foreground">{speakers.length} спикеров</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnalyticsCardShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<FlowMode>('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 (
|
||||||
|
<AnalyticsCardShell
|
||||||
|
title="Поток операций"
|
||||||
|
description="Распределение фоновых задач и типов модулей"
|
||||||
|
info="Donut строится по текущей выборке API (до 100 последних задач)."
|
||||||
|
actions={
|
||||||
|
<AnalyticsSegmentControl
|
||||||
|
value={mode}
|
||||||
|
onChange={setMode}
|
||||||
|
options={[
|
||||||
|
{ value: 'jobs', label: 'Задачи' },
|
||||||
|
{ value: 'modules', label: 'Модули' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Загрузка…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ChartDonutMetric
|
||||||
|
slices={slices}
|
||||||
|
centerLabel={centerLabel}
|
||||||
|
centerValue={total}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnalyticsCardShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<AnalyticsCardShell
|
||||||
|
title="Состояние платформы"
|
||||||
|
description="Сводка модулей, BGP и фоновых задач"
|
||||||
|
info="Актуальный снимок без исторических трендов. Обновите данные кнопкой «Обновить» на странице."
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => navigate({ to: '/schedule' })}
|
||||||
|
>
|
||||||
|
Расписание
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => navigate({ to: '/monitoring', search: { tab: 'system' } })}
|
||||||
|
>
|
||||||
|
Мониторинг
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AnalyticsKpiRow items={kpis} />
|
||||||
|
<AnalyticsProgress label={progressLabel} value={loading ? 0 : deploy.percent} />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Недавняя активность</span>
|
||||||
|
{!loading ? (
|
||||||
|
<span className="text-xs text-muted-foreground">{running} активных задач</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Загрузка…</p>
|
||||||
|
) : (
|
||||||
|
<AnalyticsActivityList items={activity} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AnalyticsCardShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -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 (
|
||||||
|
<AnalyticsCardShell
|
||||||
|
title="Доступность системы"
|
||||||
|
description="Health и readiness checks"
|
||||||
|
info="Donut отражает результат GET /v1/health и checks из GET /v1/ready."
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Загрузка…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ChartDonutMetric slices={slices} centerLabel="Checks" centerValue={total} />
|
||||||
|
)}
|
||||||
|
</AnalyticsCardShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<AnalyticsCardShell
|
||||||
|
title="Сводка BGP"
|
||||||
|
description="Established, online и mismatch по live-данным"
|
||||||
|
info="Снимок текущего состояния пиров и спикеров."
|
||||||
|
>
|
||||||
|
<AnalyticsKpiRow
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: 'Пиры Established',
|
||||||
|
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
|
||||||
|
delta: {
|
||||||
|
direction: net.peersMismatch > 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 ? (
|
||||||
|
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Загрузка…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ChartDonutMetric slices={slices} centerLabel="Пиры" centerValue={total} />
|
||||||
|
)}
|
||||||
|
</AnalyticsCardShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,11 +2,11 @@ import { Link } from '@tanstack/react-router'
|
|||||||
import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
|
import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { FrameFooter } from '@/components/reui/frame'
|
import { CardFooter } from '@evobgp/ui/components/card'
|
||||||
|
|
||||||
export function DashboardQuickActions() {
|
export function DashboardQuickActions() {
|
||||||
return (
|
return (
|
||||||
<FrameFooter className="flex flex-wrap gap-2">
|
<CardFooter className="flex flex-wrap gap-2 border-t-0 bg-transparent">
|
||||||
<Button variant="outline" size="sm" type="button" render={<Link to="/modules" />}>
|
<Button variant="outline" size="sm" type="button" render={<Link to="/modules" />}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Создать модуль
|
Создать модуль
|
||||||
@@ -36,6 +36,6 @@ export function DashboardQuickActions() {
|
|||||||
<Gauge className="size-4" />
|
<Gauge className="size-4" />
|
||||||
Мониторинг
|
Мониторинг
|
||||||
</Button>
|
</Button>
|
||||||
</FrameFooter>
|
</CardFooter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Field } from '@evobgp/ui/components/field'
|
||||||
|
|
||||||
|
import { SelectMenu } from '@/components/select-field'
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ label: 'Select an item', value: 'placeholder' as const },
|
||||||
|
...Array.from({ length: 100 }).map((_, i) => ({
|
||||||
|
label: `Item ${i}`,
|
||||||
|
value: `item-${i}` as const,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
|
export function Pattern() {
|
||||||
|
return (
|
||||||
|
<Field className="max-w-xs">
|
||||||
|
<SelectMenu items={items} placeholder="Select an item" />
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,14 +1,8 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
import { Label } from '@evobgp/ui/components/label'
|
import { Field, FieldLabel } from '@evobgp/ui/components/field'
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@evobgp/ui/components/select'
|
|
||||||
|
|
||||||
|
import { SelectMenu } from '@/components/select-field'
|
||||||
import {
|
import {
|
||||||
NONE_OPTION,
|
NONE_OPTION,
|
||||||
communityOptionLabel,
|
communityOptionLabel,
|
||||||
@@ -49,28 +43,27 @@ export function CommunitySelect({
|
|||||||
|
|
||||||
const selectValue = nullable ? nullableSelectValue(value) : (value ?? '')
|
const selectValue = nullable ? nullableSelectValue(value) : (value ?? '')
|
||||||
|
|
||||||
|
const select = (
|
||||||
|
<SelectMenu
|
||||||
|
id={id}
|
||||||
|
items={items}
|
||||||
|
value={selectValue}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (!v) return
|
||||||
|
onValueChange(nullable ? fromNullableSelect(v) : v)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!label) {
|
||||||
|
return select
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5">
|
<Field>
|
||||||
{label ? <Label htmlFor={id}>{label}</Label> : null}
|
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||||
<Select
|
{select}
|
||||||
items={items}
|
</Field>
|
||||||
value={selectValue}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
if (!v) return
|
|
||||||
onValueChange(nullable ? fromNullableSelect(v) : v)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger id={id} className="w-full">
|
|
||||||
<SelectValue placeholder={placeholder} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{items.map((item) => (
|
|
||||||
<SelectItem key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,8 @@ import {
|
|||||||
} from '@evobgp/ui/components/dialog'
|
} from '@evobgp/ui/components/dialog'
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
import { Label } from '@evobgp/ui/components/label'
|
import { Label } from '@evobgp/ui/components/label'
|
||||||
import {
|
|
||||||
Select,
|
import { SelectField } from '@/components/select-field'
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@evobgp/ui/components/select'
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
@@ -174,25 +169,13 @@ export function ModuleCdnSourceDialog({
|
|||||||
onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))}
|
onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<SelectField
|
||||||
<Label htmlFor="cdn-kind">Тип источника</Label>
|
id="cdn-kind"
|
||||||
<Select
|
label="Тип источника"
|
||||||
items={kindItems}
|
items={kindItems}
|
||||||
value={form.source_kind}
|
value={form.source_kind}
|
||||||
onValueChange={(v) => v && setForm((s) => ({ ...s, source_kind: v }))}
|
onValueChange={(v) => v && setForm((s) => ({ ...s, source_kind: v }))}
|
||||||
>
|
/>
|
||||||
<SelectTrigger id="cdn-kind" className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{kindItems.map((item) => (
|
|
||||||
<SelectItem key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="cdn-prefix-path">JSON path (prefix_path)</Label>
|
<Label htmlFor="cdn-prefix-path">JSON path (prefix_path)</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -4,12 +4,8 @@ import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
|||||||
import { cn } from "@evobgp/ui/lib/utils"
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
import {
|
import {
|
||||||
Select,
|
SelectMenu,
|
||||||
SelectContent,
|
} from "@/components/select-field"
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@evobgp/ui/components/select"
|
|
||||||
import { Skeleton } from "@evobgp/ui/components/skeleton"
|
import { Skeleton } from "@evobgp/ui/components/skeleton"
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
||||||
|
|
||||||
@@ -152,28 +148,23 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
|||||||
<div className="text-muted-foreground text-sm">
|
<div className="text-muted-foreground text-sm">
|
||||||
{mergedProps.rowsPerPageLabel}
|
{mergedProps.rowsPerPageLabel}
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<SelectMenu
|
||||||
items={mergedProps?.sizes?.map((size: number) => ({
|
items={
|
||||||
value: `${size}`,
|
mergedProps?.sizes?.map((size: number) => ({
|
||||||
label: `${size}`,
|
value: `${size}`,
|
||||||
}))}
|
label: `${size}`,
|
||||||
|
})) ?? []
|
||||||
|
}
|
||||||
value={`${pageSize}`}
|
value={`${pageSize}`}
|
||||||
|
triggerClassName="w-14"
|
||||||
|
size="sm"
|
||||||
|
side="top"
|
||||||
|
contentClassName="min-w-18"
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
const newPageSize = Number(value)
|
if (!value) return
|
||||||
table.setPageSize(newPageSize)
|
table.setPageSize(Number(value))
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<SelectTrigger className="w-14" size="sm">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent side="top" className="min-w-18">
|
|
||||||
{mergedProps?.sizes?.map((size: number) => (
|
|
||||||
<SelectItem key={size} value={`${size}`}>
|
|
||||||
{size}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import type { ComponentProps, ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { Field, FieldDescription, FieldLabel } from '@evobgp/ui/components/field'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@evobgp/ui/components/select'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
|
export type SelectMenuItem<T extends string = string> = {
|
||||||
|
value: T
|
||||||
|
label: ReactNode
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type SelectRootProps = ComponentProps<typeof Select>
|
||||||
|
|
||||||
|
export interface SelectMenuProps<T extends string = string>
|
||||||
|
extends Omit<SelectRootProps, 'children' | 'onValueChange' | 'value' | 'defaultValue'> {
|
||||||
|
items: ReadonlyArray<SelectMenuItem<T>>
|
||||||
|
value?: T | null
|
||||||
|
defaultValue?: T | null
|
||||||
|
onValueChange?: (value: T | null) => void
|
||||||
|
placeholder?: string
|
||||||
|
id?: string
|
||||||
|
triggerClassName?: string
|
||||||
|
contentClassName?: string
|
||||||
|
size?: 'sm' | 'default'
|
||||||
|
side?: ComponentProps<typeof SelectContent>['side']
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ReUI c-select-4: `items` на Root, опции в `SelectGroup`. */
|
||||||
|
export function SelectMenu<T extends string = string>({
|
||||||
|
items,
|
||||||
|
placeholder,
|
||||||
|
id,
|
||||||
|
triggerClassName,
|
||||||
|
contentClassName,
|
||||||
|
size = 'default',
|
||||||
|
side,
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
onValueChange,
|
||||||
|
...selectProps
|
||||||
|
}: SelectMenuProps<T>) {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
items={items}
|
||||||
|
value={value}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
onValueChange={(next) => onValueChange?.(next as T | null)}
|
||||||
|
{...selectProps}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={id} className={cn('w-full', triggerClassName)} size={size}>
|
||||||
|
<SelectValue placeholder={placeholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent side={side} className={contentClassName}>
|
||||||
|
<SelectGroup>
|
||||||
|
{items.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value} disabled={item.disabled}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectFieldProps<T extends string = string> extends SelectMenuProps<T> {
|
||||||
|
label?: ReactNode
|
||||||
|
description?: ReactNode
|
||||||
|
fieldClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectField<T extends string = string>({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
fieldClassName,
|
||||||
|
id,
|
||||||
|
...menuProps
|
||||||
|
}: SelectFieldProps<T>) {
|
||||||
|
return (
|
||||||
|
<Field className={fieldClassName}>
|
||||||
|
{label ? <FieldLabel htmlFor={id}>{label}</FieldLabel> : null}
|
||||||
|
<SelectMenu id={id} {...menuProps} />
|
||||||
|
{description ? (
|
||||||
|
typeof description === 'string' ? (
|
||||||
|
<FieldDescription className="font-mono text-xs">{description}</FieldDescription>
|
||||||
|
) : (
|
||||||
|
description
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 { 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 }) {
|
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
|
||||||
return (
|
return (
|
||||||
@@ -14,6 +15,38 @@ export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AnalyticsDashboardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-3 lg:grid-rows-2">
|
||||||
|
<Card className="gap-0 lg:row-span-2">
|
||||||
|
<CardContent className="space-y-4 p-5">
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-16 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-2 w-full" />
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="gap-0">
|
||||||
|
<CardContent className="space-y-4 p-5">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-10 w-24" />
|
||||||
|
<Skeleton className="h-36 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="gap-0">
|
||||||
|
<CardContent className="space-y-4 p-5">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-44 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
|
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
|
||||||
return (
|
return (
|
||||||
<Card className="gap-0">
|
<Card className="gap-0">
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -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<string, number>()
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ModuleRow, ModuleType } from '@/types/api'
|
||||||
|
|
||||||
|
import type { BreakdownSlice } from './types'
|
||||||
|
|
||||||
|
const TYPE_META: Record<ModuleType, { label: string; color: string }> = {
|
||||||
|
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<ModuleType, number>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)' }]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
|
import type { PlatformActivityItem } from './types'
|
||||||
|
|
||||||
|
const JOB_KIND_RU: Record<string, string> = {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ export function overviewRevisionsQueryOptions() {
|
|||||||
export function overviewJobsQueryOptions() {
|
export function overviewJobsQueryOptions() {
|
||||||
return queryOptions<JobsResponse>({
|
return queryOptions<JobsResponse>({
|
||||||
queryKey: overviewKeys.jobs(),
|
queryKey: overviewKeys.jobs(),
|
||||||
queryFn: () => apiJSON<JobsResponse>('/v1/jobs?limit=10'),
|
queryFn: () => apiJSON<JobsResponse>('/v1/jobs?limit=100'),
|
||||||
staleTime: 15_000,
|
staleTime: 15_000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,31 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQueries } from '@tanstack/react-query'
|
import { useQueries } from '@tanstack/react-query'
|
||||||
import {
|
import { CheckCircle, Info, RefreshCw, XCircle } from 'lucide-react'
|
||||||
Boxes,
|
|
||||||
CheckCircle,
|
|
||||||
Clock,
|
|
||||||
GitBranch,
|
|
||||||
Info,
|
|
||||||
Radio,
|
|
||||||
RefreshCw,
|
|
||||||
Activity,
|
|
||||||
XCircle,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@evobgp/ui/components/card'
|
||||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
|
|
||||||
import { DashboardNetworkPanel } from '@/components/dashboard/dashboard-network-panel'
|
import {
|
||||||
|
DashboardNetworkCapacityCard,
|
||||||
|
DashboardOperationsFlowCard,
|
||||||
|
DashboardPlatformCard,
|
||||||
|
} from '@/components/analytics'
|
||||||
|
import { DataGridCard } from '@/components/data-grid-shell'
|
||||||
import { DashboardQuickActions } from '@/components/dashboard/dashboard-quick-actions'
|
import { DashboardQuickActions } from '@/components/dashboard/dashboard-quick-actions'
|
||||||
import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid'
|
import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid'
|
||||||
import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid'
|
import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import {
|
import { AnalyticsDashboardSkeleton } from '@/components/skeletons'
|
||||||
Frame,
|
|
||||||
FrameDescription,
|
|
||||||
FrameHeader,
|
|
||||||
FramePanel,
|
|
||||||
FrameTitle,
|
|
||||||
} from '@/components/reui/frame'
|
|
||||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
|
||||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
aggregateNetworkMetrics,
|
|
||||||
moduleNameById,
|
moduleNameById,
|
||||||
overviewHealthQueryOptions,
|
overviewHealthQueryOptions,
|
||||||
overviewJobsQueryOptions,
|
overviewJobsQueryOptions,
|
||||||
@@ -41,7 +33,6 @@ import {
|
|||||||
overviewPeersQueryOptions,
|
overviewPeersQueryOptions,
|
||||||
overviewRevisionsQueryOptions,
|
overviewRevisionsQueryOptions,
|
||||||
overviewSpeakersQueryOptions,
|
overviewSpeakersQueryOptions,
|
||||||
runningJobCount,
|
|
||||||
} from '@/queries/overview'
|
} from '@/queries/overview'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/dashboard')({
|
export const Route = createFileRoute('/_auth/dashboard')({
|
||||||
@@ -49,7 +40,6 @@ export const Route = createFileRoute('/_auth/dashboard')({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function DashboardComponent() {
|
function DashboardComponent() {
|
||||||
const navigate = useNavigate()
|
|
||||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
||||||
|
|
||||||
const results = useQueries({
|
const results = useQueries({
|
||||||
@@ -82,60 +72,10 @@ function DashboardComponent() {
|
|||||||
const speakers = speakersQ.data?.items ?? []
|
const speakers = speakersQ.data?.items ?? []
|
||||||
const revisions = revisionsQ.data?.items ?? []
|
const revisions = revisionsQ.data?.items ?? []
|
||||||
const jobs = jobsQ.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 nameById = moduleNameById(modules)
|
||||||
|
|
||||||
const countBadge = (n: number, hasMore: boolean, suffix: string) => (hasMore ? '200+' : suffix)
|
|
||||||
|
|
||||||
const items: SectionCardItem[] = [
|
|
||||||
{
|
|
||||||
label: 'Модули',
|
|
||||||
value: initialLoading ? '—' : String(modules.length),
|
|
||||||
icon: <Boxes className="size-4" />,
|
|
||||||
hint: countBadge(modules.length, modulesHasMore, 'AS, CDN, домены, IP'),
|
|
||||||
onClick: () => navigate({ to: '/modules' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Пиры',
|
|
||||||
value: initialLoading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
|
|
||||||
icon: <GitBranch className="size-4" />,
|
|
||||||
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: <Radio className="size-4" />,
|
|
||||||
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: <Activity className="size-4" />,
|
|
||||||
hint: countBadge(revisions.length, revisionsHasMore, 'configs'),
|
|
||||||
onClick: () => navigate({ to: '/operations', search: { tab: 'revisions' } }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Активных задач',
|
|
||||||
value: initialLoading ? '—' : String(running),
|
|
||||||
icon: <Clock className="size-4" />,
|
|
||||||
hint: 'queued и running',
|
|
||||||
onClick: () => navigate({ to: '/operations', search: { tab: 'jobs' } }),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const activityLoading =
|
const activityLoading =
|
||||||
refreshing && jobs.length === 0 && revisions.length === 0 && peers.length === 0 && speakers.length === 0
|
refreshing && jobs.length === 0 && revisions.length === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
@@ -169,61 +109,57 @@ function DashboardComponent() {
|
|||||||
loadError={modulesQ.isError || peersQ.isError ? 'Некоторые данные не загружены' : null}
|
loadError={modulesQ.isError || peersQ.isError ? 'Некоторые данные не загружены' : null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{initialLoading ? <SectionCardsSkeleton count={5} /> : <SectionCards items={items} className="gap-3" />}
|
{initialLoading ? (
|
||||||
|
<AnalyticsDashboardSkeleton />
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-3 lg:grid-rows-2">
|
||||||
|
<div className="lg:row-span-2">
|
||||||
|
<DashboardPlatformCard
|
||||||
|
modules={modules}
|
||||||
|
peers={peers}
|
||||||
|
speakers={speakers}
|
||||||
|
jobs={jobs}
|
||||||
|
revisions={revisions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DashboardNetworkCapacityCard peers={peers} speakers={speakers} jobs={jobs} />
|
||||||
|
<DashboardOperationsFlowCard jobs={jobs} modules={modules} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<Frame spacing="sm" className="h-full">
|
<DataGridCard
|
||||||
<FramePanel className="flex h-full flex-col p-0">
|
title="Недавние задачи"
|
||||||
<FrameHeader className="border-b">
|
description="Последние фоновые операции"
|
||||||
<FrameTitle>Недавние задачи</FrameTitle>
|
className="h-full"
|
||||||
<FrameDescription>Последние фоновые операции</FrameDescription>
|
>
|
||||||
</FrameHeader>
|
{activityLoading ? (
|
||||||
{activityLoading ? (
|
<Skeleton className="m-3 h-24 w-auto" />
|
||||||
<Skeleton className="m-3 h-24 w-auto" />
|
) : (
|
||||||
) : (
|
<DashboardRecentJobsGrid jobs={jobs.slice(0, 10)} nameById={nameById} isLoading={refreshing} />
|
||||||
<DashboardRecentJobsGrid jobs={jobs} nameById={nameById} isLoading={refreshing} />
|
)}
|
||||||
)}
|
</DataGridCard>
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
<Frame spacing="sm" className="h-full">
|
<DataGridCard
|
||||||
<FramePanel className="flex h-full flex-col p-0">
|
title="Последние ревизии"
|
||||||
<FrameHeader className="border-b">
|
description="История конфигураций"
|
||||||
<FrameTitle>Последние ревизии</FrameTitle>
|
className="h-full"
|
||||||
<FrameDescription>История конфигураций</FrameDescription>
|
>
|
||||||
</FrameHeader>
|
{activityLoading ? (
|
||||||
{activityLoading ? (
|
<Skeleton className="m-3 h-24 w-auto" />
|
||||||
<Skeleton className="m-3 h-24 w-auto" />
|
) : (
|
||||||
) : (
|
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
|
||||||
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
|
)}
|
||||||
)}
|
</DataGridCard>
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
<Frame spacing="sm" className="h-full">
|
|
||||||
<FramePanel className="flex h-full flex-col p-0">
|
|
||||||
<FrameHeader className="border-b">
|
|
||||||
<FrameTitle>Состояние сети</FrameTitle>
|
|
||||||
<FrameDescription>BGP-сессии и спикеры</FrameDescription>
|
|
||||||
</FrameHeader>
|
|
||||||
{activityLoading ? (
|
|
||||||
<Skeleton className="m-3 h-24 w-auto" />
|
|
||||||
) : (
|
|
||||||
<DashboardNetworkPanel peers={peers} speakers={speakers} />
|
|
||||||
)}
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Frame spacing="sm">
|
<Card className="gap-0">
|
||||||
<FramePanel className="p-0">
|
<CardHeader className="border-b py-3">
|
||||||
<FrameHeader className="border-b">
|
<CardTitle className="text-base">Быстрые действия</CardTitle>
|
||||||
<FrameTitle>Быстрые действия</FrameTitle>
|
<CardDescription>Частые переходы к настройке и деплою</CardDescription>
|
||||||
<FrameDescription>Частые переходы к настройке и деплою</FrameDescription>
|
</CardHeader>
|
||||||
</FrameHeader>
|
<DashboardQuickActions />
|
||||||
<DashboardQuickActions />
|
</Card>
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
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 { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||||
import { Badge } from '@evobgp/ui/components/badge'
|
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 { Separator } from '@evobgp/ui/components/separator'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
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 { MonitoringReadyGrid } from '@/components/monitoring/monitoring-ready-grid'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
import { AnalyticsDashboardSkeleton } from '@/components/skeletons'
|
||||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
monitoringHealthQueryOptions,
|
monitoringHealthQueryOptions,
|
||||||
@@ -24,6 +27,7 @@ import {
|
|||||||
} from '@/queries/monitoring'
|
} from '@/queries/monitoring'
|
||||||
import { networkBirdQueryOptions } from '@/queries/network'
|
import { networkBirdQueryOptions } from '@/queries/network'
|
||||||
import { operationsJobsQueryOptions } from '@/queries/operations'
|
import { operationsJobsQueryOptions } from '@/queries/operations'
|
||||||
|
import { overviewModulesQueryOptions } from '@/queries/overview'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/monitoring')({
|
export const Route = createFileRoute('/_auth/monitoring')({
|
||||||
component: MonitoringComponent,
|
component: MonitoringComponent,
|
||||||
@@ -42,53 +46,25 @@ function MonitoringComponent() {
|
|||||||
const versionQ = useQuery(monitoringVersionQueryOptions())
|
const versionQ = useQuery(monitoringVersionQueryOptions())
|
||||||
const birdQ = useQuery(networkBirdQueryOptions())
|
const birdQ = useQuery(networkBirdQueryOptions())
|
||||||
const jobsQ = useQuery(operationsJobsQueryOptions())
|
const jobsQ = useQuery(operationsJobsQueryOptions())
|
||||||
|
const modulesQ = useQuery(overviewModulesQueryOptions())
|
||||||
|
|
||||||
const refreshing =
|
const refreshing =
|
||||||
healthQ.isFetching ||
|
healthQ.isFetching ||
|
||||||
readyQ.isFetching ||
|
readyQ.isFetching ||
|
||||||
versionQ.isFetching ||
|
versionQ.isFetching ||
|
||||||
birdQ.isFetching ||
|
birdQ.isFetching ||
|
||||||
jobsQ.isFetching
|
jobsQ.isFetching ||
|
||||||
|
modulesQ.isFetching
|
||||||
|
|
||||||
const jobs = jobsQ.data?.items ?? []
|
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) =>
|
const failed = jobs.filter((j) =>
|
||||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||||
).length
|
).length
|
||||||
|
|
||||||
const versionText = formatVersion(versionQ.data)
|
const versionText = formatVersion(versionQ.data)
|
||||||
|
const analyticsLoading =
|
||||||
const items: SectionCardItem[] = [
|
(healthQ.isLoading || readyQ.isLoading || jobsQ.isLoading) && jobs.length === 0
|
||||||
{
|
|
||||||
label: 'Общий статус',
|
|
||||||
value: overallStatusLabel({ health: healthQ.data, ready: readyQ.data, jobsFailed: failed }),
|
|
||||||
icon: <Gauge className="size-4" />,
|
|
||||||
hint: overallHint({ health: healthQ.data, jobsFailed: failed }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'BGP сессии',
|
|
||||||
value: birdQ.data
|
|
||||||
? `${birdQ.data.bgp_established}/${birdQ.data.bgp_sessions_total}`
|
|
||||||
: '—',
|
|
||||||
icon: <Bird className="size-4" />,
|
|
||||||
hint: birdQ.data?.birdc_configured
|
|
||||||
? 'Established / total на API-хосте'
|
|
||||||
: 'birdc не настроен',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Задачи',
|
|
||||||
value: running,
|
|
||||||
icon: <Activity className="size-4" />,
|
|
||||||
hint: `активных из ${jobs.length}`,
|
|
||||||
variant: failed > 0 ? 'warning' : 'default',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Версия',
|
|
||||||
value: versionText,
|
|
||||||
icon: <Gauge className="size-4" />,
|
|
||||||
hint: versionQ.data?.git_sha ?? versionQ.data?.build_time ?? 'GET /v1/version',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function refetchAll() {
|
function refetchAll() {
|
||||||
void healthQ.refetch()
|
void healthQ.refetch()
|
||||||
@@ -96,6 +72,7 @@ function MonitoringComponent() {
|
|||||||
void versionQ.refetch()
|
void versionQ.refetch()
|
||||||
void birdQ.refetch()
|
void birdQ.refetch()
|
||||||
void jobsQ.refetch()
|
void jobsQ.refetch()
|
||||||
|
void modulesQ.refetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
const failedJobs = jobs
|
const failedJobs = jobs
|
||||||
@@ -123,7 +100,29 @@ function MonitoringComponent() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="system" className="mt-4 flex flex-col gap-6">
|
<TabsContent value="system" className="mt-4 flex flex-col gap-6">
|
||||||
{refreshing ? <SectionCardsSkeleton count={4} /> : <SectionCards items={items} />}
|
{analyticsLoading ? (
|
||||||
|
<AnalyticsDashboardSkeleton />
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<MonitoringHealthCard
|
||||||
|
healthOk={healthQ.data?.ok ?? false}
|
||||||
|
ready={readyQ.data}
|
||||||
|
loading={healthQ.isLoading || readyQ.isLoading}
|
||||||
|
/>
|
||||||
|
<DashboardOperationsFlowCard jobs={jobs} modules={modules} loading={jobsQ.isLoading} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Alert className="border-muted bg-muted/30">
|
||||||
|
<Info className="size-4" />
|
||||||
|
<AlertTitle className="text-sm">
|
||||||
|
Версия API: {versionText}
|
||||||
|
{versionQ.data?.git_sha ? ` · ${versionQ.data.git_sha.slice(0, 8)}` : ''}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription className="text-xs">
|
||||||
|
{overallHint({ health: healthQ.data, jobsFailed: failed })}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -179,7 +178,7 @@ function MonitoringComponent() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex flex-wrap gap-4 text-sm">
|
<div className="flex flex-wrap gap-4 text-sm">
|
||||||
<Metric label="Активных" value={running} />
|
<Metric label="Активных" value={jobs.filter((j) => j.status === 'running' || j.status === 'queued').length} />
|
||||||
<Metric
|
<Metric
|
||||||
label="С ошибками"
|
label="С ошибками"
|
||||||
value={failed}
|
value={failed}
|
||||||
@@ -322,13 +321,6 @@ interface OverallInput {
|
|||||||
jobsFailed: number
|
jobsFailed: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function overallStatusLabel(input: OverallInput): string {
|
|
||||||
if (!input.health?.ok) return 'Ошибка'
|
|
||||||
if (input.jobsFailed > 0) return 'Внимание'
|
|
||||||
if (input.ready?.status && input.ready.status !== 'ok') return 'Внимание'
|
|
||||||
return 'В норме'
|
|
||||||
}
|
|
||||||
|
|
||||||
function overallHint(input: OverallInput): string {
|
function overallHint(input: OverallInput): string {
|
||||||
if (!input.health?.ok) return 'API недоступен или возвращает ошибку'
|
if (!input.health?.ok) return 'API недоступен или возвращает ошибку'
|
||||||
if (input.jobsFailed > 0) return `Есть провальные задачи (${input.jobsFailed})`
|
if (input.jobsFailed > 0) return `Есть провальные задачи (${input.jobsFailed})`
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evob
|
|||||||
import { Info, RefreshCw } from 'lucide-react'
|
import { Info, RefreshCw } from 'lucide-react'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||||
|
|
||||||
|
import {
|
||||||
|
DashboardNetworkCapacityCard,
|
||||||
|
NetworkOverviewAnalyticsCard,
|
||||||
|
} from '@/components/analytics'
|
||||||
import { DataGridCard } from '@/components/data-grid-shell'
|
import { DataGridCard } from '@/components/data-grid-shell'
|
||||||
import { NetworkPeersGrid } from '@/components/network/network-peers-grid'
|
import { NetworkPeersGrid } from '@/components/network/network-peers-grid'
|
||||||
import { NetworkSpeakersGrid } from '@/components/network/network-speakers-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 { QueryState } from '@/components/query-state'
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
import { networkBirdQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions } from '@/queries/network'
|
import { networkBirdQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions } from '@/queries/network'
|
||||||
import { aggregateNetworkMetrics } from '@/queries/overview'
|
import { overviewJobsQueryOptions } from '@/queries/overview'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/network')({
|
export const Route = createFileRoute('/_auth/network')({
|
||||||
component: NetworkComponent,
|
component: NetworkComponent,
|
||||||
@@ -29,11 +33,13 @@ function NetworkComponent() {
|
|||||||
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
|
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
|
||||||
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
|
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
|
||||||
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
|
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
|
||||||
|
const jobsQ = useQuery(overviewJobsQueryOptions())
|
||||||
|
|
||||||
const refreshing = peersQ.isFetching || speakersQ.isFetching
|
const refreshing = peersQ.isFetching || speakersQ.isFetching
|
||||||
const peers = peersQ.data?.items ?? []
|
const peers = peersQ.data?.items ?? []
|
||||||
const speakers = speakersQ.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() {
|
function refetchAll() {
|
||||||
void peersQ.refetch()
|
void peersQ.refetch()
|
||||||
@@ -71,40 +77,37 @@ function NetworkComponent() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="overview" className="mt-4">
|
<TabsContent value="overview" className="mt-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<Card>
|
<NetworkOverviewAnalyticsCard
|
||||||
<CardHeader className="border-b py-3">
|
peers={peers}
|
||||||
<CardTitle className="text-base">Сводка сети</CardTitle>
|
speakers={speakers}
|
||||||
</CardHeader>
|
loading={overviewLoading}
|
||||||
<CardContent className="grid grid-cols-2 gap-3 p-4 text-sm">
|
/>
|
||||||
<Field label="Пиры всего" value={String(net.peersTotal)} />
|
<DashboardNetworkCapacityCard
|
||||||
<Field label="Established" value={`${net.peersEstablished} / ${net.peersEnabled}`} />
|
peers={peers}
|
||||||
<Field label="Спикеры всего" value={String(net.speakersTotal)} />
|
speakers={speakers}
|
||||||
<Field label="Online" value={`${net.speakersOnline} / ${net.speakersTotal}`} />
|
jobs={jobs}
|
||||||
{net.peersMismatch > 0 ? (
|
loading={overviewLoading}
|
||||||
<Field label="Mismatches" value={String(net.peersMismatch)} />
|
/>
|
||||||
) : null}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="border-b py-3">
|
|
||||||
<CardTitle className="text-base">BIRD (control plane)</CardTitle>
|
|
||||||
<CardDescription>Статус birdc на хосте API</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<QueryState
|
|
||||||
data={birdQ.data}
|
|
||||||
isLoading={birdQ.isLoading}
|
|
||||||
isError={birdQ.isError}
|
|
||||||
error={birdQ.error}
|
|
||||||
skeleton={<TableSkeleton rows={3} cols={2} />}
|
|
||||||
onRetry={() => birdQ.refetch()}
|
|
||||||
>
|
|
||||||
{(bird) => <BirdSummary bird={bird} />}
|
|
||||||
</QueryState>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Card className="mt-4">
|
||||||
|
<CardHeader className="border-b py-3">
|
||||||
|
<CardTitle className="text-base">BIRD (control plane)</CardTitle>
|
||||||
|
<CardDescription>Статус birdc на хосте API</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<QueryState
|
||||||
|
data={birdQ.data}
|
||||||
|
isLoading={birdQ.isLoading}
|
||||||
|
isError={birdQ.isError}
|
||||||
|
error={birdQ.error}
|
||||||
|
skeleton={<TableSkeleton rows={3} cols={2} />}
|
||||||
|
onRetry={() => birdQ.refetch()}
|
||||||
|
>
|
||||||
|
{(bird) => <BirdSummary bird={bird} />}
|
||||||
|
</QueryState>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="peers" className="mt-4">
|
<TabsContent value="peers" className="mt-4">
|
||||||
|
|||||||
@@ -1,28 +1,21 @@
|
|||||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { AlertTriangle, Clock, Activity, Info, RefreshCw } from 'lucide-react'
|
import { Info, RefreshCw } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@evobgp/ui/components/select'
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||||
|
|
||||||
|
import { OperationsAnalyticsCard } from '@/components/analytics'
|
||||||
import { DataGridCard } from '@/components/data-grid-shell'
|
import { DataGridCard } from '@/components/data-grid-shell'
|
||||||
|
import { SelectMenu } from '@/components/select-field'
|
||||||
import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid'
|
import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid'
|
||||||
import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid'
|
import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { QueryState } from '@/components/query-state'
|
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 { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
|
||||||
import { operationsJobsQueryOptions, operationsRevisionsQueryOptions, operationsDiffQueryOptions } from '@/queries/operations'
|
import { operationsJobsQueryOptions, operationsRevisionsQueryOptions, operationsDiffQueryOptions } from '@/queries/operations'
|
||||||
@@ -52,32 +45,6 @@ function OperationsComponent() {
|
|||||||
const nameById = moduleNameById(modulesQ.data?.items ?? [])
|
const nameById = moduleNameById(modulesQ.data?.items ?? [])
|
||||||
|
|
||||||
const refreshing = revisionsQ.isFetching || jobsQ.isFetching
|
const refreshing = revisionsQ.isFetching || jobsQ.isFetching
|
||||||
const 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: <Activity className="size-4" />,
|
|
||||||
hint: 'история конфигов',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Активных задач',
|
|
||||||
value: running,
|
|
||||||
icon: <Clock className="size-4" />,
|
|
||||||
hint: 'queued и running',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Задач с ошибкой',
|
|
||||||
value: failed,
|
|
||||||
icon: <AlertTriangle className="size-4" />,
|
|
||||||
hint: failed > 0 ? 'требуют внимания' : 'критичных сбоев нет',
|
|
||||||
variant: failed > 0 ? 'warning' : 'default',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function refetchAll() {
|
function refetchAll() {
|
||||||
void revisionsQ.refetch()
|
void revisionsQ.refetch()
|
||||||
@@ -161,7 +128,11 @@ function OperationsComponent() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{revisionsQ.isLoading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
|
{revisionsQ.isLoading ? (
|
||||||
|
<OperationsAnalyticsCard jobs={[]} revisions={[]} loading />
|
||||||
|
) : (
|
||||||
|
<OperationsAnalyticsCard jobs={jobs} revisions={revisions} />
|
||||||
|
)}
|
||||||
|
|
||||||
<Tabs defaultValue={search.tab}>
|
<Tabs defaultValue={search.tab}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
@@ -245,33 +216,21 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
|
|||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<div className="flex w-full max-w-xs flex-col gap-1">
|
<div className="flex w-full max-w-xs flex-col gap-1">
|
||||||
<span className="text-xs text-muted-foreground">Ревизия A</span>
|
<span className="text-xs text-muted-foreground">Ревизия A</span>
|
||||||
<Select items={revisionItems} value={a} onValueChange={(v) => v && setA(v)}>
|
<SelectMenu
|
||||||
<SelectTrigger>
|
items={revisionItems}
|
||||||
<SelectValue placeholder="Выберите" />
|
value={a}
|
||||||
</SelectTrigger>
|
placeholder="Выберите"
|
||||||
<SelectContent>
|
onValueChange={(v) => v && setA(v)}
|
||||||
{revisions.map((r) => (
|
/>
|
||||||
<SelectItem key={r.id} value={r.id}>
|
|
||||||
{r.id.slice(0, 12)}…
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full max-w-xs flex-col gap-1">
|
<div className="flex w-full max-w-xs flex-col gap-1">
|
||||||
<span className="text-xs text-muted-foreground">Ревизия B</span>
|
<span className="text-xs text-muted-foreground">Ревизия B</span>
|
||||||
<Select items={revisionItems} value={b} onValueChange={(v) => v && setB(v)}>
|
<SelectMenu
|
||||||
<SelectTrigger>
|
items={revisionItems}
|
||||||
<SelectValue placeholder="Выберите" />
|
value={b}
|
||||||
</SelectTrigger>
|
placeholder="Выберите"
|
||||||
<SelectContent>
|
onValueChange={(v) => v && setB(v)}
|
||||||
{revisions.map((r) => (
|
/>
|
||||||
<SelectItem key={r.id} value={r.id}>
|
|
||||||
{r.id.slice(0, 12)}…
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => diffQ.refetch()} disabled={!a || !b || diffQ.isFetching}>
|
<Button onClick={() => diffQ.refetch()} disabled={!a || !b || diffQ.isFetching}>
|
||||||
Сравнить
|
Сравнить
|
||||||
|
|||||||
@@ -6,16 +6,10 @@ import { Button } from '@evobgp/ui/components/button'
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
import { Label } from '@evobgp/ui/components/label'
|
import { Label } from '@evobgp/ui/components/label'
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@evobgp/ui/components/select'
|
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
import { SelectField } from '@/components/select-field'
|
||||||
import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||||
import { authKeys, authSessionQueryOptions } from '@/queries/auth'
|
import { authKeys, authSessionQueryOptions } from '@/queries/auth'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -141,21 +135,15 @@ function SettingsComponent() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-2">
|
<CardContent className="flex flex-col gap-2">
|
||||||
<Label htmlFor="theme-select">Тема</Label>
|
<SelectField
|
||||||
<Select
|
id="theme-select"
|
||||||
|
label="Тема"
|
||||||
items={[...THEME_SELECT_ITEMS]}
|
items={[...THEME_SELECT_ITEMS]}
|
||||||
value={theme ?? 'system'}
|
value={theme ?? 'system'}
|
||||||
|
placeholder="Выберите тему"
|
||||||
|
triggerClassName="max-w-xs"
|
||||||
onValueChange={(v) => v && setTheme(v)}
|
onValueChange={(v) => v && setTheme(v)}
|
||||||
>
|
/>
|
||||||
<SelectTrigger id="theme-select" className="w-full max-w-xs">
|
|
||||||
<SelectValue placeholder="Выберите тему" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="light">Светлая</SelectItem>
|
|
||||||
<SelectItem value="dark">Тёмная</SelectItem>
|
|
||||||
<SelectItem value="system">Как в системе</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,15 +8,10 @@ import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
import { Label } from '@evobgp/ui/components/label'
|
import { Label } from '@evobgp/ui/components/label'
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@evobgp/ui/components/select'
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||||
|
|
||||||
|
import { SelectField } from '@/components/select-field'
|
||||||
|
|
||||||
|
|
||||||
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
|
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
@@ -244,31 +239,20 @@ function TenantSettingsComponent() {
|
|||||||
>
|
>
|
||||||
{() => (
|
{() => (
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="flex flex-col gap-1.5">
|
<SelectField
|
||||||
<Label>Авто-очистка включена</Label>
|
label="Авто-очистка включена"
|
||||||
<Select
|
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
|
||||||
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
|
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
|
||||||
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
|
placeholder="Выберите"
|
||||||
onValueChange={(v) =>
|
description="runtime_logs_auto_enabled"
|
||||||
v &&
|
onValueChange={(v) =>
|
||||||
setRuntimeLogsForm((s) => ({
|
v &&
|
||||||
...s,
|
setRuntimeLogsForm((s) => ({
|
||||||
runtime_logs_auto_enabled: v,
|
...s,
|
||||||
}))
|
runtime_logs_auto_enabled: v,
|
||||||
}
|
}))
|
||||||
>
|
}
|
||||||
<SelectTrigger>
|
/>
|
||||||
<SelectValue placeholder="Выберите" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="true">Вкл</SelectItem>
|
|
||||||
<SelectItem value="false">Выкл</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="font-mono text-xs text-muted-foreground">
|
|
||||||
runtime_logs_auto_enabled
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="runtime_logs_max_file_mb">Макс. размер файла (MB)</Label>
|
<Label htmlFor="runtime_logs_max_file_mb">Макс. размер файла (MB)</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -302,31 +286,20 @@ function TenantSettingsComponent() {
|
|||||||
runtime_logs_auto_schedule
|
runtime_logs_auto_schedule
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<SelectField
|
||||||
<Label>Режим очистки</Label>
|
label="Режим очистки"
|
||||||
<Select
|
items={[...RUNTIME_LOGS_MODE_ITEMS]}
|
||||||
items={[...RUNTIME_LOGS_MODE_ITEMS]}
|
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
|
||||||
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
|
placeholder="Выберите"
|
||||||
onValueChange={(v) =>
|
description="runtime_logs_auto_mode"
|
||||||
v &&
|
onValueChange={(v) =>
|
||||||
setRuntimeLogsForm((s) => ({
|
v &&
|
||||||
...s,
|
setRuntimeLogsForm((s) => ({
|
||||||
runtime_logs_auto_mode: v,
|
...s,
|
||||||
}))
|
runtime_logs_auto_mode: v,
|
||||||
}
|
}))
|
||||||
>
|
}
|
||||||
<SelectTrigger>
|
/>
|
||||||
<SelectValue placeholder="Выберите" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="truncate">truncate — обнулить</SelectItem>
|
|
||||||
<SelectItem value="delete">delete — удалить файл</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="font-mono text-xs text-muted-foreground">
|
|
||||||
runtime_logs_auto_mode
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
|
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
|
||||||
<Save />
|
<Save />
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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 (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn(
|
||||||
|
"aspect-square size-full rounded-full object-cover",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AvatarPrimitive.Fallback.Props) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="avatar-badge"
|
||||||
|
className={cn(
|
||||||
|
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||||
|
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>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 (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group"
|
||||||
|
className={cn(
|
||||||
|
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroupCount({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group-count"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>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,
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
value={value}
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn("flex flex-wrap gap-3", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ProgressTrack>
|
||||||
|
<ProgressIndicator />
|
||||||
|
</ProgressTrack>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Track
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-slot="progress-track"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressIndicator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ProgressPrimitive.Indicator.Props) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className={cn("h-full bg-primary transition-all", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Label
|
||||||
|
className={cn("text-sm font-medium", className)}
|
||||||
|
data-slot="progress-label"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Value
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-sm text-muted-foreground tabular-nums",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-slot="progress-value"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Progress,
|
||||||
|
ProgressTrack,
|
||||||
|
ProgressIndicator,
|
||||||
|
ProgressLabel,
|
||||||
|
ProgressValue,
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||||
|
|
||||||
@@ -111,15 +109,11 @@ function SelectLabel({
|
|||||||
function SelectItem({
|
function SelectItem({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
label,
|
|
||||||
...props
|
...props
|
||||||
}: SelectPrimitive.Item.Props) {
|
}: SelectPrimitive.Item.Props) {
|
||||||
const resolvedLabel = label ?? (typeof children === "string" ? children : undefined)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
label={resolvedLabel}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
className
|
className
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||||
|
|
||||||
import { cn } from "@evobgp/ui/lib/utils"
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
|||||||
Reference in New Issue
Block a user