Compare commits

...
5 Commits
Author SHA1 Message Date
DenozordecandCursor b04b27c215 refactor(web): align Frame KPI settings chrome and a11y
CI / changes (push) Successful in 5s
CI / openapi (push) Successful in 39s
CI / web (push) Successful in 55s
CI / go (push) Successful in 2m40s
CI / bird2 (push) Successful in 16s
CI / commitlint (push) Skipped
CI / release (push) Successful in 18s
Выровнять KPI на IconTile, вынести settings из settings-7, Sheet/AlertDialog и skip-link в общем chrome.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 15:36:54 +07:00
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
2026-08-12 12:42:01 +07:00
DenozordecandCursor fc4f234cc5 feat(directories): add create dialogs for communities and DoH profiles
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 41s
CI / web (push) Successful in 1m13s
CI / release (push) Successful in 4m35s
На странице Справочники добавлены кнопки «Добавить» и FormDrawer
для POST /v1/communities и /v1/doh-profiles. Доступ ограничен
bgp:directories:write (sessionCanWriteDirectories).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 12:24:10 +07:00
Denozordec 821f342476 feat(modules): enhance ModuleKpiCards and add update functionality for modules
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 51s
CI / web (push) Successful in 1m26s
CI / release (push) Successful in 4m26s
Updated the ModuleKpiCards component to improve the display of KPI metrics by introducing a new entries count prop and simplifying the logic for displaying community and DoH information. Added a new mutation hook for updating modules, which includes success and error handling with toast notifications. Enhanced the ModuleDetailComponent to support editing modules with a new dialog and integrated the entries count into the KPI display.

