Compare commits

...
2 Commits
Author SHA1 Message Date
DenozordecandCursor 6a25a3d137 fix(web): parse string readiness checks on monitoring System tab
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 38s
CI / web (push) Successful in 54s
CI / release (push) Successful in 3m59s
Исправляет ложные «Ошибки проверок»: GET /v1/ready отдаёт строки ok/memory, а не boolean. Уплотнён ReUI Frame/donut layout на вкладке Система.

Co-authored-by: Cursor <[email protected]>
2026-08-12 13:41:58 +07:00
DenozordecandCursor 32d9dc6acb feat(directories): edit communities and DoH via row actions menu
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 36s
CI / web (push) Successful in 55s
CI / release (push) Successful in 4m28s
В data-grid справочников добавлено меню «⋯» с пунктом «Редактировать»
и FormDrawer на PATCH /v1/communities/{id} и /v1/doh-profiles/{id}.

Co-authored-by: Cursor <[email protected]>
2026-08-12 12:42:01 +07:00
15 changed files with 493 additions and 113 deletions
@@ -40,7 +40,7 @@ export function ChartDonutMetric({
} }
return ( return (
<div className={cn('flex items-center gap-6', className)}> <div className={cn('flex flex-col items-center justify-start gap-4 sm:flex-row sm:gap-6', className)}>
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0"> <ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
<PieChart> <PieChart>
<Pie <Pie
@@ -75,7 +75,7 @@ export function ChartDonutMetric({
</PieChart> </PieChart>
</ChartContainer> </ChartContainer>
<ul className="min-w-0 flex-1 space-y-3"> <ul className="flex w-full min-w-0 max-w-xs flex-col gap-3 sm:w-auto sm:min-w-[10rem]">
{slices.map((slice) => { {slices.map((slice) => {
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0' const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
return ( return (
@@ -7,24 +7,40 @@ import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer' import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { useCreateCommunityMutation } from '@/queries/directories' import {
import type { BgpCommunityCreate } from '@/types/api' useCreateCommunityMutation,
useUpdateCommunityMutation,
} from '@/queries/directories'
import type { BgpCommunity, BgpCommunityCreate, BgpCommunityPatch } from '@/types/api'
interface CommunityCreateDialogProps { interface CommunityFormDialogProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
editTarget?: BgpCommunity | null
} }
export function CommunityCreateDialog({ open, onOpenChange }: CommunityCreateDialogProps) { /** @see https://reui.io/preview/base/form-7 */
export function CommunityFormDialog({
open,
onOpenChange,
editTarget = null,
}: CommunityFormDialogProps) {
const createMutation = useCreateCommunityMutation() const createMutation = useCreateCommunityMutation()
const updateMutation = useUpdateCommunityMutation()
const saving = createMutation.isPending || updateMutation.isPending
const [community, setCommunity] = useState('') const [community, setCommunity] = useState('')
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
setCommunity('') if (editTarget) {
setTitle('') setCommunity(editTarget.community ?? '')
}, [open]) setTitle(editTarget.title ?? '')
} else {
setCommunity('')
setTitle('')
}
}, [editTarget, open])
async function save() { async function save() {
const value = community.trim() const value = community.trim()
@@ -32,11 +48,19 @@ export function CommunityCreateDialog({ open, onOpenChange }: CommunityCreateDia
toast.error('Укажите community') toast.error('Укажите community')
return return
} }
const body: BgpCommunityCreate = { community: value } const titleTrimmed = title.trim()
const t = title.trim()
if (t) body.title = t
try { try {
await createMutation.mutateAsync(body) if (editTarget) {
const body: BgpCommunityPatch = {
community: value,
title: titleTrimmed || '',
}
await updateMutation.mutateAsync({ id: editTarget.id, body })
} else {
const body: BgpCommunityCreate = { community: value }
if (titleTrimmed) body.title = titleTrimmed
await createMutation.mutateAsync(body)
}
onOpenChange(false) onOpenChange(false)
} catch { } catch {
// toast in mutation // toast in mutation
@@ -47,7 +71,7 @@ export function CommunityCreateDialog({ open, onOpenChange }: CommunityCreateDia
<FormDrawer <FormDrawer
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title="Новое сообщество BGP" title={editTarget ? 'Редактировать сообщество' : 'Новое сообщество BGP'}
description="Тег для префиксов в фильтрах BIRD" description="Тег для префиксов в фильтрах BIRD"
className="sm:max-w-sm" className="sm:max-w-sm"
footer={ footer={
@@ -55,8 +79,8 @@ export function CommunityCreateDialog({ open, onOpenChange }: CommunityCreateDia
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}> <Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена Отмена
</Button> </Button>
<LoadingButton type="button" loading={createMutation.isPending} onClick={() => void save()}> <LoadingButton type="button" loading={saving} onClick={() => void save()}>
Создать {editTarget ? 'Сохранить' : 'Создать'}
</LoadingButton> </LoadingButton>
</> </>
} }
@@ -82,3 +106,6 @@ export function CommunityCreateDialog({ open, onOpenChange }: CommunityCreateDia
</FormDrawer> </FormDrawer>
) )
} }
/** @deprecated Use CommunityFormDialog */
export const CommunityCreateDialog = CommunityFormDialog
@@ -4,6 +4,7 @@ import { useMemo } from 'react'
import { CategoryBadge } from '@/components/category-badge' import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell' import { DataGridSection } from '@/components/data-grid-shell'
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { BgpCommunity } from '@/types/api' import type { BgpCommunity } from '@/types/api'
@@ -11,12 +12,16 @@ import type { BgpCommunity } from '@/types/api'
export function DirectoriesCommunitiesGrid({ export function DirectoriesCommunitiesGrid({
items, items,
isLoading = false, isLoading = false,
canWrite = false,
onEdit,
}: { }: {
items: BgpCommunity[] items: BgpCommunity[]
isLoading?: boolean isLoading?: boolean
canWrite?: boolean
onEdit?: (row: BgpCommunity) => void
}) { }) {
const columns = useMemo<ColumnDef<BgpCommunity>[]>( const columns = useMemo<ColumnDef<BgpCommunity>[]>(() => {
() => [ const cols: ColumnDef<BgpCommunity>[] = [
{ {
accessorKey: 'title', accessorKey: 'title',
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
@@ -38,9 +43,23 @@ export function DirectoriesCommunitiesGrid({
cell: () => <CategoryBadge>community</CategoryBadge>, cell: () => <CategoryBadge>community</CategoryBadge>,
meta: { headerTitle: 'Тип' }, meta: { headerTitle: 'Тип' },
}, },
], ]
[],
) if (canWrite && onEdit) {
cols.push({
id: 'actions',
enableSorting: false,
enableHiding: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => (
<DirectoriesRowActions onEdit={() => onEdit(row.original)} />
),
meta: { headerTitle: 'Действия' },
})
}
return cols
}, [canWrite, onEdit])
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({ const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items, data: items,
@@ -4,6 +4,7 @@ import { useMemo } from 'react'
import { CategoryBadge } from '@/components/category-badge' import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell' import { DataGridSection } from '@/components/data-grid-shell'
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { DohProfile } from '@/types/api' import type { DohProfile } from '@/types/api'
@@ -11,12 +12,16 @@ import type { DohProfile } from '@/types/api'
export function DirectoriesDohGrid({ export function DirectoriesDohGrid({
items, items,
isLoading = false, isLoading = false,
canWrite = false,
onEdit,
}: { }: {
items: DohProfile[] items: DohProfile[]
isLoading?: boolean isLoading?: boolean
canWrite?: boolean
onEdit?: (row: DohProfile) => void
}) { }) {
const columns = useMemo<ColumnDef<DohProfile>[]>( const columns = useMemo<ColumnDef<DohProfile>[]>(() => {
() => [ const cols: ColumnDef<DohProfile>[] = [
{ {
id: 'name', id: 'name',
accessorFn: (row) => row.name ?? row.url, accessorFn: (row) => row.name ?? row.url,
@@ -43,9 +48,23 @@ export function DirectoriesDohGrid({
cell: () => <CategoryBadge></CategoryBadge>, cell: () => <CategoryBadge></CategoryBadge>,
meta: { headerTitle: 'По умолчанию' }, meta: { headerTitle: 'По умолчанию' },
}, },
], ]
[],
) if (canWrite && onEdit) {
cols.push({
id: 'actions',
enableSorting: false,
enableHiding: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => (
<DirectoriesRowActions onEdit={() => onEdit(row.original)} />
),
meta: { headerTitle: 'Действия' },
})
}
return cols
}, [canWrite, onEdit])
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({ const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items, data: items,
@@ -0,0 +1,36 @@
import { MoreHorizontalIcon, PencilIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
/**
* Data-grid row actions — ⋯ menu (ReUI / shadcn DropdownMenu).
* @see https://reui.io/preview/base/components/c-dropdown-menu-12
* @see https://reui.io/docs/components/base/dropdown-menu
*/
export function DirectoriesRowActions({ onEdit }: { onEdit: () => void }) {
return (
<div className="flex justify-end">
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button type="button" variant="ghost" size="icon-sm" aria-label="Действия">
<MoreHorizontalIcon className="size-4" aria-hidden />
</Button>
}
/>
<DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuItem onClick={onEdit}>
<PencilIcon aria-hidden />
Редактировать
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
@@ -7,26 +7,47 @@ import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer' import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { useCreateDohProfileMutation } from '@/queries/directories' import {
import type { DohProfileCreate } from '@/types/api' useCreateDohProfileMutation,
useUpdateDohProfileMutation,
} from '@/queries/directories'
import type { DohProfile, DohProfileCreate, DohProfilePatch } from '@/types/api'
interface DohProfileCreateDialogProps { interface DohProfileFormDialogProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
editTarget?: DohProfile | null
} }
export function DohProfileCreateDialog({ open, onOpenChange }: DohProfileCreateDialogProps) { /** @see https://reui.io/preview/base/form-7 */
export function DohProfileFormDialog({
open,
onOpenChange,
editTarget = null,
}: DohProfileFormDialogProps) {
const createMutation = useCreateDohProfileMutation() const createMutation = useCreateDohProfileMutation()
const updateMutation = useUpdateDohProfileMutation()
const saving = createMutation.isPending || updateMutation.isPending
const [name, setName] = useState('') const [name, setName] = useState('')
const [url, setUrl] = useState('') const [url, setUrl] = useState('')
const [timeoutMs, setTimeoutMs] = useState('') const [timeoutMs, setTimeoutMs] = useState('')
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
setName('') if (editTarget) {
setUrl('') setName(editTarget.name ?? '')
setTimeoutMs('') setUrl(editTarget.url ?? '')
}, [open]) setTimeoutMs(
editTarget.timeout_ms === null || editTarget.timeout_ms === undefined
? ''
: String(editTarget.timeout_ms),
)
} else {
setName('')
setUrl('')
setTimeoutMs('')
}
}, [editTarget, open])
async function save() { async function save() {
const trimmedUrl = url.trim() const trimmedUrl = url.trim()
@@ -45,20 +66,32 @@ export function DohProfileCreateDialog({ open, onOpenChange }: DohProfileCreateD
return return
} }
const body: DohProfileCreate = { url: trimmedUrl } let timeout: number | null = null
const n = name.trim()
if (n) body.name = n
if (timeoutMs.trim() !== '') { if (timeoutMs.trim() !== '') {
const ms = Number(timeoutMs) const ms = Number(timeoutMs)
if (!Number.isFinite(ms) || !Number.isInteger(ms) || ms <= 0) { if (!Number.isFinite(ms) || !Number.isInteger(ms) || ms <= 0) {
toast.error('Timeout должен быть целым числом > 0') toast.error('Timeout должен быть целым числом > 0')
return return
} }
body.timeout_ms = ms timeout = ms
} }
const nameTrimmed = name.trim()
try { try {
await createMutation.mutateAsync(body) if (editTarget) {
const body: DohProfilePatch = {
url: trimmedUrl,
name: nameTrimmed || undefined,
timeout_ms: timeout,
}
await updateMutation.mutateAsync({ id: editTarget.id, body })
} else {
const body: DohProfileCreate = { url: trimmedUrl }
if (nameTrimmed) body.name = nameTrimmed
if (timeout !== null) body.timeout_ms = timeout
await createMutation.mutateAsync(body)
}
onOpenChange(false) onOpenChange(false)
} catch { } catch {
// toast in mutation // toast in mutation
@@ -69,7 +102,7 @@ export function DohProfileCreateDialog({ open, onOpenChange }: DohProfileCreateD
<FormDrawer <FormDrawer
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title="Новый DoH-профиль" title={editTarget ? 'Редактировать DoH-профиль' : 'Новый DoH-профиль'}
description="Резолвер DNS-over-HTTPS для доменных модулей" description="Резолвер DNS-over-HTTPS для доменных модулей"
className="sm:max-w-sm" className="sm:max-w-sm"
footer={ footer={
@@ -77,8 +110,8 @@ export function DohProfileCreateDialog({ open, onOpenChange }: DohProfileCreateD
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}> <Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена Отмена
</Button> </Button>
<LoadingButton type="button" loading={createMutation.isPending} onClick={() => void save()}> <LoadingButton type="button" loading={saving} onClick={() => void save()}>
Создать {editTarget ? 'Сохранить' : 'Создать'}
</LoadingButton> </LoadingButton>
</> </>
} }
@@ -116,3 +149,6 @@ export function DohProfileCreateDialog({ open, onOpenChange }: DohProfileCreateD
</FormDrawer> </FormDrawer>
) )
} }
/** @deprecated Use DohProfileFormDialog */
export const DohProfileCreateDialog = DohProfileFormDialog
@@ -7,12 +7,20 @@ import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { jobStatusRu, readyCheckRu } from '@/lib/ui-labels' import {
isReadyCheckOk,
isSystemReady,
readyCheckStatusLabel,
} from '@/lib/metrics'
import { readyCheckRu } from '@/lib/ui-labels'
import type { ReadyStatus } from '@/queries/monitoring' import type { ReadyStatus } from '@/queries/monitoring'
import { Item, ItemMedia } from '@evobgp/ui/components/item'
import { cn } from '@evobgp/ui/lib/utils'
const READY_CHECK_ICONS: Record<string, typeof Database> = { const READY_CHECK_ICONS: Record<string, typeof Database> = {
postgres: Database, postgres: Database,
store: HardDrive, store: HardDrive,
store_backend: HardDrive,
jobs: ListTodo, jobs: ListTodo,
} }
@@ -21,6 +29,7 @@ interface ReadyCheckRow {
label: string label: string
subtitle?: string subtitle?: string
icon: typeof Database icon: typeof Database
iconClassName?: string
status: string status: string
statusLabel: string statusLabel: string
} }
@@ -34,12 +43,14 @@ export function MonitoringReadyGrid({
}) { }) {
const data = useMemo<ReadyCheckRow[]>(() => { const data = useMemo<ReadyCheckRow[]>(() => {
const checks = ready.checks ?? {} const checks = ready.checks ?? {}
const systemReady = isSystemReady(ready.status)
const rows: ReadyCheckRow[] = [ const rows: ReadyCheckRow[] = [
{ {
id: 'liveness', id: 'liveness',
label: 'Живучесть', label: 'Живучесть',
subtitle: '/v1/health', subtitle: '/v1/health',
icon: HeartPulse, icon: HeartPulse,
iconClassName: health?.ok ? 'text-success' : 'text-destructive',
status: health?.ok ? 'ok' : 'error', status: health?.ok ? 'ok' : 'error',
statusLabel: health?.ok ? 'В норме' : 'Ошибка', statusLabel: health?.ok ? 'В норме' : 'Ошибка',
}, },
@@ -48,19 +59,21 @@ export function MonitoringReadyGrid({
label: 'Готовность', label: 'Готовность',
subtitle: '/v1/ready', subtitle: '/v1/ready',
icon: ShieldCheck, icon: ShieldCheck,
status: ready.status === 'ok' ? 'ok' : 'warning', iconClassName: systemReady ? 'text-success' : 'text-warning',
statusLabel: ready.status === 'ok' ? 'Готов' : jobStatusRu(ready.status ?? 'pending'), status: systemReady ? 'ok' : 'warning',
statusLabel: systemReady ? 'Готов' : 'Не готов',
}, },
] ]
for (const key of Object.keys(checks)) { for (const key of Object.keys(checks)) {
const value = checks[key] const value = checks[key]
const ok = typeof value === 'boolean' ? value : value?.ok !== false const ok = isReadyCheckOk(value)
rows.push({ rows.push({
id: key, id: key,
label: readyCheckRu(key), label: readyCheckRu(key),
icon: READY_CHECK_ICONS[key] ?? ListTodo, icon: READY_CHECK_ICONS[key] ?? ListTodo,
iconClassName: ok ? 'text-success' : 'text-destructive',
status: ok ? 'ok' : 'error', status: ok ? 'ok' : 'error',
statusLabel: ok ? 'В норме' : 'Ошибка', statusLabel: readyCheckStatusLabel(value, ok),
}) })
} }
return rows return rows
@@ -74,8 +87,17 @@ export function MonitoringReadyGrid({
cell: ({ row }) => { cell: ({ row }) => {
const Icon = row.original.icon const Icon = row.original.icon
return ( return (
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2.5 py-0.5">
<Icon className="size-4 shrink-0 text-muted-foreground" /> <Item
className={cn(
'border-background bg-muted flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-3.5',
row.original.iconClassName,
)}
>
<ItemMedia variant="icon" className="size-auto">
<Icon />
</ItemMedia>
</Item>
<DataGridPrimaryCell <DataGridPrimaryCell
title={row.original.label} title={row.original.label}
subtitle={row.original.subtitle} subtitle={row.original.subtitle}
@@ -90,7 +112,9 @@ export function MonitoringReadyGrid({
enableSorting: false, enableSorting: false,
header: 'Статус', header: 'Статус',
cell: ({ row }) => ( cell: ({ row }) => (
<StatusBadge status={row.original.status} label={row.original.statusLabel} /> <div className="flex justify-start">
<StatusBadge status={row.original.status} label={row.original.statusLabel} />
</div>
), ),
meta: { headerTitle: 'Статус' }, meta: { headerTitle: 'Статус' },
}, },
@@ -8,7 +8,7 @@ import {
} from '@evobgp/ui/components/chart' } from '@evobgp/ui/components/chart'
import type { BreakdownSlice } from '@/lib/metrics' import type { BreakdownSlice } from '@/lib/metrics'
/** chart-27 / chart-13 inspired donut in PanelCard. */ /** chart-27 / chart-13 inspired donut in PanelCard (Frame surface). */
export function DonutBreakdownCard({ export function DonutBreakdownCard({
title, title,
description, description,
@@ -30,13 +30,24 @@ export function DonutBreakdownCard({
const data = slices.map((slice) => ({ ...slice, fill: slice.color, share: slice.count })) const data = slices.map((slice) => ({ ...slice, fill: slice.color, share: slice.count }))
return ( return (
<PanelCard title={title} description={description} className="h-full"> <PanelCard
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center"> title={title}
description={description}
className="h-full"
actions={
badge ? (
<Badge variant="success-light" className="hidden sm:inline-flex">
{badge}
</Badge>
) : undefined
}
>
<div className="flex flex-col items-center justify-start gap-4 p-4 sm:flex-row sm:items-center sm:justify-start sm:gap-6">
{total === 0 ? ( {total === 0 ? (
<p className="text-muted-foreground w-full py-8 text-center text-sm">Нет данных</p> <p className="text-muted-foreground w-full py-8 text-center text-sm">Нет данных</p>
) : ( ) : (
<> <>
<div className="relative mx-auto size-36 shrink-0"> <div className="relative mx-auto size-36 shrink-0 sm:mx-0">
<ChartContainer config={chartConfig} className="aspect-square size-36"> <ChartContainer config={chartConfig} className="aspect-square size-36">
<PieChart> <PieChart>
<Pie <Pie
@@ -59,11 +70,11 @@ export function DonutBreakdownCard({
<span className="text-lg font-semibold tabular-nums">{total}</span> <span className="text-lg font-semibold tabular-nums">{total}</span>
</div> </div>
</div> </div>
<ul className="min-w-0 flex-1 space-y-2"> <ul className="flex w-full min-w-0 max-w-xs flex-col gap-2 sm:w-auto sm:min-w-[10rem]">
{slices.map((slice) => { {slices.map((slice) => {
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0' const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
return ( return (
<li key={slice.key} className="flex items-center justify-between gap-2 text-sm"> <li key={slice.key} className="flex items-center justify-between gap-3 text-sm">
<span className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<span <span
className="size-2.5 shrink-0 rounded-full" className="size-2.5 shrink-0 rounded-full"
@@ -81,11 +92,6 @@ export function DonutBreakdownCard({
</ul> </ul>
</> </>
)} )}
{badge ? (
<Badge variant="success-light" className="absolute top-4 right-4 hidden sm:flex">
{badge}
</Badge>
) : null}
</div> </div>
</PanelCard> </PanelCard>
) )
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import {
isReadyCheckOk,
isSystemReady,
readinessBreakdown,
readyCheckStatusLabel,
} from '@/lib/metrics/readiness-breakdown'
describe('isSystemReady', () => {
it('accepts ready and ok', () => {
expect(isSystemReady('ready')).toBe(true)
expect(isSystemReady('ok')).toBe(true)
expect(isSystemReady('READY')).toBe(true)
})
it('rejects not_ready and empty', () => {
expect(isSystemReady('not_ready')).toBe(false)
expect(isSystemReady(undefined)).toBe(false)
expect(isSystemReady('')).toBe(false)
})
})
describe('isReadyCheckOk', () => {
it('parses backend string checks as ok', () => {
expect(isReadyCheckOk('ok')).toBe(true)
expect(isReadyCheckOk('memory')).toBe(true)
expect(isReadyCheckOk('unavailable')).toBe(false)
})
it('parses boolean and object forms', () => {
expect(isReadyCheckOk(true)).toBe(true)
expect(isReadyCheckOk(false)).toBe(false)
expect(isReadyCheckOk({ ok: true })).toBe(true)
expect(isReadyCheckOk({ ok: false, error: 'down' })).toBe(false)
})
})
describe('readyCheckStatusLabel', () => {
it('labels memory and failures', () => {
expect(readyCheckStatusLabel('memory', true)).toBe('Memory')
expect(readyCheckStatusLabel('ok', true)).toBe('В норме')
expect(readyCheckStatusLabel('unavailable', false)).toBe('Недоступно')
})
})
describe('readinessBreakdown', () => {
it('counts healthy handleReady payload without false failures', () => {
const slices = readinessBreakdown(
{
status: 'ready',
checks: { store: 'ok', jobs: 'memory', postgres: 'ok' },
},
true,
)
expect(slices.find((s) => s.key === 'checks-fail')).toBeUndefined()
expect(slices.find((s) => s.key === 'checks-ok')?.count).toBe(3)
expect(slices.find((s) => s.key === 'health')?.count).toBe(1)
})
it('counts unavailable checks as failures', () => {
const slices = readinessBreakdown(
{
status: 'not_ready',
checks: { store: 'unavailable', jobs: 'memory' },
},
true,
)
expect(slices.find((s) => s.key === 'checks-fail')?.count).toBe(1)
expect(slices.find((s) => s.key === 'checks-ok')?.count).toBe(1)
})
it('returns API down slice when health fails', () => {
const slices = readinessBreakdown({ status: 'ready', checks: { store: 'ok' } }, false)
expect(slices).toHaveLength(1)
expect(slices[0]?.key).toBe('health-fail')
})
})
@@ -2,12 +2,65 @@ import type { ReadyStatus } from '@/queries/monitoring'
import type { BreakdownSlice } from './types' import type { BreakdownSlice } from './types'
function checkOk(value: boolean | { ok?: boolean; error?: string } | undefined): boolean { /** Значение check из GET /v1/ready (строка, boolean или объект). */
export type ReadyCheckValue = boolean | string | { ok?: boolean; error?: string } | null | undefined
const OK_STRINGS = new Set(['ok', 'ready', 'memory', 'true', 'healthy', 'up'])
const FAIL_STRINGS = new Set([
'unavailable',
'error',
'not_ready',
'failed',
'down',
'false',
'unhealthy',
])
/** Top-level status GET /v1/ready: API отдаёт `ready`, не `ok`. */
export function isSystemReady(status: string | null | undefined): boolean {
if (!status) return false
const normalized = status.trim().toLowerCase()
return normalized === 'ready' || normalized === 'ok'
}
/**
* Интерпретация check value по контракту handleReady:
* store/postgres → "ok" | "unavailable"; jobs → "memory"; store_backend → "memory".
*/
export function isReadyCheckOk(value: ReadyCheckValue): boolean {
if (typeof value === 'boolean') return value if (typeof value === 'boolean') return value
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase()
if (OK_STRINGS.has(normalized)) return true
if (FAIL_STRINGS.has(normalized)) return false
// неизвестная непустая строка — считать OK (информативный статус бэкенда)
return normalized.length > 0
}
if (value && typeof value === 'object') return value.ok === true if (value && typeof value === 'object') return value.ok === true
return false return false
} }
/** Человекочитаемый статус check для UI. */
export function readyCheckStatusLabel(value: ReadyCheckValue, ok: boolean): string {
if (!ok) {
if (typeof value === 'string' && value.trim()) {
const n = value.trim().toLowerCase()
if (n === 'unavailable') return 'Недоступно'
if (n === 'not_ready') return 'Не готов'
return value
}
if (value && typeof value === 'object' && value.error) return value.error
return 'Ошибка'
}
if (typeof value === 'string') {
const n = value.trim().toLowerCase()
if (n === 'memory') return 'Memory'
if (n === 'ok' || n === 'ready' || n === 'healthy' || n === 'true') return 'В норме'
if (n) return value
}
return 'В норме'
}
export function readinessBreakdown( export function readinessBreakdown(
ready: ReadyStatus | null | undefined, ready: ReadyStatus | null | undefined,
healthOk: boolean, healthOk: boolean,
@@ -28,7 +81,7 @@ export function readinessBreakdown(
let failCount = 0 let failCount = 0
for (const value of Object.values(checks)) { for (const value of Object.values(checks)) {
if (checkOk(value)) okCount += 1 if (isReadyCheckOk(value)) okCount += 1
else failCount += 1 else failCount += 1
} }
@@ -61,9 +114,11 @@ export function readinessBreakdown(
if (slices.length === 1 && okCount === 0 && failCount === 0) { if (slices.length === 1 && okCount === 0 && failCount === 0) {
slices.push({ slices.push({
key: 'ready', key: 'ready',
label: ready?.status === 'ok' ? 'Готов' : 'Ожидает готовности', label: isSystemReady(ready?.status) ? 'Готов' : 'Не готов',
count: 1, count: 1,
color: 'var(--color-chart-4)', color: isSystemReady(ready?.status)
? 'var(--color-chart-1)'
: 'var(--color-warning)',
}) })
} }
+2
View File
@@ -117,6 +117,8 @@ export function readyCheckRu(key: string): string {
return 'Хранилище' return 'Хранилище'
case 'jobs': case 'jobs':
return 'Очередь задач' return 'Очередь задач'
case 'store_backend':
return 'Бэкенд хранилища'
default: default:
return key return key
} }
+30
View File
@@ -5,9 +5,11 @@ import { apiJSON, apiMutate } from '@/lib/api-client'
import type { import type {
BgpCommunity, BgpCommunity,
BgpCommunityCreate, BgpCommunityCreate,
BgpCommunityPatch,
CommunitiesResponse, CommunitiesResponse,
DohProfile, DohProfile,
DohProfileCreate, DohProfileCreate,
DohProfilePatch,
DohProfilesResponse, DohProfilesResponse,
} from '@/types/api' } from '@/types/api'
@@ -47,6 +49,20 @@ export function useCreateCommunityMutation() {
}) })
} }
export function useUpdateCommunityMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: string; body: BgpCommunityPatch }) =>
apiMutate<BgpCommunity>(`/v1/communities/${id}`, 'PATCH', body, { idempotent: false }),
onSuccess: () => {
toast.success('Сообщество обновлено')
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
},
onError: (e) =>
toast.error(e instanceof Error ? e.message : 'Не удалось обновить сообщество'),
})
}
export function useCreateDohProfileMutation() { export function useCreateDohProfileMutation() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
@@ -60,3 +76,17 @@ export function useCreateDohProfileMutation() {
toast.error(e instanceof Error ? e.message : 'Не удалось создать DoH-профиль'), toast.error(e instanceof Error ? e.message : 'Не удалось создать DoH-профиль'),
}) })
} }
export function useUpdateDohProfileMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: string; body: DohProfilePatch }) =>
apiMutate<DohProfile>(`/v1/doh-profiles/${id}`, 'PATCH', body, { idempotent: false }),
onSuccess: () => {
toast.success('DoH-профиль обновлён')
void qc.invalidateQueries({ queryKey: directoriesKeys.doh() })
},
onError: (e) =>
toast.error(e instanceof Error ? e.message : 'Не удалось обновить DoH-профиль'),
})
}
+2 -1
View File
@@ -9,7 +9,8 @@ export interface HealthStatus {
export interface ReadyStatus { export interface ReadyStatus {
status?: string status?: string
checks?: Record<string, boolean | { ok?: boolean; error?: string }> /** Backend: string ("ok"|"memory"|"unavailable"), boolean, or { ok, error }. */
checks?: Record<string, boolean | string | { ok?: boolean; error?: string }>
} }
export interface VersionInfo { export interface VersionInfo {
+51 -6
View File
@@ -5,10 +5,14 @@ import { useState } from 'react'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs' import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { CommunityCreateDialog } from '@/components/directories/community-create-dialog' import {
CommunityFormDialog,
} from '@/components/directories/community-create-dialog'
import { DirectoriesCommunitiesGrid } from '@/components/directories/directories-communities-grid' import { DirectoriesCommunitiesGrid } from '@/components/directories/directories-communities-grid'
import { DirectoriesDohGrid } from '@/components/directories/directories-doh-grid' import { DirectoriesDohGrid } from '@/components/directories/directories-doh-grid'
import { DohProfileCreateDialog } from '@/components/directories/doh-profile-create-dialog' import {
DohProfileFormDialog,
} from '@/components/directories/doh-profile-create-dialog'
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 { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
@@ -17,6 +21,7 @@ import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
import { sessionCanWriteDirectories } from '@/lib/auth' import { sessionCanWriteDirectories } from '@/lib/auth'
import { authSessionQueryOptions } from '@/queries/auth' import { authSessionQueryOptions } from '@/queries/auth'
import { directoriesCommunitiesQueryOptions, directoriesDohQueryOptions } from '@/queries/directories' import { directoriesCommunitiesQueryOptions, directoriesDohQueryOptions } from '@/queries/directories'
import type { BgpCommunity, DohProfile } from '@/types/api'
export const Route = createFileRoute('/_auth/directories')({ export const Route = createFileRoute('/_auth/directories')({
component: DirectoriesComponent, component: DirectoriesComponent,
@@ -24,7 +29,9 @@ export const Route = createFileRoute('/_auth/directories')({
function DirectoriesComponent() { function DirectoriesComponent() {
const [communityOpen, setCommunityOpen] = useState(false) const [communityOpen, setCommunityOpen] = useState(false)
const [communityEdit, setCommunityEdit] = useState<BgpCommunity | null>(null)
const [dohOpen, setDohOpen] = useState(false) const [dohOpen, setDohOpen] = useState(false)
const [dohEdit, setDohEdit] = useState<DohProfile | null>(null)
const sessionQ = useQuery(authSessionQueryOptions()) const sessionQ = useQuery(authSessionQueryOptions())
const canWrite = sessionCanWriteDirectories(sessionQ.data) const canWrite = sessionCanWriteDirectories(sessionQ.data)
@@ -69,15 +76,35 @@ function DirectoriesComponent() {
}, },
] ]
function openCreateCommunity() {
setCommunityEdit(null)
setCommunityOpen(true)
}
function openEditCommunity(row: BgpCommunity) {
setCommunityEdit(row)
setCommunityOpen(true)
}
function openCreateDoh() {
setDohEdit(null)
setDohOpen(true)
}
function openEditDoh(row: DohProfile) {
setDohEdit(row)
setDohOpen(true)
}
const addCommunityButton = canWrite ? ( const addCommunityButton = canWrite ? (
<Button size="sm" type="button" onClick={() => setCommunityOpen(true)}> <Button size="sm" type="button" onClick={openCreateCommunity}>
<Plus /> <Plus />
Добавить Добавить
</Button> </Button>
) : null ) : null
const addDohButton = canWrite ? ( const addDohButton = canWrite ? (
<Button size="sm" type="button" onClick={() => setDohOpen(true)}> <Button size="sm" type="button" onClick={openCreateDoh}>
<Plus /> <Plus />
Добавить Добавить
</Button> </Button>
@@ -134,6 +161,8 @@ function DirectoriesComponent() {
<DirectoriesCommunitiesGrid <DirectoriesCommunitiesGrid
items={rows} items={rows}
isLoading={communitiesQ.isFetching && !communitiesQ.isLoading} isLoading={communitiesQ.isFetching && !communitiesQ.isLoading}
canWrite={canWrite}
onEdit={openEditCommunity}
/> />
)} )}
</QueryState> </QueryState>
@@ -161,6 +190,8 @@ function DirectoriesComponent() {
<DirectoriesDohGrid <DirectoriesDohGrid
items={rows} items={rows}
isLoading={dohQ.isFetching && !dohQ.isLoading} isLoading={dohQ.isFetching && !dohQ.isLoading}
canWrite={canWrite}
onEdit={openEditDoh}
/> />
)} )}
</QueryState> </QueryState>
@@ -170,8 +201,22 @@ function DirectoriesComponent() {
{canWrite ? ( {canWrite ? (
<> <>
<CommunityCreateDialog open={communityOpen} onOpenChange={setCommunityOpen} /> <CommunityFormDialog
<DohProfileCreateDialog open={dohOpen} onOpenChange={setDohOpen} /> open={communityOpen}
onOpenChange={(open) => {
setCommunityOpen(open)
if (!open) setCommunityEdit(null)
}}
editTarget={communityEdit}
/>
<DohProfileFormDialog
open={dohOpen}
onOpenChange={(open) => {
setDohOpen(open)
if (!open) setDohEdit(null)
}}
editTarget={dohEdit}
/>
</> </>
) : null} ) : null}
</div> </div>
+43 -41
View File
@@ -81,7 +81,7 @@ function MonitoringComponent() {
.slice(0, 5) .slice(0, 5)
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-2 md:gap-3">
<PageHeader <PageHeader
title="Мониторинг" title="Мониторинг"
description={`Состояние API, BGP и задач для диагностики инцидентов${ description={`Состояние API, BGP и задач для диагностики инцидентов${
@@ -110,11 +110,11 @@ function MonitoringComponent() {
{ value: 'runtime-logs', label: 'Файловые логи' }, { value: 'runtime-logs', label: 'Файловые логи' },
]} ]}
> >
<TabsContent value="system" className="mt-0 flex flex-col gap-6"> <TabsContent value="system" className="mt-0 flex flex-col gap-2 md:gap-3">
{analyticsLoading ? ( {analyticsLoading ? (
<AnalyticsDashboardSkeleton /> <AnalyticsDashboardSkeleton />
) : ( ) : (
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid items-stretch gap-2 md:gap-3 lg:grid-cols-2">
<MonitoringHealthCard <MonitoringHealthCard
healthOk={healthQ.data?.ok ?? false} healthOk={healthQ.data?.ok ?? false}
ready={readyQ.data} ready={readyQ.data}
@@ -124,7 +124,7 @@ function MonitoringComponent() {
</div> </div>
)} )}
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid items-stretch gap-2 md:gap-3 lg:grid-cols-2">
<FrameDataGrid <FrameDataGrid
title="Доступность и готовность" title="Доступность и готовность"
description="GET /v1/health · GET /v1/ready" description="GET /v1/health · GET /v1/ready"
@@ -149,23 +149,24 @@ function MonitoringComponent() {
</span> </span>
} }
description="GET /v1/bird/status" description="GET /v1/bird/status"
contentClassName="py-4" className="h-full"
contentClassName="px-5 py-4"
> >
<QueryState <QueryState
data={birdQ.data} data={birdQ.data}
isLoading={birdQ.isLoading} isLoading={birdQ.isLoading}
isError={birdQ.isError} isError={birdQ.isError}
error={birdQ.error} error={birdQ.error}
skeleton={<div className="h-40" />} skeleton={<div className="h-40" />}
onRetry={() => birdQ.refetch()} onRetry={() => birdQ.refetch()}
> >
{(bird) => <BirdSummary bird={bird} />} {(bird) => <BirdSummary bird={bird} />}
</QueryState> </QueryState>
</PanelCard> </PanelCard>
</div> </div>
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid items-stretch gap-2 md:gap-3 lg:grid-cols-2">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-2 md:gap-3">
<SegmentedProgressCard <SegmentedProgressCard
title="Задачи" title="Задачи"
description="Последние 100 задач · GET /v1/jobs" description="Последние 100 задач · GET /v1/jobs"
@@ -189,8 +190,8 @@ function MonitoringComponent() {
footer={`В выборке: ${jobs.length} задач`} footer={`В выборке: ${jobs.length} задач`}
/> />
{failedJobs.length > 0 ? ( {failedJobs.length > 0 ? (
<PanelCard title="Последние ошибки" contentClassName="space-y-2 py-4"> <PanelCard title="Последние ошибки" contentClassName="px-5 py-4">
<ul className="space-y-2"> <ul className="flex flex-col gap-2">
{failedJobs.map((job) => ( {failedJobs.map((job) => (
<li key={job.job_id} className="rounded-lg border px-3 py-2 text-sm"> <li key={job.job_id} className="rounded-lg border px-3 py-2 text-sm">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
@@ -218,28 +219,29 @@ function MonitoringComponent() {
</span> </span>
} }
description="Краткая шпаргалка для первичной диагностики" description="Краткая шпаргалка для первичной диагностики"
contentClassName="py-4" className="h-full"
contentClassName="px-5 py-4"
> >
<ul className="space-y-3 text-sm text-muted-foreground"> <ul className="flex flex-col gap-3 text-sm text-muted-foreground">
<li> <li>
<span className="font-medium text-foreground">API недоступен.</span> Если{' '} <span className="font-medium text-foreground">API недоступен.</span> Если{' '}
<code className="text-xs">/v1/health</code> возвращает ошибку проверьте процесс API и <code className="text-xs">/v1/health</code> возвращает ошибку проверьте процесс API и
его логи. его логи.
</li> </li>
<li> <li>
<span className="font-medium text-foreground">Готовность не «Готов».</span> Сначала{' '} <span className="font-medium text-foreground">Готовность не «Готов».</span> Сначала{' '}
<code className="text-xs">postgres</code>, затем <code className="text-xs">store</code>{' '} <code className="text-xs">postgres</code>, затем <code className="text-xs">store</code>{' '}
и <code className="text-xs">jobs</code> в проверках. и <code className="text-xs">jobs</code> в проверках.
</li> </li>
<li> <li>
<span className="font-medium text-foreground">Низкий ratio BGP.</span> Проверьте{' '} <span className="font-medium text-foreground">Низкий ratio BGP.</span> Проверьте{' '}
<code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети. <code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
</li> </li>
<li> <li>
<span className="font-medium text-foreground">Ошибки задач.</span> Откройте Операции и <span className="font-medium text-foreground">Ошибки задач.</span> Откройте Операции и
проверьте последние неуспешные задачи. проверьте последние неуспешные задачи.
</li> </li>
</ul> </ul>
</PanelCard> </PanelCard>
</div> </div>
</TabsContent> </TabsContent>
@@ -282,7 +284,7 @@ function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
? Math.round((bird.bgp_established / bird.bgp_sessions_total) * 100) ? Math.round((bird.bgp_established / bird.bgp_sessions_total) * 100)
: null : null
return ( return (
<div className="space-y-2"> <div className="flex flex-col gap-2">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Установлено / всего</span> <span className="text-muted-foreground">Установлено / всего</span>
<span className="font-medium tabular-nums"> <span className="font-medium tabular-nums">