Compare commits

...
2 Commits
Author SHA1 Message Date
Denozordec e04fea657c feat: enhance dashboard and monitoring components with new analytics skeletons
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 49s
CI / go (push) Successful in 1m2s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 3m57s
Added the AnalyticsDashboardSkeleton component to improve loading states in the dashboard and monitoring pages. Refactored the dashboard to utilize the new skeleton for initial loading, replacing the previous SectionCardsSkeleton. Updated the overview queries to increase job limit from 10 to 100 for better data handling. Enhanced the network and operations components to incorporate new analytics cards, streamlining the user experience and improving data presentation.
2026-07-09 12:09:08 +07:00
Denozordec 452f6b2db0 feat: refactor components to utilize SelectField for improved UI consistency
Updated various components to replace traditional select implementations with the new SelectField component. This change enhances the user interface by providing a more consistent layout and improved accessibility. Additionally, refactored the dashboard and operations pages to utilize DataGridCard for better organization of content, streamlining the overall user experience.
2026-07-09 11:53:43 +07:00
44 changed files with 1829 additions and 467 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"pid": 39884,
"pid": 43636,
"version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
"startedAt": 1783489774882
"startedAt": 1783570299043
}
@@ -11,15 +11,9 @@ import {
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
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 { SelectField } from '@/components/select-field'
import { API_KEY_ROLE_ITEMS } from '@/lib/access/api-key-labels'
import { useCreateApiKeyMutation } from '@/queries/api-keys'
import type { ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '@/types/api'
@@ -89,25 +83,14 @@ export function ApiKeyCreateDialog({ open, onOpenChange, onCreated }: ApiKeyCrea
placeholder="CI / оператор UI"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="key-role">Роль</Label>
<Select
items={[...API_KEY_ROLE_ITEMS]}
value={role}
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>
<SelectField
id="key-role"
label="Роль"
items={[...API_KEY_ROLE_ITEMS]}
value={role}
placeholder="Выберите роль"
onValueChange={(v) => v && setRole(v as ApiKeyRole)}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="key-expires">Истекает (опционально)</Label>
<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 { Button } from '@evobgp/ui/components/button'
import { FrameFooter } from '@/components/reui/frame'
import { CardFooter } from '@evobgp/ui/components/card'
export function DashboardQuickActions() {
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" />}>
<Plus className="size-4" />
Создать модуль
@@ -36,6 +36,6 @@ export function DashboardQuickActions() {
<Gauge className="size-4" />
Мониторинг
</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 { Label } from '@evobgp/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { Field, FieldLabel } from '@evobgp/ui/components/field'
import { SelectMenu } from '@/components/select-field'
import {
NONE_OPTION,
communityOptionLabel,
@@ -49,28 +43,27 @@ export function CommunitySelect({
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 (
<div className="flex flex-col gap-1.5">
{label ? <Label htmlFor={id}>{label}</Label> : null}
<Select
items={items}
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>
<Field>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
{select}
</Field>
)
}
@@ -10,13 +10,8 @@ import {
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { SelectField } from '@/components/select-field'
import { Button } from '@evobgp/ui/components/button'
import { LoadingButton } from '@/components/loading-button'
@@ -174,25 +169,13 @@ export function ModuleCdnSourceDialog({
onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-kind">Тип источника</Label>
<Select
items={kindItems}
value={form.source_kind}
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>
<SelectField
id="cdn-kind"
label="Тип источника"
items={kindItems}
value={form.source_kind}
onValueChange={(v) => v && setForm((s) => ({ ...s, source_kind: v }))}
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-prefix-path">JSON path (prefix_path)</Label>
<Input
@@ -4,12 +4,8 @@ import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { cn } from "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
SelectMenu,
} from "@/components/select-field"
import { Skeleton } from "@evobgp/ui/components/skeleton"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
@@ -152,28 +148,23 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
<div className="text-muted-foreground text-sm">
{mergedProps.rowsPerPageLabel}
</div>
<Select
items={mergedProps?.sizes?.map((size: number) => ({
value: `${size}`,
label: `${size}`,
}))}
<SelectMenu
items={
mergedProps?.sizes?.map((size: number) => ({
value: `${size}`,
label: `${size}`,
})) ?? []
}
value={`${pageSize}`}
triggerClassName="w-14"
size="sm"
side="top"
contentClassName="min-w-18"
onValueChange={(value) => {
const newPageSize = Number(value)
table.setPageSize(newPageSize)
if (!value) return
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>
+100
View File
@@ -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>
)
}
+35 -2
View File
@@ -1,6 +1,7 @@
import { SectionCards } from './section-cards'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { Card, CardContent } from '@evobgp/ui/components/card'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { SectionCards } from './section-cards'
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
return (
@@ -14,6 +15,38 @@ export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
)
}
export function AnalyticsDashboardSkeleton() {
return (
<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 }) {
return (
<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',
}
}
+8
View File
@@ -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)
}
+28
View File
@@ -0,0 +1,28 @@
export type BreakdownSlice = {
key: string
label: string
count: number
color: string
}
export type CapacityBar = {
id: string
name: string
/** 0100 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'
}
+1 -1
View File
@@ -58,7 +58,7 @@ export function overviewRevisionsQueryOptions() {
export function overviewJobsQueryOptions() {
return queryOptions<JobsResponse>({
queryKey: overviewKeys.jobs(),
queryFn: () => apiJSON<JobsResponse>('/v1/jobs?limit=10'),
queryFn: () => apiJSON<JobsResponse>('/v1/jobs?limit=100'),
staleTime: 15_000,
})
}
+63 -127
View File
@@ -1,39 +1,31 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { createFileRoute } from '@tanstack/react-router'
import { useQueries } from '@tanstack/react-query'
import {
Boxes,
CheckCircle,
Clock,
GitBranch,
Info,
Radio,
RefreshCw,
Activity,
XCircle,
} from 'lucide-react'
import { CheckCircle, Info, RefreshCw, XCircle } from 'lucide-react'
import { useState } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from '@evobgp/ui/components/card'
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 { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid'
import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid'
import { PageHeader } from '@/components/page-header'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { AnalyticsDashboardSkeleton } from '@/components/skeletons'
import {
aggregateNetworkMetrics,
moduleNameById,
overviewHealthQueryOptions,
overviewJobsQueryOptions,
@@ -41,7 +33,6 @@ import {
overviewPeersQueryOptions,
overviewRevisionsQueryOptions,
overviewSpeakersQueryOptions,
runningJobCount,
} from '@/queries/overview'
export const Route = createFileRoute('/_auth/dashboard')({
@@ -49,7 +40,6 @@ export const Route = createFileRoute('/_auth/dashboard')({
})
function DashboardComponent() {
const navigate = useNavigate()
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const results = useQueries({
@@ -82,60 +72,10 @@ function DashboardComponent() {
const speakers = speakersQ.data?.items ?? []
const revisions = revisionsQ.data?.items ?? []
const jobs = jobsQ.data?.items ?? []
const modulesHasMore = modulesQ.data?.has_more ?? false
const peersHasMore = peersQ.data?.has_more ?? false
const speakersHasMore = speakersQ.data?.has_more ?? false
const revisionsHasMore = revisionsQ.data?.has_more ?? false
const net = aggregateNetworkMetrics(peers, speakers)
const running = runningJobCount(jobs)
const nameById = moduleNameById(modules)
const countBadge = (n: number, hasMore: boolean, suffix: string) => (hasMore ? '200+' : suffix)
const items: SectionCardItem[] = [
{
label: 'Модули',
value: initialLoading ? '—' : String(modules.length),
icon: <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 =
refreshing && jobs.length === 0 && revisions.length === 0 && peers.length === 0 && speakers.length === 0
refreshing && jobs.length === 0 && revisions.length === 0
return (
<div className="flex flex-col gap-5">
@@ -169,61 +109,57 @@ function DashboardComponent() {
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">
<Frame spacing="sm" className="h-full">
<FramePanel className="flex h-full flex-col p-0">
<FrameHeader className="border-b">
<FrameTitle>Недавние задачи</FrameTitle>
<FrameDescription>Последние фоновые операции</FrameDescription>
</FrameHeader>
{activityLoading ? (
<Skeleton className="m-3 h-24 w-auto" />
) : (
<DashboardRecentJobsGrid jobs={jobs} nameById={nameById} isLoading={refreshing} />
)}
</FramePanel>
</Frame>
<div className="grid gap-4 lg:grid-cols-2">
<DataGridCard
title="Недавние задачи"
description="Последние фоновые операции"
className="h-full"
>
{activityLoading ? (
<Skeleton className="m-3 h-24 w-auto" />
) : (
<DashboardRecentJobsGrid jobs={jobs.slice(0, 10)} nameById={nameById} isLoading={refreshing} />
)}
</DataGridCard>
<Frame spacing="sm" className="h-full">
<FramePanel className="flex h-full flex-col p-0">
<FrameHeader className="border-b">
<FrameTitle>Последние ревизии</FrameTitle>
<FrameDescription>История конфигураций</FrameDescription>
</FrameHeader>
{activityLoading ? (
<Skeleton className="m-3 h-24 w-auto" />
) : (
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
)}
</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>
<DataGridCard
title="Последние ревизии"
description="История конфигураций"
className="h-full"
>
{activityLoading ? (
<Skeleton className="m-3 h-24 w-auto" />
) : (
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
)}
</DataGridCard>
</div>
<Frame spacing="sm">
<FramePanel className="p-0">
<FrameHeader className="border-b">
<FrameTitle>Быстрые действия</FrameTitle>
<FrameDescription>Частые переходы к настройке и деплою</FrameDescription>
</FrameHeader>
<DashboardQuickActions />
</FramePanel>
</Frame>
<Card className="gap-0">
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Быстрые действия</CardTitle>
<CardDescription>Частые переходы к настройке и деплою</CardDescription>
</CardHeader>
<DashboardQuickActions />
</Card>
</div>
)
}
+38 -46
View File
@@ -1,6 +1,6 @@
import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Activity, AlertTriangle, Bird, Database, Gauge, HeartPulse, Info, ListTodo, RefreshCw } from 'lucide-react'
import { Activity, AlertTriangle, Bird, Database, HeartPulse, Info, ListTodo, RefreshCw } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Badge } from '@evobgp/ui/components/badge'
@@ -9,11 +9,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evob
import { Separator } from '@evobgp/ui/components/separator'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
DashboardOperationsFlowCard,
MonitoringHealthCard,
} from '@/components/analytics'
import { MonitoringReadyGrid } from '@/components/monitoring/monitoring-ready-grid'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { AnalyticsDashboardSkeleton } from '@/components/skeletons'
import {
monitoringHealthQueryOptions,
@@ -24,6 +27,7 @@ import {
} from '@/queries/monitoring'
import { networkBirdQueryOptions } from '@/queries/network'
import { operationsJobsQueryOptions } from '@/queries/operations'
import { overviewModulesQueryOptions } from '@/queries/overview'
export const Route = createFileRoute('/_auth/monitoring')({
component: MonitoringComponent,
@@ -42,53 +46,25 @@ function MonitoringComponent() {
const versionQ = useQuery(monitoringVersionQueryOptions())
const birdQ = useQuery(networkBirdQueryOptions())
const jobsQ = useQuery(operationsJobsQueryOptions())
const modulesQ = useQuery(overviewModulesQueryOptions())
const refreshing =
healthQ.isFetching ||
readyQ.isFetching ||
versionQ.isFetching ||
birdQ.isFetching ||
jobsQ.isFetching
jobsQ.isFetching ||
modulesQ.isFetching
const jobs = jobsQ.data?.items ?? []
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
const modules = modulesQ.data?.items ?? []
const failed = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const versionText = formatVersion(versionQ.data)
const items: SectionCardItem[] = [
{
label: 'Общий статус',
value: overallStatusLabel({ health: healthQ.data, ready: readyQ.data, jobsFailed: failed }),
icon: <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',
},
]
const analyticsLoading =
(healthQ.isLoading || readyQ.isLoading || jobsQ.isLoading) && jobs.length === 0
function refetchAll() {
void healthQ.refetch()
@@ -96,6 +72,7 @@ function MonitoringComponent() {
void versionQ.refetch()
void birdQ.refetch()
void jobsQ.refetch()
void modulesQ.refetch()
}
const failedJobs = jobs
@@ -123,7 +100,29 @@ function MonitoringComponent() {
</TabsList>
<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">
<Card>
@@ -179,7 +178,7 @@ function MonitoringComponent() {
</CardHeader>
<CardContent className="space-y-4">
<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
label="С ошибками"
value={failed}
@@ -322,13 +321,6 @@ interface OverallInput {
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 {
if (!input.health?.ok) return 'API недоступен или возвращает ошибку'
if (input.jobsFailed > 0) return `Есть провальные задачи (${input.jobsFailed})`
+38 -35
View File
@@ -6,6 +6,10 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evob
import { Info, RefreshCw } from 'lucide-react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
DashboardNetworkCapacityCard,
NetworkOverviewAnalyticsCard,
} from '@/components/analytics'
import { DataGridCard } from '@/components/data-grid-shell'
import { NetworkPeersGrid } from '@/components/network/network-peers-grid'
import { NetworkSpeakersGrid } from '@/components/network/network-speakers-grid'
@@ -13,7 +17,7 @@ import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { networkBirdQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions } from '@/queries/network'
import { aggregateNetworkMetrics } from '@/queries/overview'
import { overviewJobsQueryOptions } from '@/queries/overview'
export const Route = createFileRoute('/_auth/network')({
component: NetworkComponent,
@@ -29,11 +33,13 @@ function NetworkComponent() {
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
const jobsQ = useQuery(overviewJobsQueryOptions())
const refreshing = peersQ.isFetching || speakersQ.isFetching
const peers = peersQ.data?.items ?? []
const speakers = speakersQ.data?.items ?? []
const net = aggregateNetworkMetrics(peers, speakers)
const jobs = jobsQ.data?.items ?? []
const overviewLoading = peersQ.isLoading || speakersQ.isLoading
function refetchAll() {
void peersQ.refetch()
@@ -71,40 +77,37 @@ function NetworkComponent() {
</TabsList>
<TabsContent value="overview" className="mt-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Сводка сети</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 p-4 text-sm">
<Field label="Пиры всего" value={String(net.peersTotal)} />
<Field label="Established" value={`${net.peersEstablished} / ${net.peersEnabled}`} />
<Field label="Спикеры всего" value={String(net.speakersTotal)} />
<Field label="Online" value={`${net.speakersOnline} / ${net.speakersTotal}`} />
{net.peersMismatch > 0 ? (
<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 className="grid gap-4 lg:grid-cols-2">
<NetworkOverviewAnalyticsCard
peers={peers}
speakers={speakers}
loading={overviewLoading}
/>
<DashboardNetworkCapacityCard
peers={peers}
speakers={speakers}
jobs={jobs}
loading={overviewLoading}
/>
</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 value="peers" className="mt-4">
+20 -61
View File
@@ -1,28 +1,21 @@
import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Clock, Activity, Info, RefreshCw } from 'lucide-react'
import { Info, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { useState, useMemo } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
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 { OperationsAnalyticsCard } from '@/components/analytics'
import { DataGridCard } from '@/components/data-grid-shell'
import { SelectMenu } from '@/components/select-field'
import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid'
import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { operationsJobsQueryOptions, operationsRevisionsQueryOptions, operationsDiffQueryOptions } from '@/queries/operations'
@@ -52,32 +45,6 @@ function OperationsComponent() {
const nameById = moduleNameById(modulesQ.data?.items ?? [])
const refreshing = revisionsQ.isFetching || jobsQ.isFetching
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
const failed = jobs.filter(
(j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const items: SectionCardItem[] = [
{
label: 'Ревизий',
value: revisions.length,
icon: <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() {
void revisionsQ.refetch()
@@ -161,7 +128,11 @@ function OperationsComponent() {
/>
</div>
{revisionsQ.isLoading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
{revisionsQ.isLoading ? (
<OperationsAnalyticsCard jobs={[]} revisions={[]} loading />
) : (
<OperationsAnalyticsCard jobs={jobs} revisions={revisions} />
)}
<Tabs defaultValue={search.tab}>
<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 w-full max-w-xs flex-col gap-1">
<span className="text-xs text-muted-foreground">Ревизия A</span>
<Select items={revisionItems} value={a} onValueChange={(v) => v && setA(v)}>
<SelectTrigger>
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
{revisions.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.id.slice(0, 12)}
</SelectItem>
))}
</SelectContent>
</Select>
<SelectMenu
items={revisionItems}
value={a}
placeholder="Выберите"
onValueChange={(v) => v && setA(v)}
/>
</div>
<div className="flex w-full max-w-xs flex-col gap-1">
<span className="text-xs text-muted-foreground">Ревизия B</span>
<Select items={revisionItems} value={b} onValueChange={(v) => v && setB(v)}>
<SelectTrigger>
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
{revisions.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.id.slice(0, 12)}
</SelectItem>
))}
</SelectContent>
</Select>
<SelectMenu
items={revisionItems}
value={b}
placeholder="Выберите"
onValueChange={(v) => v && setB(v)}
/>
</div>
<Button onClick={() => diffQ.refetch()} disabled={!a || !b || diffQ.isFetching}>
Сравнить
+7 -19
View File
@@ -6,16 +6,10 @@ import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Input } from '@evobgp/ui/components/input'
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 { 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 { authKeys, authSessionQueryOptions } from '@/queries/auth'
import { toast } from 'sonner'
@@ -141,21 +135,15 @@ function SettingsComponent() {
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<Label htmlFor="theme-select">Тема</Label>
<Select
<SelectField
id="theme-select"
label="Тема"
items={[...THEME_SELECT_ITEMS]}
value={theme ?? 'system'}
placeholder="Выберите тему"
triggerClassName="max-w-xs"
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>
</Card>
</div>
+30 -57
View File
@@ -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 { Input } from '@evobgp/ui/components/input'
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 { SelectField } from '@/components/select-field'
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
import { PageHeader } from '@/components/page-header'
@@ -244,31 +239,20 @@ function TenantSettingsComponent() {
>
{() => (
<div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label>Авто-очистка включена</Label>
<Select
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
onValueChange={(v) =>
v &&
setRuntimeLogsForm((s) => ({
...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>
<SelectField
label="Авто-очистка включена"
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
placeholder="Выберите"
description="runtime_logs_auto_enabled"
onValueChange={(v) =>
v &&
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_enabled: v,
}))
}
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="runtime_logs_max_file_mb">Макс. размер файла (MB)</Label>
<Input
@@ -302,31 +286,20 @@ function TenantSettingsComponent() {
runtime_logs_auto_schedule
</p>
</div>
<div className="flex flex-col gap-1.5">
<Label>Режим очистки</Label>
<Select
items={[...RUNTIME_LOGS_MODE_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
onValueChange={(v) =>
v &&
setRuntimeLogsForm((s) => ({
...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>
<SelectField
label="Режим очистки"
items={[...RUNTIME_LOGS_MODE_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
placeholder="Выберите"
description="runtime_logs_auto_mode"
onValueChange={(v) =>
v &&
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_mode: v,
}))
}
/>
<div className="md:col-span-2">
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
<Save />
File diff suppressed because one or more lines are too long
+109
View File
@@ -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,
}
+81
View File
@@ -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,
}
-6
View File
@@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
@@ -111,15 +109,11 @@ function SelectLabel({
function SelectItem({
className,
children,
label,
...props
}: SelectPrimitive.Item.Props) {
const resolvedLabel = label ?? (typeof children === "string" ? children : undefined)
return (
<SelectPrimitive.Item
data-slot="select-item"
label={resolvedLabel}
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",
className
+2
View File
@@ -1,3 +1,5 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@evobgp/ui/lib/utils"