Additionally, introduced a new helper function for generating short labels for DoH profiles, improving the overall user experience in module management.
2026-08-12 11:32:44 +07:00
55 changed files with 1763 additions and 567 deletions
+10 -9
View File
@@ -1,11 +1,11 @@
---
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12). Запрет SectionCards и hand-roll.
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12 + IconTile). Запрет SectionCards и hand-roll.
alwaysApply: true
---
# KPI hybrid — только kit (stats-12 DNA)
# KPI hybrid — только kit (stats-12 + IconTile)
Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Markup в проекте: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
Preview: [stats-12](https://reui.io/preview/base/stats-12). Primitive: [icon-tile](https://reui.io/docs/components/base/icon-tile). SoT DNA = EvoBGP. Markup: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
Связанные: [`reui-mcp.mdc`](reui-mcp.mdc), [`web-shadcn.mdc`](web-shadcn.mdc).
@@ -14,16 +14,17 @@ Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Ma
| Зона | Компонент / DNA |
|------|-----------------|
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) |
| Markup | horizontal compact hybrid: icon left `Item` `size-10.5` `bg-muted` + `border-background` + shadow + `ItemMedia` + label/Badge + value ± `variant` |
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на `bg-muted` |
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA) |
| Markup | horizontal compact hybrid: icon left `IconTile` `variant="elevated"` `className="size-10.5"` + label/Badge + value ± `variant` |
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на IconTile elevated |
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA, IconTile elevated) |
Импорты UI: `@evobgp/ui/components/*`.
Импорты UI: `@evobgp/ui/components/*`. IconTile: `@/components/reui/icon-tile`.
## NEVER
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
- Другой size / radius / solid brand fill вместо `bg-muted`
- `Item` `size-10.5` `bg-muted` hybrid вместо IconTile
- Другой size / radius / solid brand fill вместо `elevated`
- `card-35` как замена stats-12 hybrid KPI
- Копипаст ReUI block в route — adapt через `reui-kit/`
- Голый lucide `size-4` в name-cell без hybrid tile
- Голый lucide `size-4` в name-cell без IconTile
+3 -2
View File
@@ -75,9 +75,9 @@ alwaysApply: true
## Установленные ReUI (apps/web)
**Components:** `frame`, `data-grid/*`, `filters`, `kanban`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`
**Components:** `frame`, `data-grid/*`, `filters`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`, `icon-tile`, `stepper`
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `KanbanBoard`, `DetailPanel`, `SettingsShell`
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell`
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
@@ -110,6 +110,7 @@ REUI_LICENSE_KEY=
- Hand-roll data-grid/filters/KPI/Quick Actions/settings rows при наличии `@reui` / kit
- Смешивать Card и Frame на ops-экране
- Ставить shadcn/ui blocks выше ReUI PRO
- **settings-7** как target (`surface: card`) — только [settings-3](https://reui.io/preview/base/settings-3) / [settings-16](https://reui.io/preview/base/settings-16)
## Чеклист
+3
View File
@@ -101,6 +101,9 @@ pnpm --filter @evobgp/web run build
**WEB-22** | NEVER | Legacy Svelte UI удалён. Не восстанавливать `web-legacy-svelte/` и не копировать Svelte-паттерны в React-код.
*Проверка:* отсутствие каталога `web-legacy-svelte/`; `pnpm --filter @evobgp/web run typecheck`.
**WEB-23** | NEVER | **settings-7** как target ([preview](https://reui.io/preview/base/settings-7) — `surface: card`). Новые settings-экраны — [settings-3](https://reui.io/preview/base/settings-3) / [settings-16](https://reui.io/preview/base/settings-16) (`surface: frame`). Существующий `apps/web/src/components/settings/*` на DNA settings-7 **не** копировать как эталон; миграция — отдельный CHANGE.
*Проверка:* review MCP `get_block` / cite; нет новых экранов с settings-7.
---
## Documentation Sync (Web)
@@ -40,7 +40,7 @@ export function ChartDonutMetric({
}
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">
<PieChart>
<Pie
@@ -75,7 +75,7 @@ export function ChartDonutMetric({
</PieChart>
</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) => {
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
return (
@@ -1,62 +1,2 @@
import { type ReactNode } from "react"
import { cn } from "@evobgp/ui/lib/utils"
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldSeparator,
FieldTitle,
} from "@evobgp/ui/components/field"
interface SettingRowProps {
title: string
description?: ReactNode
children: ReactNode
last?: boolean
labelFor?: string
contentClassName?: string
titleAddon?: ReactNode
}
export function SettingRow({
title,
description,
children,
last,
labelFor,
contentClassName,
titleAddon,
}: SettingRowProps) {
return (
<>
<Field orientation="responsive" className="gap-4 px-5 py-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
<div className="flex flex-wrap items-center gap-2">
{labelFor ? (
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
) : (
<FieldTitle>{title}</FieldTitle>
)}
{titleAddon}
</div>
{description ? (
<FieldDescription>{description}</FieldDescription>
) : null}
</div>
<FieldContent
className={cn("min-w-0 @md/field-group:w-78", contentClassName)}
>
<div className="flex w-full justify-start @md/field-group:justify-end">
{children}
</div>
</FieldContent>
</Field>
{!last ? <FieldSeparator /> : null}
</>
)
}
/** @deprecated Import from `@/components/settings/setting-row` — settings-7 is not a target. */
export { SettingRow } from '@/components/settings/setting-row'
@@ -1,53 +1,2 @@
import { type ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
interface SettingsCardProps {
title: string
description?: string
children: ReactNode
footer?: ReactNode
className?: string
contentClassName?: string
footerClassName?: string
}
/** Settings section — Frame surface. Preview: https://reui.io/preview/base/settings-16 */
export function SettingsCard({
title,
description,
children,
footer,
className,
contentClassName,
footerClassName,
}: SettingsCardProps) {
return (
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
<FrameHeader className="gap-0 border-b px-5 py-3">
<FrameTitle>{title}</FrameTitle>
{description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader>
<div className={cn('min-w-0', contentClassName)}>{children}</div>
{footer ? (
<FrameFooter
className={cn('justify-end gap-2 border-t px-5 py-3', footerClassName)}
>
{footer}
</FrameFooter>
) : null}
</FramePanel>
</Frame>
)
}
/** @deprecated Import from `@/components/settings/settings-card` — settings-7 is not a target. */
export { SettingsCard } from '@/components/settings/settings-card'
@@ -1,37 +1,2 @@
import { type ReactNode } from "react"
import { cn } from "@evobgp/ui/lib/utils"
import {
FieldDescription,
FieldGroup,
FieldLegend,
FieldSet,
} from "@evobgp/ui/components/field"
interface SettingsFieldGroupProps {
legend: string
description: string
children: ReactNode
className?: string
fieldGroupClassName?: string
}
export function SettingsFieldGroup({
legend,
description,
children,
className,
fieldGroupClassName,
}: SettingsFieldGroupProps) {
return (
<FieldSet className={cn("gap-0", className)}>
<FieldLegend className="sr-only">{legend}</FieldLegend>
{/* Description */}
<FieldDescription className="sr-only">{description}</FieldDescription>
{/* List */}
<FieldGroup className={cn("gap-0", fieldGroupClassName)}>
{children}
</FieldGroup>
</FieldSet>
)
}
/** @deprecated Import from `@/components/settings/settings-field-group` — settings-7 is not a target. */
export { SettingsFieldGroup } from '@/components/settings/settings-field-group'
+52 -93
View File
@@ -1,19 +1,16 @@
import { Button } from '@evobgp/ui/components/button'
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '@evobgp/ui/components/drawer'
import type { ReactElement, ReactNode } from 'react'
import { useState, type ReactElement, type ReactNode } from 'react'
import {
confirmDrawerContentClassName,
DrawerActionsFooter,
} from '@/components/drawer-layout'
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@evobgp/ui/components/alert-dialog'
type ConfirmDialogBaseProps = {
title: string
@@ -41,90 +38,52 @@ type ConfirmDialogControlled = ConfirmDialogBaseProps & {
type ConfirmDialogProps = ConfirmDialogWithTrigger | ConfirmDialogControlled
function ConfirmDrawerBody({
title,
description,
confirmLabel = 'Подтвердить',
cancelLabel = 'Отмена',
destructive,
onConfirm,
confirmDisabled,
confirmLoading,
confirmLoadingLabel,
controlled,
}: ConfirmDialogBaseProps & { controlled?: boolean }) {
const confirmText =
confirmLoading && confirmLoadingLabel
? confirmLoadingLabel
: confirmLoading
? `${confirmLabel}`
: confirmLabel
return (
<>
<DrawerHeader className="shrink-0 border-b border-border pb-4">
<DrawerTitle>{title}</DrawerTitle>
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
</DrawerHeader>
<DrawerActionsFooter>
<DrawerClose render={<Button variant="outline" disabled={confirmLoading} />}>
{cancelLabel}
</DrawerClose>
{controlled ? (
<Button
variant={destructive ? 'destructive' : 'default'}
disabled={confirmDisabled || confirmLoading}
onClick={onConfirm}
>
{confirmText}
</Button>
) : (
<DrawerClose
render={
<Button
variant={destructive ? 'destructive' : 'default'}
disabled={confirmDisabled || confirmLoading}
/>
}
onClick={onConfirm}
>
{confirmText}
</DrawerClose>
)}
</DrawerActionsFooter>
</>
)
}
/** Confirm overlay — Alert Dialog. Preview: https://reui.io/preview/base/components/c-alert-dialog-11 */
export function ConfirmDialog(props: ConfirmDialogProps) {
const bodyProps: ConfirmDialogBaseProps = {
title: props.title,
description: props.description,
confirmLabel: props.confirmLabel,
cancelLabel: props.cancelLabel,
destructive: props.destructive,
onConfirm: props.onConfirm,
confirmDisabled: props.confirmDisabled,
confirmLoading: props.confirmLoading,
confirmLoadingLabel: props.confirmLoadingLabel,
const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
const isControlled = props.open !== undefined
const open = isControlled ? props.open : uncontrolledOpen
function setOpen(next: boolean) {
if (!isControlled) setUncontrolledOpen(next)
if (isControlled) props.onOpenChange?.(next)
}
if (props.trigger) {
return (
<Drawer swipeDirection="right">
<DrawerTrigger render={props.trigger} />
<DrawerContent className={confirmDrawerContentClassName}>
<ConfirmDrawerBody {...bodyProps} />
</DrawerContent>
</Drawer>
)
const confirmText =
props.confirmLoading && props.confirmLoadingLabel
? props.confirmLoadingLabel
: props.confirmLoading
? `${props.confirmLabel ?? 'Подтвердить'}`
: (props.confirmLabel ?? 'Подтвердить')
function handleConfirm() {
props.onConfirm()
if (!props.confirmLoading && !isControlled) setOpen(false)
}
return (
<Drawer open={props.open} onOpenChange={props.onOpenChange} swipeDirection="right">
<DrawerContent className={confirmDrawerContentClassName}>
<ConfirmDrawerBody {...bodyProps} controlled />
</DrawerContent>
</Drawer>
<AlertDialog open={open} onOpenChange={setOpen}>
{props.trigger ? <AlertDialogTrigger render={props.trigger} /> : null}
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{props.title}</AlertDialogTitle>
{props.description ? (
<AlertDialogDescription>{props.description}</AlertDialogDescription>
) : null}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={props.confirmLoading}>
{props.cancelLabel ?? 'Отмена'}
</AlertDialogCancel>
<AlertDialogAction
variant={props.destructive ? 'destructive' : 'default'}
disabled={props.confirmDisabled || props.confirmLoading}
onClick={handleConfirm}
>
{confirmText}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -0,0 +1,111 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import {
useCreateCommunityMutation,
useUpdateCommunityMutation,
} from '@/queries/directories'
import type { BgpCommunity, BgpCommunityCreate, BgpCommunityPatch } from '@/types/api'
interface CommunityFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
editTarget?: BgpCommunity | null
}
/** @see https://reui.io/preview/base/form-7 */
export function CommunityFormDialog({
open,
onOpenChange,
editTarget = null,
}: CommunityFormDialogProps) {
const createMutation = useCreateCommunityMutation()
const updateMutation = useUpdateCommunityMutation()
const saving = createMutation.isPending || updateMutation.isPending
const [community, setCommunity] = useState('')
const [title, setTitle] = useState('')
useEffect(() => {
if (!open) return
if (editTarget) {
setCommunity(editTarget.community ?? '')
setTitle(editTarget.title ?? '')
} else {
setCommunity('')
setTitle('')
}
}, [editTarget, open])
async function save() {
const value = community.trim()
if (!value) {
toast.error('Укажите community')
return
}
const titleTrimmed = title.trim()
try {
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)
} catch {
// toast in mutation
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title={editTarget ? 'Редактировать сообщество' : 'Новое сообщество BGP'}
description="Тег для префиксов в фильтрах BIRD"
className="sm:max-w-sm"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={saving} onClick={() => void save()}>
{editTarget ? 'Сохранить' : 'Создать'}
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-2">
<Label htmlFor="comm-value">Community</Label>
<Input
id="comm-value"
placeholder="65000:100"
value={community}
onChange={(e) => setCommunity(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="comm-title">Название (опционально)</Label>
<Input
id="comm-title"
placeholder="Отображаемое имя"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
</FormDrawer>
)
}
/** @deprecated Use CommunityFormDialog */
export const CommunityCreateDialog = CommunityFormDialog
@@ -4,6 +4,7 @@ import { useMemo } from 'react'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
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 { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { BgpCommunity } from '@/types/api'
@@ -11,12 +12,16 @@ import type { BgpCommunity } from '@/types/api'
export function DirectoriesCommunitiesGrid({
items,
isLoading = false,
canWrite = false,
onEdit,
}: {
items: BgpCommunity[]
isLoading?: boolean
canWrite?: boolean
onEdit?: (row: BgpCommunity) => void
}) {
const columns = useMemo<ColumnDef<BgpCommunity>[]>(
() => [
const columns = useMemo<ColumnDef<BgpCommunity>[]>(() => {
const cols: ColumnDef<BgpCommunity>[] = [
{
accessorKey: 'title',
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
@@ -38,9 +43,23 @@ export function DirectoriesCommunitiesGrid({
cell: () => <CategoryBadge>community</CategoryBadge>,
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({
data: items,
@@ -4,6 +4,7 @@ import { useMemo } from 'react'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
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 { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { DohProfile } from '@/types/api'
@@ -11,12 +12,16 @@ import type { DohProfile } from '@/types/api'
export function DirectoriesDohGrid({
items,
isLoading = false,
canWrite = false,
onEdit,
}: {
items: DohProfile[]
isLoading?: boolean
canWrite?: boolean
onEdit?: (row: DohProfile) => void
}) {
const columns = useMemo<ColumnDef<DohProfile>[]>(
() => [
const columns = useMemo<ColumnDef<DohProfile>[]>(() => {
const cols: ColumnDef<DohProfile>[] = [
{
id: 'name',
accessorFn: (row) => row.name ?? row.url,
@@ -43,9 +48,23 @@ export function DirectoriesDohGrid({
cell: () => <CategoryBadge></CategoryBadge>,
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({
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>
)
}
@@ -0,0 +1,154 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import {
useCreateDohProfileMutation,
useUpdateDohProfileMutation,
} from '@/queries/directories'
import type { DohProfile, DohProfileCreate, DohProfilePatch } from '@/types/api'
interface DohProfileFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
editTarget?: DohProfile | null
}
/** @see https://reui.io/preview/base/form-7 */
export function DohProfileFormDialog({
open,
onOpenChange,
editTarget = null,
}: DohProfileFormDialogProps) {
const createMutation = useCreateDohProfileMutation()
const updateMutation = useUpdateDohProfileMutation()
const saving = createMutation.isPending || updateMutation.isPending
const [name, setName] = useState('')
const [url, setUrl] = useState('')
const [timeoutMs, setTimeoutMs] = useState('')
useEffect(() => {
if (!open) return
if (editTarget) {
setName(editTarget.name ?? '')
setUrl(editTarget.url ?? '')
setTimeoutMs(
editTarget.timeout_ms === null || editTarget.timeout_ms === undefined
? ''
: String(editTarget.timeout_ms),
)
} else {
setName('')
setUrl('')
setTimeoutMs('')
}
}, [editTarget, open])
async function save() {
const trimmedUrl = url.trim()
if (!trimmedUrl) {
toast.error('Укажите URL DoH')
return
}
let urlOk = true
try {
new URL(trimmedUrl)
} catch {
urlOk = false
}
if (!urlOk) {
toast.error('Некорректный URL')
return
}
let timeout: number | null = null
if (timeoutMs.trim() !== '') {
const ms = Number(timeoutMs)
if (!Number.isFinite(ms) || !Number.isInteger(ms) || ms <= 0) {
toast.error('Timeout должен быть целым числом > 0')
return
}
timeout = ms
}
const nameTrimmed = name.trim()
try {
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)
} catch {
// toast in mutation
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title={editTarget ? 'Редактировать DoH-профиль' : 'Новый DoH-профиль'}
description="Резолвер DNS-over-HTTPS для доменных модулей"
className="sm:max-w-sm"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={saving} onClick={() => void save()}>
{editTarget ? 'Сохранить' : 'Создать'}
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-2">
<Label htmlFor="doh-name">Имя (опционально)</Label>
<Input
id="doh-name"
placeholder="Control D / AdGuard"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="doh-url">URL</Label>
<Input
id="doh-url"
type="url"
placeholder="https://dns.example/dns-query"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="doh-timeout">Timeout, мс (опционально)</Label>
<Input
id="doh-timeout"
type="number"
min={1}
placeholder="5000"
value={timeoutMs}
onChange={(e) => setTimeoutMs(e.target.value)}
/>
</div>
</FormDrawer>
)
}
/** @deprecated Use DohProfileFormDialog */
export const DohProfileCreateDialog = DohProfileFormDialog
-31
View File
@@ -1,31 +0,0 @@
import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import { DrawerFooter } from '@evobgp/ui/components/drawer'
/** Shared footer layout for right-side form and confirm drawers. */
export function DrawerActionsFooter({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<DrawerFooter
className={cn(
'mt-0 shrink-0 border-t border-border bg-muted/50 p-4',
className,
)}
>
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
{children}
</div>
</DrawerFooter>
)
}
export const formDrawerContentClassName =
'flex h-full max-h-dvh flex-col sm:max-w-lg'
export const confirmDrawerContentClassName = 'flex h-auto max-h-dvh flex-col sm:max-w-sm'
+84 -9
View File
@@ -1,28 +1,103 @@
/**
* ReUI Empty + IconStack — Frame-friendly (empty-state-14 / empty-state-12).
* Preview: https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12
* Docs: https://reui.io/docs/components/base/icon-stack
*/
import { InboxIcon, type LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import { IconStack } from '@/components/reui/icon-stack'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@evobgp/ui/components/empty'
import { cn } from '@evobgp/ui/lib/utils'
interface EmptyStateProps {
icon?: LucideIcon | ReactNode
title: string
description?: string
icon?: ReactNode
action?: ReactNode
className?: string
stackedIcon?: boolean
centered?: boolean
}
export function EmptyState({ title, description, icon, action, className }: EmptyStateProps) {
function isLucideIcon(icon: LucideIcon | ReactNode): icon is LucideIcon {
if (typeof icon === 'function') return true
if (icon != null && typeof icon === 'object' && '$$typeof' in icon) {
if ('props' in icon) return false
return true
}
return false
}
export function EmptyState({
icon,
title,
description,
action,
className,
stackedIcon = true,
centered = true,
}: EmptyStateProps) {
const Icon = isLucideIcon(icon) ? icon : InboxIcon
const customIcon = icon && !isLucideIcon(icon) ? icon : null
const body = (
<Empty
className={cn(
'max-w-md flex-none border-0 bg-transparent p-0',
!centered && className,
)}
>
<EmptyHeader className="gap-5 text-center">
<EmptyMedia className="mb-0">
{customIcon ? (
stackedIcon ? (
<IconStack aria-hidden="true" className="h-14 w-12 shrink-0">
{customIcon}
</IconStack>
) : (
<div className="text-muted-foreground">{customIcon}</div>
)
) : stackedIcon ? (
<IconStack aria-hidden="true" className="h-14 w-12 shrink-0">
<Icon strokeWidth={1.9} aria-hidden="true" className="size-5" />
</IconStack>
) : (
<span className="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-lg [&_svg]:size-5">
<Icon aria-hidden="true" />
</span>
)}
</EmptyMedia>
<div className="flex flex-col items-center gap-2">
<EmptyTitle className="text-base font-semibold tracking-tight">{title}</EmptyTitle>
{description ? (
<EmptyDescription className="max-w-sm text-sm/relaxed">{description}</EmptyDescription>
) : null}
</div>
</EmptyHeader>
{action ? (
<EmptyContent className="mt-1 items-center justify-center">{action}</EmptyContent>
) : null}
</Empty>
)
if (!centered) return body
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center',
'flex w-full flex-1 items-center justify-center self-stretch py-14 sm:py-16',
className,
)}
>
{icon ? <div className="text-muted-foreground">{icon}</div> : null}
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">{title}</p>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{action ? <div className="mt-2">{action}</div> : null}
{body}
</div>
)
}
+25 -24
View File
@@ -2,18 +2,13 @@ import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from '@evobgp/ui/components/drawer'
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
import {
DrawerActionsFooter,
formDrawerContentClassName,
} from '@/components/drawer-layout'
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@evobgp/ui/components/sheet'
interface FormDrawerProps {
open: boolean
@@ -25,6 +20,7 @@ interface FormDrawerProps {
className?: string
}
/** Create/edit overlay — Sheet DNA (sheet-8 / sheet-1). Preview: https://reui.io/preview/base/sheet-8 */
export function FormDrawer({
open,
onOpenChange,
@@ -35,17 +31,22 @@ export function FormDrawer({
className,
}: FormDrawerProps) {
return (
<Drawer open={open} onOpenChange={onOpenChange} swipeDirection="right">
<DrawerContent className={cn(formDrawerContentClassName, className)}>
<DrawerHeader className="shrink-0 border-b border-border pb-4">
<DrawerTitle>{title}</DrawerTitle>
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
</DrawerHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-4 px-4 py-4">{children}</div>
</ScrollArea>
<DrawerActionsFooter>{footer}</DrawerActionsFooter>
</DrawerContent>
</Drawer>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className={cn('w-full gap-0 overflow-hidden p-0 sm:max-w-lg', className)}
>
<SheetHeader className="shrink-0 border-b border-border/50">
<SheetTitle>{title}</SheetTitle>
{description ? <SheetDescription>{description}</SheetDescription> : null}
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">{children}</div>
<SheetFooter className="mt-0 shrink-0 border-t border-border/50 bg-muted/50">
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
{footer}
</div>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
+7 -3
View File
@@ -47,6 +47,7 @@ import { CommandPalette, type CommandPaletteItem } from '@/components/layout/com
import { NavUser } from '@/components/layout/nav-user'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
interface NavItem {
to: string
@@ -161,6 +162,9 @@ export function AppShell({ children }: { children: ReactNode }) {
} as CSSProperties
}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<Sidebar collapsible="icon">
<SidebarHeader>
<AppSwitcher />
@@ -196,7 +200,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<NavUser />
</SidebarFooter>
</Sidebar>
<SidebarInset>
<SidebarInset id="main-content">
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
@@ -220,9 +224,9 @@ export function AppShell({ children }: { children: ReactNode }) {
<SystemMonitorPopover />
</div>
</header>
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{children}
</main>
</div>
</SidebarInset>
<CommandPalette items={COMMAND_ITEMS} hotkeyOnly />
</SidebarProvider>
@@ -0,0 +1,260 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Checkbox } from '@evobgp/ui/components/checkbox'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { SelectField } from '@/components/select-field'
import {
dohProfileShortLabel,
moduleDohProfileIds,
} from '@/lib/modules/helpers'
import { dohPolicyRu, moduleTypeRu } from '@/lib/ui-labels'
import { useUpdateModuleMutation } from '@/queries/modules'
import type {
BgpCommunity,
DohProfile,
DohResolverPolicy,
ModulePatch,
ModuleRow,
} from '@/types/api'
interface ModuleEditDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
mod: ModuleRow
communities: BgpCommunity[]
dohProfiles: DohProfile[]
}
const DOH_POLICY_ITEMS: { value: DohResolverPolicy; label: string }[] = [
{ value: 'primary_only', label: dohPolicyRu('primary_only') },
{ value: 'failover', label: dohPolicyRu('failover') },
{ value: 'union', label: dohPolicyRu('union') },
]
export function ModuleEditDialog({
open,
onOpenChange,
mod,
communities,
dohProfiles,
}: ModuleEditDialogProps) {
const updateMutation = useUpdateModuleMutation()
const isDomains = mod.type === 'DOMAINS'
const [name, setName] = useState('')
const [enabled, setEnabled] = useState(true)
const [priority, setPriority] = useState('0')
const [refreshIntervalSec, setRefreshIntervalSec] = useState('')
const [cronExpr, setCronExpr] = useState('')
const [defaultCommunityId, setDefaultCommunityId] = useState<string | null>(null)
const [dohResolverPolicy, setDohResolverPolicy] = useState<DohResolverPolicy>('primary_only')
const [dohProfileIds, setDohProfileIds] = useState<string[]>([])
useEffect(() => {
if (!open) return
setName(mod.name ?? '')
setEnabled(mod.enabled !== false)
setPriority(String(mod.priority ?? 0))
setRefreshIntervalSec(
mod.refresh_interval_sec === null || mod.refresh_interval_sec === undefined
? ''
: String(mod.refresh_interval_sec),
)
setCronExpr(mod.cron_expr ?? '')
setDefaultCommunityId(mod.default_community_id ?? null)
setDohResolverPolicy(mod.doh_resolver_policy ?? 'primary_only')
setDohProfileIds(moduleDohProfileIds(mod))
}, [mod, open])
function toggleDohProfile(id: string, checked: boolean) {
setDohProfileIds((prev) => {
if (checked) {
if (prev.includes(id)) return prev
return [...prev, id]
}
return prev.filter((x) => x !== id)
})
}
async function save() {
const trimmedName = name.trim()
if (!trimmedName) {
toast.error('Укажите название модуля')
return
}
const priorityNum = Number(priority)
if (!Number.isFinite(priorityNum) || !Number.isInteger(priorityNum)) {
toast.error('Приоритет должен быть целым числом')
return
}
let refresh: number | null = null
if (refreshIntervalSec.trim() !== '') {
const n = Number(refreshIntervalSec)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
toast.error('Интервал обновления должен быть целым числом ≥ 0')
return
}
refresh = n
}
const body: ModulePatch = {
name: trimmedName,
enabled,
priority: priorityNum,
refresh_interval_sec: refresh,
cron_expr: cronExpr.trim() || null,
default_community_id: defaultCommunityId,
}
if (isDomains) {
body.doh_resolver_policy = dohResolverPolicy
body.doh_profile_ids = dohProfileIds
}
try {
await updateMutation.mutateAsync({ id: mod.id, body })
onOpenChange(false)
} catch {
// toast in mutation
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title="Редактировать модуль"
description={`${moduleTypeRu(mod.type)} · ${mod.type}`}
className="sm:max-w-md"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={updateMutation.isPending} onClick={() => void save()}>
Сохранить
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-name">Название</Label>
<Input
id="mod-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Имя модуля"
/>
</div>
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3">
<div className="grid min-w-0 flex-1 gap-1 pr-2">
<Label htmlFor="mod-enabled">Включён</Label>
<p className="text-xs text-muted-foreground">
Выключенный модуль не участвует в refresh и apply.
</p>
</div>
<Checkbox
id="mod-enabled"
checked={enabled}
onCheckedChange={(v) => setEnabled(v === true)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-priority">Приоритет</Label>
<Input
id="mod-priority"
type="number"
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-interval">Интервал обновления (сек)</Label>
<Input
id="mod-interval"
type="number"
min={0}
placeholder="пусто = по умолчанию"
value={refreshIntervalSec}
onChange={(e) => setRefreshIntervalSec(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-cron">Cron (опционально)</Label>
<Input
id="mod-cron"
placeholder="0 * * * *"
value={cronExpr}
onChange={(e) => setCronExpr(e.target.value)}
/>
</div>
<CommunitySelect
id="mod-community"
label="Community по умолчанию"
value={defaultCommunityId}
onValueChange={setDefaultCommunityId}
communities={communities}
nullable
/>
{isDomains ? (
<>
<SelectField
id="mod-doh-policy"
label="Политика DoH"
items={DOH_POLICY_ITEMS}
value={dohResolverPolicy}
onValueChange={(v) => setDohResolverPolicy(v as DohResolverPolicy)}
/>
<div className="flex flex-col gap-2">
<Label>DoH профили</Label>
{dohProfiles.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
) : (
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
{dohProfiles.map((p) => {
const checked = dohProfileIds.includes(p.id)
return (
<label
key={p.id}
htmlFor={`mod-doh-${p.id}`}
className="flex cursor-pointer items-start gap-3"
>
<Checkbox
id={`mod-doh-${p.id}`}
checked={checked}
onCheckedChange={(v) => toggleDohProfile(p.id, v === true)}
className="mt-0.5"
/>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium">{dohProfileShortLabel(p.id, dohProfiles)}</span>
<span className="text-muted-foreground truncate text-xs" title={p.url}>
{p.url}
</span>
</span>
</label>
)
})}
</div>
)}
</div>
</>
) : null}
</FormDrawer>
)
}
@@ -2,29 +2,25 @@ import { Clock, Gauge, Globe, Tags } from 'lucide-react'
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
import { Badge } from '@/components/reui/badge'
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
import {
communityLabel,
dohProfileLabel,
moduleDohProfileIds,
} from '@/lib/modules/helpers'
import { dohPolicyRu } from '@/lib/ui-labels'
import { KpiStatGridSkeleton } from '@/components/skeletons'
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
import { communityLabel, moduleDohProfileIds } from '@/lib/modules/helpers'
import { dohPolicyRu } from '@/lib/ui-labels'
import type { AsEntry, BgpCommunity, ModuleRow } from '@/types/api'
interface ModuleKpiCardsProps {
mod: ModuleRow | null
communities: BgpCommunity[]
dohProfiles: DohProfile[]
asEntries: AsEntry[]
entriesCount?: number
loading?: boolean
}
export function ModuleKpiCards({
mod,
communities,
dohProfiles,
asEntries,
entriesCount = 0,
loading = false,
}: ModuleKpiCardsProps) {
if (loading || !mod) {
@@ -33,6 +29,49 @@ export function ModuleKpiCards({
const asPrefixTotal = asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
const dohIds = moduleDohProfileIds(mod)
const isDomains = mod.type === 'DOMAINS'
const isAsPrefixes = mod.type === 'AS_PREFIXES'
const community = communityLabel(mod.default_community_id, communities)
const entriesFooter = isAsPrefixes ? (
<Badge variant="success-light" size="sm">
{asEntries.length} AS · в модуле
</Badge>
) : isDomains ? (
<Badge variant="outline" size="sm">
{mod.default_community_id ? community : 'без community'}
</Badge>
) : (
<Badge variant="success-light" size="sm">
{entriesCount} · в модуле
</Badge>
)
const policyItem: KpiStatItem = isDomains
? {
id: 'doh',
icon: <Globe aria-hidden />,
iconClassName: 'text-primary',
value: dohIds.length > 0 ? String(dohIds.length) : '—',
label: 'DoH',
footer: (
<Badge variant={dohIds.length > 0 ? 'info-light' : 'outline'} size="sm">
{dohIds.length > 0 ? dohPolicyRu(mod.doh_resolver_policy) : 'без DoH'}
</Badge>
),
}
: {
id: 'community',
icon: <Globe aria-hidden />,
iconClassName: 'text-primary',
value: community,
label: 'Community',
footer: (
<Badge variant="outline" size="sm">
по умолчанию
</Badge>
),
}
const items: KpiStatItem[] = [
{
@@ -63,29 +102,11 @@ export function ModuleKpiCards({
id: 'prefixes',
icon: <Tags aria-hidden />,
iconClassName: 'text-success',
value: mod.type === 'AS_PREFIXES' ? String(asPrefixTotal) : String(asEntries.length),
label: mod.type === 'AS_PREFIXES' ? 'Префиксы AS' : 'Записи модуля',
footer: (
<Badge variant="success-light" size="sm">
{asEntries.length} AS · в модуле
</Badge>
),
},
{
id: 'policy',
icon: <Globe aria-hidden />,
iconClassName: 'text-primary',
value: dohIds.length > 0 ? String(dohIds.length) : '—',
label: communityLabel(mod.default_community_id, communities),
footer: (
<Badge variant="info-light" size="sm">
{dohPolicyRu(mod.doh_resolver_policy)}
{dohIds.length > 0
? ` · ${dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join(', ')}`
: ' · без DoH'}
</Badge>
),
value: isAsPrefixes ? String(asPrefixTotal) : String(entriesCount),
label: isAsPrefixes ? 'Префиксы AS' : 'Записи модуля',
footer: entriesFooter,
},
policyItem,
]
return <KpiStatGrid items={items} aria-label="KPI модуля" />
@@ -7,12 +7,20 @@ import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
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 { Item, ItemMedia } from '@evobgp/ui/components/item'
import { cn } from '@evobgp/ui/lib/utils'
const READY_CHECK_ICONS: Record<string, typeof Database> = {
postgres: Database,
store: HardDrive,
store_backend: HardDrive,
jobs: ListTodo,
}
@@ -21,6 +29,7 @@ interface ReadyCheckRow {
label: string
subtitle?: string
icon: typeof Database
iconClassName?: string
status: string
statusLabel: string
}
@@ -34,12 +43,14 @@ export function MonitoringReadyGrid({
}) {
const data = useMemo<ReadyCheckRow[]>(() => {
const checks = ready.checks ?? {}
const systemReady = isSystemReady(ready.status)
const rows: ReadyCheckRow[] = [
{
id: 'liveness',
label: 'Живучесть',
subtitle: '/v1/health',
icon: HeartPulse,
iconClassName: health?.ok ? 'text-success' : 'text-destructive',
status: health?.ok ? 'ok' : 'error',
statusLabel: health?.ok ? 'В норме' : 'Ошибка',
},
@@ -48,19 +59,21 @@ export function MonitoringReadyGrid({
label: 'Готовность',
subtitle: '/v1/ready',
icon: ShieldCheck,
status: ready.status === 'ok' ? 'ok' : 'warning',
statusLabel: ready.status === 'ok' ? 'Готов' : jobStatusRu(ready.status ?? 'pending'),
iconClassName: systemReady ? 'text-success' : 'text-warning',
status: systemReady ? 'ok' : 'warning',
statusLabel: systemReady ? 'Готов' : 'Не готов',
},
]
for (const key of Object.keys(checks)) {
const value = checks[key]
const ok = typeof value === 'boolean' ? value : value?.ok !== false
const ok = isReadyCheckOk(value)
rows.push({
id: key,
label: readyCheckRu(key),
icon: READY_CHECK_ICONS[key] ?? ListTodo,
iconClassName: ok ? 'text-success' : 'text-destructive',
status: ok ? 'ok' : 'error',
statusLabel: ok ? 'В норме' : 'Ошибка',
statusLabel: readyCheckStatusLabel(value, ok),
})
}
return rows
@@ -74,8 +87,17 @@ export function MonitoringReadyGrid({
cell: ({ row }) => {
const Icon = row.original.icon
return (
<div className="flex items-center gap-2">
<Icon className="size-4 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 items-center gap-2.5 py-0.5">
<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
title={row.original.label}
subtitle={row.original.subtitle}
@@ -90,7 +112,9 @@ export function MonitoringReadyGrid({
enableSorting: false,
header: 'Статус',
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: 'Статус' },
},
@@ -8,7 +8,7 @@ import {
} from '@evobgp/ui/components/chart'
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({
title,
description,
@@ -30,13 +30,24 @@ export function DonutBreakdownCard({
const data = slices.map((slice) => ({ ...slice, fill: slice.color, share: slice.count }))
return (
<PanelCard title={title} description={description} className="h-full">
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center">
<PanelCard
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 ? (
<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">
<PieChart>
<Pie
@@ -59,11 +70,11 @@ export function DonutBreakdownCard({
<span className="text-lg font-semibold tabular-nums">{total}</span>
</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) => {
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
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="size-2.5 shrink-0 rounded-full"
@@ -81,11 +92,6 @@ export function DonutBreakdownCard({
</ul>
</>
)}
{badge ? (
<Badge variant="success-light" className="absolute top-4 right-4 hidden sm:flex">
{badge}
</Badge>
) : null}
</div>
</PanelCard>
)
@@ -2,7 +2,10 @@ import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { IconStack } from '@/components/reui/icon-stack'
import { Card, CardContent } from '@evobgp/ui/components/card'
import {
Frame,
FramePanel,
} from '@/components/reui/frame'
import {
Empty,
EmptyDescription,
@@ -11,7 +14,7 @@ import {
EmptyTitle,
} from '@evobgp/ui/components/empty'
/** empty-state-11 pattern adapted to Card surface. */
/** empty-state-14: Frame + IconStack. Preview: https://reui.io/preview/base/empty-state-14 */
export function IllustratedEmptyState({
title,
description,
@@ -24,8 +27,8 @@ export function IllustratedEmptyState({
action?: ReactNode
}) {
return (
<Card className="bg-muted min-h-[240px] p-0 shadow-none">
<CardContent className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
<Frame dense spacing="sm" className="w-full">
<FramePanel className="flex min-h-[240px] flex-col items-center justify-center gap-4 p-6 sm:p-10">
{action ? <div className="w-full self-end">{action}</div> : null}
<Empty className="max-w-md gap-5 bg-transparent p-0">
<EmptyHeader className="items-center gap-5 text-center">
@@ -40,7 +43,7 @@ export function IllustratedEmptyState({
</div>
</EmptyHeader>
</Empty>
</CardContent>
</Card>
</FramePanel>
</Frame>
)
}
@@ -4,7 +4,7 @@ import { Badge } from '@/components/reui/badge'
import { CardDotField } from '@/components/dashboard/card-dot-field'
import { PanelCorners } from '@/components/patterns/panel-corners'
import { toneStyles, type MetricTone } from '@/components/patterns/metric-tone-styles'
import { Card, CardContent } from '@evobgp/ui/components/card'
import { Frame, FramePanel } from '@/components/reui/frame'
export type KpiSparklineMetric = {
id: string
@@ -44,21 +44,21 @@ function Sparkline({ values, color }: { values: readonly number[]; color: string
)
}
/** KPI card with sparkline (dashboard-5 / chart-15 pattern, Card surface). */
/** KPI sparkline tile — chart-15 Frame DNA. Preview: https://reui.io/preview/base/chart-15 */
export function KpiSparklineCard({ metric }: { metric: KpiSparklineMetric }) {
const tone = toneStyles[metric.tone]
return (
<Card className="relative overflow-hidden p-0">
<CardDotField className="text-muted-foreground [mask-image:radial-gradient(72%_64%_at_50%_44%,black,transparent)] opacity-70" />
<PanelCorners />
<CardContent className="relative z-10 flex min-h-[7.25rem] flex-col justify-between gap-5 p-4">
<div className="flex min-w-0 flex-col gap-0.5">
<Frame dense spacing="sm" className="relative overflow-hidden">
<FramePanel className="relative isolate flex min-h-[7.25rem] flex-col justify-between gap-5 overflow-hidden p-4">
<CardDotField className="text-muted-foreground [mask-image:radial-gradient(72%_64%_at_50%_44%,black,transparent)] opacity-70" />
<PanelCorners />
<div className="relative z-10 flex min-w-0 flex-col gap-0.5">
<span className="text-foreground truncate text-sm leading-4 font-semibold">{metric.title}</span>
<span className="text-muted-foreground truncate text-xs leading-4">{metric.label}</span>
</div>
<div className="flex items-end justify-between gap-4">
<div className="min-w-0 space-y-2.5">
<div className="relative z-10 flex items-end justify-between gap-4">
<div className="flex min-w-0 flex-col gap-2.5">
<div className="text-foreground text-2xl leading-none font-semibold tracking-tight tabular-nums">
{metric.value}
</div>
@@ -71,7 +71,7 @@ export function KpiSparklineCard({ metric }: { metric: KpiSparklineMetric }) {
</div>
<Sparkline values={metric.sparkline} color={tone.stroke} />
</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
)
}
+20 -14
View File
@@ -34,23 +34,29 @@ export function QueryState<T>({
children,
}: QueryStateProps<T>) {
if (isLoading) {
return <>{skeleton ?? <DefaultSkeleton />}</>
return (
<div role="status" aria-live="polite" aria-busy="true">
{skeleton ?? <DefaultSkeleton />}
</div>
)
}
if (isError) {
return (
<EmptyState
icon={<AlertCircle className="size-8" />}
title="Ошибка загрузки"
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
action={
onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
<RefreshCwIcon data-icon="inline-start" />
Повторить
</Button>
) : null
}
/>
<div role="alert">
<EmptyState
icon={AlertCircle}
title="Ошибка загрузки"
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
action={
onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
<RefreshCwIcon data-icon="inline-start" />
Повторить
</Button>
) : null
}
/>
</div>
)
}
if (empty || data == null) {
@@ -5,7 +5,7 @@ import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { cn } from '@evobgp/ui/lib/utils'
import { kpiCols } from './kpi-cols'
import { Item, ItemMedia } from '@evobgp/ui/components/item'
import { IconTile } from '@/components/reui/icon-tile'
import { Skeleton } from '@evobgp/ui/components/skeleton'
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
@@ -83,16 +83,13 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
return (
<div className="relative z-10 flex h-full items-start gap-3">
{item.icon ? (
<Item
className={cn(
'border-background bg-muted flex size-10.5 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-4',
item.iconClassName ?? DEFAULT_ICON_CLASS,
)}
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
>
<ItemMedia variant="icon" className="size-auto">
{item.icon}
</ItemMedia>
</Item>
{item.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
@@ -8,7 +8,7 @@ import {
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { Item, ItemMedia } from '@evobgp/ui/components/item'
import { IconTile } from '@/components/reui/icon-tile'
import { cn } from '@evobgp/ui/lib/utils'
import { kpiCols } from './kpi-cols'
@@ -54,16 +54,13 @@ function QuickActionBody({ action }: { action: QuickActionItem }) {
return (
<div className="relative z-10 flex h-full items-start gap-3">
{action.icon ? (
<Item
className={cn(
'border-background bg-muted flex size-10.5 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-4',
action.iconClassName ?? DEFAULT_ICON_CLASS,
)}
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', action.iconClassName ?? DEFAULT_ICON_CLASS)}
>
<ItemMedia variant="icon" className="size-auto">
{action.icon}
</ItemMedia>
</Item>
{action.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
@@ -9,7 +9,7 @@ import { PageHeader } from '@/components/page-header'
export interface SettingsTabConfig {
id: string
to: string
to: '/settings' | '/tenant-settings'
label: string
icon?: ReactNode
}
@@ -72,19 +72,35 @@ export function SettingsShell({
>
{tabs.map((tab) => {
const isActive = pathname.startsWith(tab.to)
const className = cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
isMobile && 'shrink-0',
!isMobile && 'w-full',
isActive
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)
if (tab.to === '/tenant-settings') {
return (
<Link
key={tab.id}
to="/tenant-settings"
search={{ tab: 'bird' }}
aria-current={isActive ? 'page' : undefined}
className={className}
>
{tab.icon}
{tab.label}
</Link>
)
}
return (
<Link
key={tab.id}
to={tab.to}
to="/settings"
search={{ tab: 'connection' }}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
isMobile && 'shrink-0',
!isMobile && 'w-full',
isActive
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)}
className={className}
>
{tab.icon}
{tab.label}
+125
View File
@@ -0,0 +1,125 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@evobgp/ui/lib/utils"
/**
* CSS variable architecture:
*
* The root owns four variables so every part of the tile stays in proportion
* and stays overridable from a single `className`:
*
* --icon-tile-size tile width/height
* --icon-tile-icon-size glyph size applied to child svgs
* --icon-tile-radius corner radius (also drives the nested inner card)
* --icon-tile-inset gap between the outer ring and the inner card
*
* The `frame` and `soft` variants paint their inner card with an `::after`
* pseudo element instead of a wrapper node. `isolate` makes the root a stacking
* context, so the negative z-index pseudo paints above the root background but
* below the in-flow icon - no extra DOM, and `render` composition keeps working.
*
* Tone: `soft` and `solid` derive every fill and border from `currentColor`, so
* a single text color class (e.g. `text-success`) retints the whole tile. They
* default to `text-primary`; override it to recolor without touching internals.
*/
const iconTileVariants = cva(
[
"relative inline-flex shrink-0 items-center justify-center align-middle",
"size-(--icon-tile-size) rounded-(--icon-tile-radius)",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-(--icon-tile-icon-size)",
],
{
variants: {
variant: {
/** Plain bordered surface. The quiet default for list rows and toolbars. */
outline: "border border-border bg-background dark:bg-input/32",
/** Raised muted fill with a background-colored ring. Reads as a physical chip. */
elevated:
"border-2 border-background bg-muted text-accent-foreground shadow-[0_1px_3px_0_rgb(0_0_0/0.14)] dark:border",
/**
* Tinted double container: an opacity-filled outer ring with no border
* around a bordered inner card, all derived from `currentColor`. The
* quiet, colorful sibling of `frame`. Retint with a text color class.
*/
soft: [
"isolate p-(--icon-tile-inset) text-primary bg-current/10",
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
"after:border after:border-current/20 after:bg-current/5",
],
/** Filled tone with a contrasting glyph. Retint with `bg-*` + a text color. */
solid: "bg-primary text-primary-foreground",
/** Double container - a muted ring around an inset card, matching Frame. */
frame: [
"isolate border border-border bg-muted/50 p-(--icon-tile-inset)",
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
"after:border after:border-border after:bg-card after:shadow-xs",
],
},
size: {
xs: "[--icon-tile-size:--spacing(6)] [--icon-tile-icon-size:--spacing(3.5)] [--icon-tile-inset:--spacing(0.5)]",
sm: "[--icon-tile-size:--spacing(8)] [--icon-tile-icon-size:--spacing(4)] [--icon-tile-inset:--spacing(0.5)]",
default:
"[--icon-tile-size:--spacing(10)] [--icon-tile-icon-size:--spacing(4.5)] [--icon-tile-inset:--spacing(0.75)]",
lg: "[--icon-tile-size:--spacing(12)] [--icon-tile-icon-size:--spacing(5.5)] [--icon-tile-inset:--spacing(0.75)]",
xl: "[--icon-tile-size:--spacing(14)] [--icon-tile-icon-size:--spacing(7)] [--icon-tile-inset:--spacing(1)]",
},
/**
* `default`: active style radius. `full`: circular.
* The plain value is the fallback outside a style scope; the `style-*`
* tokens win by specificity inside one, and survive the registry
* transform as the single resolved value per generated style.
*
* Each style token is clamped to a fraction of the tile. A flat radius
* is a circle once it reaches half the box, so the soft styles used to
* render `xs` (24px) and `sm` (32px) as plain circles and swallow the
* `full` variant's meaning. Clamping keeps one corner ratio at every
* size instead, so a tile still reads as the same shape when it scales.
* Luma and Rhea take the gentler quarter ratio; Maia stays at a third.
*/
radius: {
default:
"[--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))] [--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))]",
full: "[--icon-tile-radius:calc(infinity*1px)]",
},
},
defaultVariants: {
variant: "outline",
size: "default",
radius: "default",
},
}
)
interface IconTileProps extends useRender.ComponentProps<"span"> {
variant?: VariantProps<typeof iconTileVariants>["variant"]
size?: VariantProps<typeof iconTileVariants>["size"]
radius?: VariantProps<typeof iconTileVariants>["radius"]
}
function IconTile({
className,
variant = "outline",
size = "default",
radius = "default",
render,
...props
}: IconTileProps) {
const defaultProps = {
"data-slot": "icon-tile",
"data-variant": variant,
"data-size": size,
className: cn(iconTileVariants({ variant, size, radius, className })),
}
return useRender({
defaultTagName: "span",
render,
props: mergeProps<"span">(defaultProps, props),
})
}
export { IconTile, iconTileVariants, type IconTileProps }
@@ -3,9 +3,9 @@ import { useTheme } from 'next-themes'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
import { SettingRow } from '@/components/settings/setting-row'
import { SettingsCard } from '@/components/settings/settings-card'
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
import { Badge } from '@/components/reui/badge'
import {
ToggleGroup,
@@ -4,9 +4,9 @@ import { Save } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
import { SettingRow } from '@/components/settings/setting-row'
import { SettingsCard } from '@/components/settings/settings-card'
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
import { Badge } from '@/components/reui/badge'
import { LoadingButton } from '@/components/loading-button'
import {
@@ -6,7 +6,7 @@ import {
} from 'lucide-react'
import { Fragment } from 'react'
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
import { SettingsCard } from '@/components/settings/settings-card'
import { Badge } from '@/components/reui/badge'
import { Button } from '@evobgp/ui/components/button'
import {
@@ -1,9 +1,9 @@
import { useQuery } from '@tanstack/react-query'
import { MonitorIcon } from 'lucide-react'
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
import { SettingRow } from '@/components/settings/setting-row'
import { SettingsCard } from '@/components/settings/settings-card'
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
import { Badge } from '@/components/reui/badge'
import { QueryState } from '@/components/query-state'
import { TOKEN_STORAGE_KEY } from '@/lib/api-client'
@@ -0,0 +1,63 @@
import { type ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldSeparator,
FieldTitle,
} from '@evobgp/ui/components/field'
interface SettingRowProps {
title: string
description?: ReactNode
children: ReactNode
last?: boolean
labelFor?: string
contentClassName?: string
titleAddon?: ReactNode
}
/** Settings row — settings-3 DNA. Preview: https://reui.io/preview/base/settings-3 */
export function SettingRow({
title,
description,
children,
last,
labelFor,
contentClassName,
titleAddon,
}: SettingRowProps) {
return (
<>
<Field orientation="responsive" className="gap-4 px-5 py-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
<div className="flex flex-wrap items-center gap-2">
{labelFor ? (
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
) : (
<FieldTitle>{title}</FieldTitle>
)}
{titleAddon}
</div>
{description ? (
<FieldDescription>{description}</FieldDescription>
) : null}
</div>
<FieldContent
className={cn('min-w-0 @md/field-group:w-78', contentClassName)}
>
<div className="flex w-full justify-start @md/field-group:justify-end">
{children}
</div>
</FieldContent>
</Field>
{!last ? <FieldSeparator /> : null}
</>
)
}
@@ -0,0 +1,53 @@
import { type ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
interface SettingsCardProps {
title: string
description?: string
children: ReactNode
footer?: ReactNode
className?: string
contentClassName?: string
footerClassName?: string
}
/** Settings section — Frame surface. Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3 */
export function SettingsCard({
title,
description,
children,
footer,
className,
contentClassName,
footerClassName,
}: SettingsCardProps) {
return (
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
<FrameHeader className="gap-0 border-b px-5 py-3">
<FrameTitle>{title}</FrameTitle>
{description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader>
<div className={cn('min-w-0', contentClassName)}>{children}</div>
{footer ? (
<FrameFooter
className={cn('justify-end gap-2 border-t px-5 py-3', footerClassName)}
>
{footer}
</FrameFooter>
) : null}
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,36 @@
import { type ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import {
FieldDescription,
FieldGroup,
FieldLegend,
FieldSet,
} from '@evobgp/ui/components/field'
interface SettingsFieldGroupProps {
legend: string
description: string
children: ReactNode
className?: string
fieldGroupClassName?: string
}
/** Settings field group — settings-3 DNA. Preview: https://reui.io/preview/base/settings-3 */
export function SettingsFieldGroup({
legend,
description,
children,
className,
fieldGroupClassName,
}: SettingsFieldGroupProps) {
return (
<FieldSet className={cn('gap-0', className)}>
<FieldLegend className="sr-only">{legend}</FieldLegend>
<FieldDescription className="sr-only">{description}</FieldDescription>
<FieldGroup className={cn('gap-0', fieldGroupClassName)}>
{children}
</FieldGroup>
</FieldSet>
)
}
+25
View File
@@ -308,6 +308,31 @@ export function sessionCanWriteModules(session: {
return role === 'editor' || role === 'operator'
}
/**
* Whether session may create/update directories (`bgp:directories:write`).
* Mirrors backend `requirePerm` for JWT (is_admin / permissions) and API-key editor+.
*/
export function sessionCanWriteDirectories(session: {
role?: string
kind?: string
is_admin?: boolean
permissions?: readonly string[]
} | null | undefined): boolean {
if (!session) return false
const jwtPath =
session.kind === 'jwt' ||
session.is_admin === true ||
(session.permissions?.length ?? 0) > 0
if (jwtPath) {
return (
session.is_admin === true ||
hasPermission(session.permissions ?? [], 'bgp:directories:write')
)
}
const role = (session.role ?? '').toLowerCase()
return role === 'editor' || role === 'operator'
}
/** Nav path → minimum permission to show the item. Sync with app-shell NAV. */
export function permissionForPath(pathname: string): string | null {
if (pathname === '/' || pathname.startsWith('/dashboard')) {
@@ -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'
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 === '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
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(
ready: ReadyStatus | null | undefined,
healthOk: boolean,
@@ -28,7 +81,7 @@ export function readinessBreakdown(
let failCount = 0
for (const value of Object.values(checks)) {
if (checkOk(value)) okCount += 1
if (isReadyCheckOk(value)) okCount += 1
else failCount += 1
}
@@ -61,9 +114,11 @@ export function readinessBreakdown(
if (slices.length === 1 && okCount === 0 && failCount === 0) {
slices.push({
key: 'ready',
label: ready?.status === 'ok' ? 'Готов' : 'Ожидает готовности',
label: isSystemReady(ready?.status) ? 'Готов' : 'Не готов',
count: 1,
color: 'var(--color-chart-4)',
color: isSystemReady(ready?.status)
? 'var(--color-chart-1)'
: 'var(--color-warning)',
})
}
+13
View File
@@ -36,6 +36,19 @@ export function dohProfileLabel(id: string, dohProfiles: DohProfile[]): string {
return p ? (p.name?.trim() ? `${p.name} (${p.url})` : p.url) : `${id.slice(0, 8)}`
}
/** Short label for forms / chips — name preferred, else hostname from URL. */
export function dohProfileShortLabel(id: string, dohProfiles: DohProfile[]): string {
const p = dohProfiles.find((d) => d.id === id)
if (!p) return `${id.slice(0, 8)}`
const name = p.name?.trim()
if (name) return name
try {
return new URL(p.url).hostname
} catch {
return p.url
}
}
export function normalizeCdnSourceKind(k: string): 'plaintext' | 'json' {
return k.trim().toLowerCase() === 'json' ? 'json' : 'plaintext'
}
+2
View File
@@ -117,6 +117,8 @@ export function readyCheckRu(key: string): string {
return 'Хранилище'
case 'jobs':
return 'Очередь задач'
case 'store_backend':
return 'Бэкенд хранилища'
default:
return key
}
+4
View File
@@ -12,6 +12,10 @@
*/
export const UI_SURFACE = 'frame' as const
/** Skip link — keyboard: first Tab. */
export const SKIP_TO_CONTENT_CLASS =
'bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-sm focus:ring-2'
/** Grid for ReUI stats-12 KPI rows (36 tiles). */
export const kpiStatGridClassName = '@container w-full'
+70 -3
View File
@@ -1,6 +1,17 @@
import { queryOptions } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client'
import type { CommunitiesResponse, DohProfilesResponse } from '@/types/api'
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { apiJSON, apiMutate } from '@/lib/api-client'
import type {
BgpCommunity,
BgpCommunityCreate,
BgpCommunityPatch,
CommunitiesResponse,
DohProfile,
DohProfileCreate,
DohProfilePatch,
DohProfilesResponse,
} from '@/types/api'
export const directoriesKeys = {
all: ['directories'] as const,
@@ -23,3 +34,59 @@ export function directoriesDohQueryOptions() {
staleTime: 30_000,
})
}
export function useCreateCommunityMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: BgpCommunityCreate) =>
apiMutate<BgpCommunity>('/v1/communities', 'POST', body, { idempotent: false }),
onSuccess: () => {
toast.success('Сообщество создано')
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
},
onError: (e) =>
toast.error(e instanceof Error ? e.message : 'Не удалось создать сообщество'),
})
}
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() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: DohProfileCreate) =>
apiMutate<DohProfile>('/v1/doh-profiles', 'POST', body, { idempotent: false }),
onSuccess: () => {
toast.success('DoH-профиль создан')
void qc.invalidateQueries({ queryKey: directoriesKeys.doh() })
},
onError: (e) =>
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-профиль'),
})
}
+27 -2
View File
@@ -1,6 +1,10 @@
import { queryOptions } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client'
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { apiJSON, apiMutate } from '@/lib/api-client'
import { overviewKeys } from '@/queries/overview'
import type {
ModulePatch,
ModuleRow,
ModulesResponse,
Page,
@@ -16,6 +20,14 @@ export const modulesKeys = {
ipRangeEntries: (id: string) => [...modulesKeys.all, 'ip-range-entries', id] as const,
}
function invalidateModules(qc: ReturnType<typeof useQueryClient>, id?: string) {
void qc.invalidateQueries({ queryKey: modulesKeys.all })
void qc.invalidateQueries({ queryKey: overviewKeys.modules() })
if (id) {
void qc.invalidateQueries({ queryKey: modulesKeys.detail(id) })
}
}
export function modulesListQueryOptions() {
return queryOptions<ModulesResponse>({
queryKey: modulesKeys.list(),
@@ -51,3 +63,16 @@ export function moduleEntriesQueryOptions(id: string, type: ModuleRow['type']) {
queryFn: () => apiJSON<ModuleEntriesPage>(path),
})
}
export function useUpdateModuleMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: string; body: ModulePatch }) =>
apiMutate<ModuleRow>(`/v1/modules/${id}`, 'PATCH', body, { idempotent: false }),
onSuccess: (_data, vars) => {
toast.success('Модуль обновлён')
invalidateModules(qc, vars.id)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось обновить модуль'),
})
}
+2 -1
View File
@@ -9,7 +9,8 @@ export interface HealthStatus {
export interface ReadyStatus {
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 {
+7
View File
@@ -0,0 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'
import { SettingsShell } from '@/components/reui-kit/settings-shell'
export const Route = createFileRoute('/_auth/_settings')({
component: SettingsShell,
})
@@ -1,11 +1,8 @@
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
import { PageHeader } from '@/components/page-header'
import { SettingsPageShell } from '@/components/settings/settings-page-shell'
import {
type SettingsTab,
} from '@/components/settings/settings-tabs-data'
import { type SettingsTab } from '@/components/settings/settings-tabs-data'
const settingsSearchSchema = z.object({
tab: z
@@ -14,7 +11,7 @@ const settingsSearchSchema = z.object({
reason: z.enum(['token-required']).optional(),
})
export const Route = createFileRoute('/_auth/settings')({
export const Route = createFileRoute('/_auth/_settings/settings')({
component: SettingsComponent,
validateSearch: (search) => settingsSearchSchema.parse(search),
})
@@ -35,17 +32,10 @@ function SettingsComponent() {
}
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
<PageHeader
title="Настройки"
description="Параметры интерфейса и подключения браузера к API. Параметры tenant — во вкладке «Разделы»."
/>
<SettingsPageShell
activeTab={tab}
onTabChange={handleTabChange}
tokenRequired={tokenRequired}
/>
</div>
<SettingsPageShell
activeTab={tab}
onTabChange={handleTabChange}
tokenRequired={tokenRequired}
/>
)
}
@@ -12,7 +12,6 @@ import { FrameDataGrid } from '@/components/reui-kit'
import { SelectField } from '@/components/select-field'
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
import { SettingsSettingField } from '@/components/settings/settings-setting-field'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { LoadingButton } from '@/components/loading-button'
@@ -28,7 +27,7 @@ import {
} from '@/queries/settings'
import { apiMutate } from '@/lib/api-client'
export const Route = createFileRoute('/_auth/tenant-settings')({
export const Route = createFileRoute('/_auth/_settings/tenant-settings')({
component: TenantSettingsComponent,
validateSearch: (search: Record<string, unknown>) => ({
tab: (search.tab === 'revision' || search.tab === 'runtime-logs' || search.tab === 'additional'
@@ -74,7 +73,7 @@ const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
}
function TenantSettingsComponent() {
const search = useSearch({ from: '/_auth/tenant-settings' })
const search = useSearch({ from: '/_auth/_settings/tenant-settings' })
const navigate = Route.useNavigate()
const settingsQ = useQuery(settingsQueryOptions())
const qc = useQueryClient()
@@ -116,11 +115,6 @@ function TenantSettingsComponent() {
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Параметры арендатора"
description="Параметры плоскости управления для текущего арендатора (API /v1/settings)"
/>
<BadgeTabs
value={search.tab}
onValueChange={(tab) =>
+92 -8
View File
@@ -1,23 +1,41 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { BookText, Globe, RefreshCw, Tags } from 'lucide-react'
import { BookText, Globe, Plus, RefreshCw, Tags } from 'lucide-react'
import { useState } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { Badge } from '@/components/reui/badge'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { FrameDataGrid, KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import {
CommunityFormDialog,
} from '@/components/directories/community-create-dialog'
import { DirectoriesCommunitiesGrid } from '@/components/directories/directories-communities-grid'
import { DirectoriesDohGrid } from '@/components/directories/directories-doh-grid'
import {
DohProfileFormDialog,
} from '@/components/directories/doh-profile-create-dialog'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { Badge } from '@/components/reui/badge'
import { FrameDataGrid, KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
import { sessionCanWriteDirectories } from '@/lib/auth'
import { authSessionQueryOptions } from '@/queries/auth'
import { directoriesCommunitiesQueryOptions, directoriesDohQueryOptions } from '@/queries/directories'
import type { BgpCommunity, DohProfile } from '@/types/api'
export const Route = createFileRoute('/_auth/directories')({
component: DirectoriesComponent,
})
function DirectoriesComponent() {
const [communityOpen, setCommunityOpen] = useState(false)
const [communityEdit, setCommunityEdit] = useState<BgpCommunity | null>(null)
const [dohOpen, setDohOpen] = useState(false)
const [dohEdit, setDohEdit] = useState<DohProfile | null>(null)
const sessionQ = useQuery(authSessionQueryOptions())
const canWrite = sessionCanWriteDirectories(sessionQ.data)
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
const dohQ = useQuery(directoriesDohQueryOptions())
@@ -58,6 +76,40 @@ 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 ? (
<Button size="sm" type="button" onClick={openCreateCommunity}>
<Plus />
Добавить
</Button>
) : null
const addDohButton = canWrite ? (
<Button size="sm" type="button" onClick={openCreateDoh}>
<Plus />
Добавить
</Button>
) : null
return (
<div className="flex flex-col gap-6">
<PageHeader
@@ -92,6 +144,7 @@ function DirectoriesComponent() {
<FrameDataGrid
title="Сообщества BGP"
description="Теги для префиксов в фильтрах BIRD"
actions={addCommunityButton}
>
<QueryState
data={communities}
@@ -100,13 +153,16 @@ function DirectoriesComponent() {
error={communitiesQ.error}
empty={communities.length === 0}
emptyTitle="Нет сообществ"
emptyAction={addCommunityButton}
skeleton={<TableSkeleton rows={4} cols={3} />}
onRetry={() => communitiesQ.refetch()}
>
{(items) => (
{(rows) => (
<DirectoriesCommunitiesGrid
items={items}
items={rows}
isLoading={communitiesQ.isFetching && !communitiesQ.isLoading}
canWrite={canWrite}
onEdit={openEditCommunity}
/>
)}
</QueryState>
@@ -114,7 +170,11 @@ function DirectoriesComponent() {
</TabsContent>
<TabsContent value="doh" className="mt-0">
<FrameDataGrid title="DoH профили" description="Резолверы DNS-over-HTTPS для доменных модулей">
<FrameDataGrid
title="DoH профили"
description="Резолверы DNS-over-HTTPS для доменных модулей"
actions={addDohButton}
>
<QueryState
data={dohProfiles}
isLoading={dohQ.isLoading}
@@ -122,19 +182,43 @@ function DirectoriesComponent() {
error={dohQ.error}
empty={dohProfiles.length === 0}
emptyTitle="Нет DoH профилей"
emptyAction={addDohButton}
skeleton={<TableSkeleton rows={4} cols={3} />}
onRetry={() => dohQ.refetch()}
>
{(items) => (
{(rows) => (
<DirectoriesDohGrid
items={items}
items={rows}
isLoading={dohQ.isFetching && !dohQ.isLoading}
canWrite={canWrite}
onEdit={openEditDoh}
/>
)}
</QueryState>
</FrameDataGrid>
</TabsContent>
</BadgeTabs>
{canWrite ? (
<>
<CommunityFormDialog
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}
</div>
)
}
@@ -1,16 +1,20 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, RefreshCw } from 'lucide-react'
import { ArrowLeft, Pencil, RefreshCw } from 'lucide-react'
import { useState } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { ModuleEditDialog } from '@/components/modules/module-edit-dialog'
import { ModuleEntriesSection } from '@/components/modules/module-entries-section'
import { ModuleKpiCards } from '@/components/modules/module-kpi-cards'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { StatusBadge } from '@/components/status-badge'
import { ModuleEntriesSection } from '@/components/modules/module-entries-section'
import { ModuleKpiCards } from '@/components/modules/module-kpi-cards'
import { sessionCanWriteModules } from '@/lib/auth'
import { moduleTypeRu } from '@/lib/ui-labels'
import { authSessionQueryOptions } from '@/queries/auth'
import {
directoriesCommunitiesQueryOptions,
directoriesDohQueryOptions,
@@ -25,8 +29,12 @@ export const Route = createFileRoute('/_auth/modules/$moduleId')({
function ModuleDetailComponent() {
const { moduleId } = Route.useParams()
const queryClient = useQueryClient()
const [editOpen, setEditOpen] = useState(false)
const detail = useQuery(moduleDetailQueryOptions(moduleId))
const mod = detail.data
const sessionQ = useQuery(authSessionQueryOptions())
const canWrite = sessionCanWriteModules(sessionQ.data)
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
const dohQ = useQuery(directoriesDohQueryOptions())
@@ -57,6 +65,7 @@ function ModuleDetailComponent() {
const communities = communitiesQ.data?.items ?? []
const dohProfiles = dohQ.data?.items ?? []
const asEntries = mod?.type === 'AS_PREFIXES' ? (asEntriesQ.data ?? []) : []
const entriesCount = entriesQuery.data?.items.length ?? 0
return (
<div className="flex flex-col gap-6">
@@ -69,6 +78,12 @@ function ModuleDetailComponent() {
<ArrowLeft />
К списку
</Button>
{canWrite && mod ? (
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Pencil />
Редактировать
</Button>
) : null}
<Button
variant="outline"
size="sm"
@@ -103,8 +118,8 @@ function ModuleDetailComponent() {
<ModuleKpiCards
mod={m}
communities={communities}
dohProfiles={dohProfiles}
asEntries={asEntries}
entriesCount={entriesCount}
loading={detail.isLoading || communitiesQ.isLoading}
/>
@@ -119,6 +134,16 @@ function ModuleDetailComponent() {
onRetry={() => entriesQuery.refetch()}
onChanged={onEntriesChanged}
/>
{canWrite ? (
<ModuleEditDialog
open={editOpen}
onOpenChange={setEditOpen}
mod={m}
communities={communities}
dohProfiles={dohProfiles}
/>
) : null}
</div>
)}
</QueryState>
+17 -8
View File
@@ -1,7 +1,13 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { Card, CardContent } from '@evobgp/ui/components/card'
import { Button } from '@evobgp/ui/components/button'
import { PageHeader } from '@/components/page-header'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
export const Route = createFileRoute('/_auth/modules/new')({
component: NewModuleComponent,
@@ -14,11 +20,14 @@ function NewModuleComponent() {
title="Новый модуль"
description="Создание модуля — через API или будущая форма"
/>
<Card>
<CardContent className="flex flex-col gap-3 py-6 text-sm text-muted-foreground">
<p>
Форма создания модуля будет добавлена позже. Сейчас модули можно создать через API:
</p>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Создание через API</FrameTitle>
<FrameDescription>
Форма в UI появится позже. Сейчас модуль можно создать запросом ниже.
</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-3 text-sm text-muted-foreground">
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
{`POST /v1/modules
{ "type": "DOMAINS", "name": "Мой список" }`}
@@ -26,8 +35,8 @@ function NewModuleComponent() {
<Button variant="outline" size="sm" className="self-start" render={<Link to="/modules" />}>
Назад к списку
</Button>
</CardContent>
</Card>
</FramePanel>
</Frame>
</div>
)
}
+43 -41
View File
@@ -81,7 +81,7 @@ function MonitoringComponent() {
.slice(0, 5)
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2 md:gap-3">
<PageHeader
title="Мониторинг"
description={`Состояние API, BGP и задач для диагностики инцидентов${
@@ -110,11 +110,11 @@ function MonitoringComponent() {
{ 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 ? (
<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
healthOk={healthQ.data?.ok ?? false}
ready={readyQ.data}
@@ -124,7 +124,7 @@ function MonitoringComponent() {
</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
title="Доступность и готовность"
description="GET /v1/health · GET /v1/ready"
@@ -149,23 +149,24 @@ function MonitoringComponent() {
</span>
}
description="GET /v1/bird/status"
contentClassName="py-4"
className="h-full"
contentClassName="px-5 py-4"
>
<QueryState
data={birdQ.data}
isLoading={birdQ.isLoading}
isError={birdQ.isError}
error={birdQ.error}
skeleton={<div className="h-40" />}
onRetry={() => birdQ.refetch()}
>
{(bird) => <BirdSummary bird={bird} />}
</QueryState>
<QueryState
data={birdQ.data}
isLoading={birdQ.isLoading}
isError={birdQ.isError}
error={birdQ.error}
skeleton={<div className="h-40" />}
onRetry={() => birdQ.refetch()}
>
{(bird) => <BirdSummary bird={bird} />}
</QueryState>
</PanelCard>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-4">
<div className="grid items-stretch gap-2 md:gap-3 lg:grid-cols-2">
<div className="flex flex-col gap-2 md:gap-3">
<SegmentedProgressCard
title="Задачи"
description="Последние 100 задач · GET /v1/jobs"
@@ -189,8 +190,8 @@ function MonitoringComponent() {
footer={`В выборке: ${jobs.length} задач`}
/>
{failedJobs.length > 0 ? (
<PanelCard title="Последние ошибки" contentClassName="space-y-2 py-4">
<ul className="space-y-2">
<PanelCard title="Последние ошибки" contentClassName="px-5 py-4">
<ul className="flex flex-col gap-2">
{failedJobs.map((job) => (
<li key={job.job_id} className="rounded-lg border px-3 py-2 text-sm">
<div className="flex items-start justify-between gap-2">
@@ -218,28 +219,29 @@ function MonitoringComponent() {
</span>
}
description="Краткая шпаргалка для первичной диагностики"
contentClassName="py-4"
className="h-full"
contentClassName="px-5 py-4"
>
<ul className="space-y-3 text-sm text-muted-foreground">
<li>
<span className="font-medium text-foreground">API недоступен.</span> Если{' '}
<code className="text-xs">/v1/health</code> возвращает ошибку проверьте процесс API и
его логи.
</li>
<li>
<span className="font-medium text-foreground">Готовность не «Готов».</span> Сначала{' '}
<code className="text-xs">postgres</code>, затем <code className="text-xs">store</code>{' '}
и <code className="text-xs">jobs</code> в проверках.
</li>
<li>
<span className="font-medium text-foreground">Низкий ratio BGP.</span> Проверьте{' '}
<code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
</li>
<li>
<span className="font-medium text-foreground">Ошибки задач.</span> Откройте Операции и
проверьте последние неуспешные задачи.
</li>
</ul>
<ul className="flex flex-col gap-3 text-sm text-muted-foreground">
<li>
<span className="font-medium text-foreground">API недоступен.</span> Если{' '}
<code className="text-xs">/v1/health</code> возвращает ошибку проверьте процесс API и
его логи.
</li>
<li>
<span className="font-medium text-foreground">Готовность не «Готов».</span> Сначала{' '}
<code className="text-xs">postgres</code>, затем <code className="text-xs">store</code>{' '}
и <code className="text-xs">jobs</code> в проверках.
</li>
<li>
<span className="font-medium text-foreground">Низкий ratio BGP.</span> Проверьте{' '}
<code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
</li>
<li>
<span className="font-medium text-foreground">Ошибки задач.</span> Откройте Операции и
проверьте последние неуспешные задачи.
</li>
</ul>
</PanelCard>
</div>
</TabsContent>
@@ -282,7 +284,7 @@ function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
? Math.round((bird.bgp_established / bird.bgp_sessions_total) * 100)
: null
return (
<div className="space-y-2">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Установлено / всего</span>
<span className="font-medium tabular-nums">
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -28,7 +28,7 @@ Settings: секции через Frame + `gap` (без hairline `Separator` п
| Lists | `data-grid-filtering-2` | https://reui.io/preview/base/data-grid-filtering-2 |
| Settings | `settings-16` + SettingRow | https://reui.io/preview/base/settings-16 |
| Auth | `auth-13` | https://reui.io/preview/base/auth-13 |
| Empty | `empty-state-12` | https://reui.io/preview/base/empty-state-12 |
| Empty | `empty-state-14` (prefer) / `empty-state-12` | https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12 |
| Forms | `form-7` → Sheet/Drawer | https://reui.io/preview/base/form-7 |
## Kit API (`reui-kit/`)
@@ -75,9 +75,9 @@ Gating: DB `show_quick_actions` / `showQuickActions` / `ui_show_quick_actions` (
NavUser (footer): avatar + name/email; dropdown — Настройки / Тема (segmented) / Выйти. Preview: [app-shell-1](https://reui.io/preview/base/app-shell-1).
App Switcher: auth-portal `GET /api/v1/app-switcher`. Ids: `cfdm` · `vps` · `bgp` · `fw`. Admin: portal `/admin/apps`.
App Switcher: auth-portal `GET /api/v1/app-switcher`. Ids: `cfdm` · `vps` · `bgp` · `fw` · `dns`. Admin: portal `/admin/apps`.
QuickActionGrid / KPI icons: только semantic **text** (`text-info` / `text-primary` / …) на kit `bg-muted` — без solid fills.
QuickActionGrid / KPI icons: только semantic **text** (`text-info` / `text-primary` / …) на `IconTile` `elevated` — без solid fills.
## System monitor
+2 -3
View File
@@ -70,9 +70,8 @@ function SheetContent({
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
<XIcon aria-hidden="true" />
<span className="sr-only">Закрыть</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
+10 -7
View File
@@ -194,8 +194,8 @@ function Sidebar({
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
<SheetTitle>Боковое меню</SheetTitle>
<SheetDescription>Навигация по приложению</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
@@ -254,7 +254,7 @@ function SidebarTrigger({
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
const { toggleSidebar, open, openMobile, isMobile } = useSidebar()
return (
<Button
@@ -262,6 +262,9 @@ function SidebarTrigger({
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
type="button"
aria-label="Боковое меню"
aria-expanded={isMobile ? openMobile : open}
className={cn(className)}
onClick={(event) => {
onClick?.(event)
@@ -269,8 +272,8 @@ function SidebarTrigger({
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
<PanelLeftIcon aria-hidden="true" />
<span className="sr-only">Боковое меню</span>
</Button>
)
}
@@ -282,10 +285,10 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
aria-label="Боковое меню"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
title="Боковое меню"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",