feat: refactor components to enhance UI consistency and functionality
CI / changes (push) Successful in 12s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m6s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m27s
CI / changes (push) Successful in 12s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m6s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m27s
Updated multiple components to improve user interface consistency by replacing traditional badge implementations with the new CategoryBadge and DataGridPrimaryCell components. Enhanced the StatusBadge component to support additional status variants and integrated it across various grids, including AccessApiKeysGrid, DashboardRecentJobsGrid, and OperationsJobsGrid. This refactor streamlines the presentation of data and improves the overall user experience across the application.
This commit is contained in:
@@ -4,8 +4,9 @@ import { useMemo } from 'react'
|
|||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
|
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
import { DataGridSection } from '@/components/data-grid-shell'
|
||||||
import { Badge } from '@/components/reui/badge'
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
@@ -33,16 +34,14 @@ export function AccessApiKeysGrid({
|
|||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||||
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
cell: ({ row }) => <DataGridPrimaryCell title={row.original.name} accent="primary" />,
|
||||||
meta: { headerTitle: 'Имя' },
|
meta: { headerTitle: 'Имя' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'role',
|
accessorKey: 'role',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge variant="outline" className="font-mono text-xs">
|
<CategoryBadge className="font-mono text-xs">{row.original.role}</CategoryBadge>
|
||||||
{row.original.role}
|
|
||||||
</Badge>
|
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Роль' },
|
meta: { headerTitle: 'Роль' },
|
||||||
},
|
},
|
||||||
@@ -71,9 +70,7 @@ export function AccessApiKeysGrid({
|
|||||||
accessorFn: (row) => row.expires_at ?? '',
|
accessorFn: (row) => row.expires_at ?? '',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Истекает" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Истекает" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-sm text-muted-foreground">
|
<DataGridMutedCell>{formatApiKeyDate(row.original.expires_at)}</DataGridMutedCell>
|
||||||
{formatApiKeyDate(row.original.expires_at)}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Истекает' },
|
meta: { headerTitle: 'Истекает' },
|
||||||
},
|
},
|
||||||
@@ -82,9 +79,7 @@ export function AccessApiKeysGrid({
|
|||||||
accessorFn: (row) => row.last_used_at ?? '',
|
accessorFn: (row) => row.last_used_at ?? '',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Последнее использование" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Последнее использование" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-sm text-muted-foreground">
|
<DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell>
|
||||||
{formatApiKeyDate(row.original.last_used_at)}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Последнее использование' },
|
meta: { headerTitle: 'Последнее использование' },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { ComponentProps, ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
|
||||||
|
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
||||||
|
|
||||||
|
const TONE_VARIANT: Record<string, BadgeVariant> = {
|
||||||
|
neutral: 'outline',
|
||||||
|
info: 'info-light',
|
||||||
|
warning: 'warning-light',
|
||||||
|
success: 'success-light',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModeBadge({
|
||||||
|
enabled,
|
||||||
|
onLabel = 'включён',
|
||||||
|
offLabel = 'выключен',
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
enabled: boolean
|
||||||
|
onLabel?: string
|
||||||
|
offLabel?: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return enabled ? (
|
||||||
|
<Badge variant="success-light" size="sm" radius="full" className={className}>
|
||||||
|
{onLabel}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" size="sm" radius="full" className={className}>
|
||||||
|
{offLabel}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryBadge({
|
||||||
|
children,
|
||||||
|
tone = 'neutral',
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
tone?: keyof typeof TONE_VARIANT
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Badge variant={TONE_VARIANT[tone]} size="sm" radius="full" className={className}>
|
||||||
|
{children}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,22 +1,14 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { ColumnDef } from '@tanstack/react-table'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||||
import type { JobRow } from '@/types/api'
|
import type { JobRow } from '@/types/api'
|
||||||
|
|
||||||
function StatusText({ status }: { status: string }) {
|
|
||||||
const cls =
|
|
||||||
status === 'succeeded'
|
|
||||||
? 'text-success'
|
|
||||||
: status === 'failed' || status === 'cancelled'
|
|
||||||
? 'text-destructive'
|
|
||||||
: 'text-muted-foreground'
|
|
||||||
return <span className={`text-xs font-medium ${cls}`}>{status}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardRecentJobsGrid({
|
export function DashboardRecentJobsGrid({
|
||||||
jobs,
|
jobs,
|
||||||
nameById,
|
nameById,
|
||||||
@@ -34,21 +26,22 @@ export function DashboardRecentJobsGrid({
|
|||||||
accessorKey: 'kind',
|
accessorKey: 'kind',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="min-w-0">
|
<DataGridPrimaryCell
|
||||||
<div className="truncate font-mono text-xs text-muted-foreground">{row.original.kind}</div>
|
title={row.original.kind}
|
||||||
{row.original.meta?.module_id ? (
|
accent="mono"
|
||||||
<div className="truncate text-xs">
|
subtitle={
|
||||||
{nameById.get(String(row.original.meta.module_id)) ?? ''}
|
row.original.meta?.module_id
|
||||||
</div>
|
? (nameById.get(String(row.original.meta.module_id)) ?? undefined)
|
||||||
) : null}
|
: undefined
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Вид' },
|
meta: { headerTitle: 'Вид' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||||
cell: ({ row }) => <StatusText status={row.original.status} />,
|
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||||
meta: { headerTitle: 'Статус' },
|
meta: { headerTitle: 'Статус' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { ColumnDef } from '@tanstack/react-table'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
import { DataGridSection } from '@/components/data-grid-shell'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||||
@@ -23,9 +24,7 @@ export function DashboardRecentRevisionsGrid({
|
|||||||
accessorFn: (row) => row.id,
|
accessorFn: (row) => row.id,
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="truncate font-mono text-xs text-muted-foreground">
|
<DataGridPrimaryCell title={`${row.original.id.slice(0, 10)}…`} accent="mono" />
|
||||||
{row.original.id.slice(0, 10)}…
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'ID' },
|
meta: { headerTitle: 'ID' },
|
||||||
},
|
},
|
||||||
@@ -33,9 +32,9 @@ export function DashboardRecentRevisionsGrid({
|
|||||||
accessorKey: 'created_at',
|
accessorKey: 'created_at',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-xs text-muted-foreground">
|
<DataGridMutedCell>
|
||||||
{new Date(row.original.created_at).toLocaleString('ru-RU')}
|
{new Date(row.original.created_at).toLocaleString('ru-RU')}
|
||||||
</span>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Создана' },
|
meta: { headerTitle: 'Создана' },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
|
const ACCENT_CLASS = {
|
||||||
|
primary: 'font-medium text-primary',
|
||||||
|
default: 'font-medium text-foreground',
|
||||||
|
mono: 'font-mono text-sm text-primary',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export function DataGridPrimaryCell({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
accent = 'default',
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
title: ReactNode
|
||||||
|
subtitle?: ReactNode
|
||||||
|
accent?: keyof typeof ACCENT_CLASS
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex min-w-0 flex-col gap-0.5', className)}>
|
||||||
|
<span className={cn('truncate', ACCENT_CLASS[accent])}>{title}</span>
|
||||||
|
{subtitle ? (
|
||||||
|
<span className="truncate text-xs text-muted-foreground">{subtitle}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataGridMutedCell({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className={cn('whitespace-nowrap text-xs text-muted-foreground', className)}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { ColumnDef } from '@tanstack/react-table'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
import { Badge } from '@evobgp/ui/components/badge'
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
import { DataGridSection } from '@/components/data-grid-shell'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||||
@@ -20,20 +20,22 @@ export function DirectoriesCommunitiesGrid({
|
|||||||
{
|
{
|
||||||
accessorKey: 'title',
|
accessorKey: 'title',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
||||||
cell: ({ row }) => <span className="font-medium">{row.original.title}</span>,
|
cell: ({ row }) => <DataGridPrimaryCell title={row.original.title} accent="primary" />,
|
||||||
meta: { headerTitle: 'Название' },
|
meta: { headerTitle: 'Название' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'community',
|
accessorKey: 'community',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />,
|
||||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.community}</span>,
|
cell: ({ row }) => (
|
||||||
|
<DataGridPrimaryCell title={row.original.community} accent="mono" />
|
||||||
|
),
|
||||||
meta: { headerTitle: 'Значение' },
|
meta: { headerTitle: 'Значение' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'type',
|
id: 'type',
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: 'Тип',
|
header: 'Тип',
|
||||||
cell: () => <Badge variant="outline">community</Badge>,
|
cell: () => <CategoryBadge>community</CategoryBadge>,
|
||||||
meta: { headerTitle: 'Тип' },
|
meta: { headerTitle: 'Тип' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { ColumnDef } from '@tanstack/react-table'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
import { Badge } from '@evobgp/ui/components/badge'
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
import { DataGridSection } from '@/components/data-grid-shell'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||||
@@ -21,20 +21,26 @@ export function DirectoriesDohGrid({
|
|||||||
id: 'name',
|
id: 'name',
|
||||||
accessorFn: (row) => row.name ?? row.url,
|
accessorFn: (row) => row.name ?? row.url,
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
||||||
cell: ({ row }) => <span className="font-medium">{row.original.name ?? row.original.url}</span>,
|
cell: ({ row }) => (
|
||||||
|
<DataGridPrimaryCell
|
||||||
|
title={row.original.name ?? row.original.url}
|
||||||
|
subtitle={row.original.name ? row.original.url : undefined}
|
||||||
|
accent="primary"
|
||||||
|
/>
|
||||||
|
),
|
||||||
meta: { headerTitle: 'Название' },
|
meta: { headerTitle: 'Название' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'url',
|
accessorKey: 'url',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="URL" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="URL" />,
|
||||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.url}</span>,
|
cell: ({ row }) => <DataGridPrimaryCell title={row.original.url} accent="mono" />,
|
||||||
meta: { headerTitle: 'URL' },
|
meta: { headerTitle: 'URL' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'default',
|
id: 'default',
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: 'По умолчанию',
|
header: 'По умолчанию',
|
||||||
cell: () => <Badge variant="outline">—</Badge>,
|
cell: () => <CategoryBadge>—</CategoryBadge>,
|
||||||
meta: { headerTitle: 'По умолчанию' },
|
meta: { headerTitle: 'По умолчанию' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useMemo } from 'react'
|
|||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
|
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
import { DataGridSection } from '@/components/data-grid-shell'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
@@ -42,12 +43,11 @@ export function FirewallClientsGrid({
|
|||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div>
|
<DataGridPrimaryCell
|
||||||
<div className="font-medium">{row.original.name}</div>
|
title={row.original.name}
|
||||||
<div className="text-muted-foreground text-xs">
|
subtitle={row.original.hostname || row.original.token_prefix}
|
||||||
{row.original.hostname || row.original.token_prefix}
|
accent="primary"
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Имя' },
|
meta: { headerTitle: 'Имя' },
|
||||||
},
|
},
|
||||||
@@ -62,7 +62,7 @@ export function FirewallClientsGrid({
|
|||||||
accessorFn: (row) => row.last_seen_at ?? '',
|
accessorFn: (row) => row.last_seen_at ?? '',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Last seen" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Last seen" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-xs">{row.original.last_seen_at?.slice(0, 19) ?? '—'}</span>
|
<DataGridMutedCell>{row.original.last_seen_at?.slice(0, 19) ?? '—'}</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
sortingFn: (a, b) => {
|
sortingFn: (a, b) => {
|
||||||
const av = a.original.last_seen_at ?? ''
|
const av = a.original.last_seen_at ?? ''
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useMemo } from 'react'
|
|||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
|
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
import { DataGridSection } from '@/components/data-grid-shell'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { formatDateTime } from '@/lib/modules/display'
|
import { formatDateTime } from '@/lib/modules/display'
|
||||||
@@ -68,7 +70,7 @@ export function ModuleEntriesGrid({
|
|||||||
<DataGridColumnHeader column={column as never} title="FQDN" />
|
<DataGridColumnHeader column={column as never} title="FQDN" />
|
||||||
),
|
),
|
||||||
cell: ({ row }: { row: { original: DomainEntry } }) => (
|
cell: ({ row }: { row: { original: DomainEntry } }) => (
|
||||||
<span className="font-mono text-sm">{row.original.fqdn}</span>
|
<DataGridPrimaryCell title={row.original.fqdn} accent="mono" />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -102,7 +104,7 @@ export function ModuleEntriesGrid({
|
|||||||
<DataGridColumnHeader column={column as never} title="Префикс (CIDR)" />
|
<DataGridColumnHeader column={column as never} title="Префикс (CIDR)" />
|
||||||
),
|
),
|
||||||
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
|
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
|
||||||
<span className="font-mono text-sm">{row.original.prefix}</span>
|
<DataGridPrimaryCell title={row.original.prefix} accent="mono" />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -136,14 +138,14 @@ export function ModuleEntriesGrid({
|
|||||||
<DataGridColumnHeader column={column as never} title="URL" />
|
<DataGridColumnHeader column={column as never} title="URL" />
|
||||||
),
|
),
|
||||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
cell: ({ row }: { row: { original: CdnSource } }) => (
|
||||||
<span className="max-w-xs truncate font-mono text-xs">{row.original.url}</span>
|
<DataGridPrimaryCell title={row.original.url} accent="mono" className="max-w-xs" />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'source_kind',
|
accessorKey: 'source_kind',
|
||||||
header: 'Тип',
|
header: 'Тип',
|
||||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
cell: ({ row }: { row: { original: CdnSource } }) => (
|
||||||
<span className="text-sm">{row.original.source_kind}</span>
|
<CategoryBadge>{row.original.source_kind}</CategoryBadge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -162,9 +164,7 @@ export function ModuleEntriesGrid({
|
|||||||
<DataGridColumnHeader column={column as never} title="Обновлено" />
|
<DataGridColumnHeader column={column as never} title="Обновлено" />
|
||||||
),
|
),
|
||||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
cell: ({ row }: { row: { original: CdnSource } }) => (
|
||||||
<span className="text-xs text-muted-foreground">
|
<DataGridMutedCell>{formatDateTime(row.original.last_refreshed_at)}</DataGridMutedCell>
|
||||||
{formatDateTime(row.original.last_refreshed_at)}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { useNavigate } from '@tanstack/react-router'
|
|||||||
import { Boxes } from 'lucide-react'
|
import { Boxes } from 'lucide-react'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
import { CategoryBadge, ModeBadge } from '@/components/category-badge'
|
||||||
|
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
import { DataGridSection } from '@/components/data-grid-shell'
|
||||||
import { Badge } from '@/components/reui/badge'
|
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { TruncatedText } from '@/components/truncated-text'
|
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||||
import type { ModuleRow } from '@/types/api'
|
import type { ModuleRow } from '@/types/api'
|
||||||
|
|
||||||
@@ -26,8 +26,8 @@ export function ModulesListGrid({
|
|||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Boxes className="size-4 text-muted-foreground" />
|
<Boxes className="size-4 shrink-0 text-muted-foreground" />
|
||||||
<TruncatedText className="max-w-[280px] font-medium">{row.original.name}</TruncatedText>
|
<DataGridPrimaryCell title={row.original.name} accent="primary" className="max-w-[280px]" />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Название' },
|
meta: { headerTitle: 'Название' },
|
||||||
@@ -35,7 +35,7 @@ export function ModulesListGrid({
|
|||||||
{
|
{
|
||||||
accessorKey: 'type',
|
accessorKey: 'type',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
|
||||||
cell: ({ row }) => <Badge variant="outline">{row.original.type}</Badge>,
|
cell: ({ row }) => <CategoryBadge>{row.original.type}</CategoryBadge>,
|
||||||
meta: { headerTitle: 'Тип' },
|
meta: { headerTitle: 'Тип' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -50,12 +50,7 @@ export function ModulesListGrid({
|
|||||||
id: 'enabled',
|
id: 'enabled',
|
||||||
accessorFn: (row) => (row.enabled ? 'enabled' : 'disabled'),
|
accessorFn: (row) => (row.enabled ? 'enabled' : 'disabled'),
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) => <ModeBadge enabled={row.original.enabled} />,
|
||||||
row.original.enabled ? (
|
|
||||||
<Badge variant="success">включён</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="secondary">выключен</Badge>
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'Состояние' },
|
meta: { headerTitle: 'Состояние' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -63,11 +58,11 @@ export function ModulesListGrid({
|
|||||||
accessorFn: (row) => row.last_refreshed_at ?? '',
|
accessorFn: (row) => row.last_refreshed_at ?? '',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-xs text-muted-foreground">
|
<DataGridMutedCell>
|
||||||
{row.original.last_refreshed_at
|
{row.original.last_refreshed_at
|
||||||
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
|
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
|
||||||
: '—'}
|
: '—'}
|
||||||
</span>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
sortingFn: (a, b) => {
|
sortingFn: (a, b) => {
|
||||||
const av = a.original.last_refreshed_at ?? ''
|
const av = a.original.last_refreshed_at ?? ''
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { ColumnDef } from '@tanstack/react-table'
|
|||||||
import { Database, HardDrive, HeartPulse, ListTodo, ShieldCheck } from 'lucide-react'
|
import { Database, HardDrive, HeartPulse, ListTodo, ShieldCheck } from 'lucide-react'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
import { Badge } from '@evobgp/ui/components/badge'
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
|
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||||
import type { ReadyStatus } from '@/queries/monitoring'
|
import type { ReadyStatus } from '@/queries/monitoring'
|
||||||
@@ -20,9 +20,8 @@ interface ReadyCheckRow {
|
|||||||
label: string
|
label: string
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
icon: typeof Database
|
icon: typeof Database
|
||||||
ok: boolean
|
status: string
|
||||||
statusLabel: string
|
statusLabel: string
|
||||||
variant: 'default' | 'destructive' | 'secondary'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MonitoringReadyGrid({
|
export function MonitoringReadyGrid({
|
||||||
@@ -40,18 +39,16 @@ export function MonitoringReadyGrid({
|
|||||||
label: 'Liveness',
|
label: 'Liveness',
|
||||||
subtitle: '/v1/health',
|
subtitle: '/v1/health',
|
||||||
icon: HeartPulse,
|
icon: HeartPulse,
|
||||||
ok: health?.ok === true,
|
status: health?.ok ? 'ok' : 'error',
|
||||||
statusLabel: health?.ok ? 'OK' : 'Ошибка',
|
statusLabel: health?.ok ? 'OK' : 'Ошибка',
|
||||||
variant: health?.ok ? 'default' : 'destructive',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'readiness',
|
id: 'readiness',
|
||||||
label: 'Readiness',
|
label: 'Readiness',
|
||||||
subtitle: '/v1/ready',
|
subtitle: '/v1/ready',
|
||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
ok: ready.status === 'ok',
|
status: ready.status === 'ok' ? 'ok' : 'warning',
|
||||||
statusLabel: ready.status ?? '—',
|
statusLabel: ready.status ?? '—',
|
||||||
variant: ready.status === 'ok' ? 'default' : 'secondary',
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
for (const key of Object.keys(checks)) {
|
for (const key of Object.keys(checks)) {
|
||||||
@@ -61,9 +58,8 @@ export function MonitoringReadyGrid({
|
|||||||
id: key,
|
id: key,
|
||||||
label: key,
|
label: key,
|
||||||
icon: READY_CHECK_ICONS[key] ?? ListTodo,
|
icon: READY_CHECK_ICONS[key] ?? ListTodo,
|
||||||
ok,
|
status: ok ? 'ok' : 'error',
|
||||||
statusLabel: ok ? 'OK' : 'Ошибка',
|
statusLabel: ok ? 'OK' : 'Ошибка',
|
||||||
variant: ok ? 'default' : 'destructive',
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return rows
|
return rows
|
||||||
@@ -79,12 +75,10 @@ export function MonitoringReadyGrid({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
<div>
|
<DataGridPrimaryCell
|
||||||
<p className="text-sm font-medium">{row.original.label}</p>
|
title={row.original.label}
|
||||||
{row.original.subtitle ? (
|
subtitle={row.original.subtitle}
|
||||||
<p className="text-xs text-muted-foreground">{row.original.subtitle}</p>
|
/>
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -95,7 +89,7 @@ export function MonitoringReadyGrid({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: 'Статус',
|
header: 'Статус',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge variant={row.original.variant}>{row.original.statusLabel}</Badge>
|
<StatusBadge status={row.original.status} label={row.original.statusLabel} />
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Статус' },
|
meta: { headerTitle: 'Статус' },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { ColumnDef } from '@tanstack/react-table'
|
||||||
import { useMemo } from 'react'
|
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 { DataGridSection } from '@/components/data-grid-shell'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||||
import type { PeerRow } from '@/types/api'
|
import type { PeerRow } from '@/types/api'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
|
||||||
|
|
||||||
export function NetworkPeersGrid({
|
export function NetworkPeersGrid({
|
||||||
items,
|
items,
|
||||||
@@ -22,14 +23,20 @@ export function NetworkPeersGrid({
|
|||||||
accessorFn: (row) => row.name ?? row.neighbor,
|
accessorFn: (row) => row.name ?? row.neighbor,
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-medium">{row.original.name ?? row.original.neighbor}</span>
|
<DataGridPrimaryCell
|
||||||
|
title={row.original.name ?? row.original.neighbor}
|
||||||
|
subtitle={row.original.name ? row.original.neighbor : undefined}
|
||||||
|
accent="primary"
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Имя' },
|
meta: { headerTitle: 'Имя' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'neighbor',
|
accessorKey: 'neighbor',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Neighbor" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Neighbor" />,
|
||||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.neighbor}</span>,
|
cell: ({ row }) => (
|
||||||
|
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
|
||||||
|
),
|
||||||
meta: { headerTitle: 'Neighbor' },
|
meta: { headerTitle: 'Neighbor' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -47,9 +54,7 @@ export function NetworkPeersGrid({
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<StatusBadge status={row.original.session_state} />
|
<StatusBadge status={row.original.session_state} />
|
||||||
{row.original.session_mismatch ? (
|
{row.original.session_mismatch ? (
|
||||||
<Badge variant="warning" className="ml-1">
|
<CategoryBadge tone="warning">mismatch</CategoryBadge>
|
||||||
mismatch
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { ColumnDef } from '@tanstack/react-table'
|
||||||
import { useMemo } from 'react'
|
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 { DataGridSection } from '@/components/data-grid-shell'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
@@ -20,13 +22,15 @@ export function NetworkSpeakersGrid({
|
|||||||
{
|
{
|
||||||
accessorKey: 'endpoint',
|
accessorKey: 'endpoint',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Endpoint" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Endpoint" />,
|
||||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.endpoint}</span>,
|
cell: ({ row }) => (
|
||||||
|
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
|
||||||
|
),
|
||||||
meta: { headerTitle: 'Endpoint' },
|
meta: { headerTitle: 'Endpoint' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'role',
|
accessorKey: 'role',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
||||||
cell: ({ row }) => <Badge variant="outline">{row.original.role}</Badge>,
|
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
|
||||||
meta: { headerTitle: 'Роль' },
|
meta: { headerTitle: 'Роль' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -37,7 +41,7 @@ export function NetworkSpeakersGrid({
|
|||||||
const live = row.original.live
|
const live = row.original.live
|
||||||
if (live?.agent_ok === true) return <StatusBadge status="ok" label="online" />
|
if (live?.agent_ok === true) return <StatusBadge status="ok" label="online" />
|
||||||
if (live?.agent_ok === false) return <StatusBadge status="error" label="offline" />
|
if (live?.agent_ok === false) return <StatusBadge status="error" label="offline" />
|
||||||
return <Badge variant="outline">—</Badge>
|
return <Badge variant="outline" size="sm" radius="full">—</Badge>
|
||||||
},
|
},
|
||||||
meta: { headerTitle: 'Agent' },
|
meta: { headerTitle: 'Agent' },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,23 +5,15 @@ import { toast } from 'sonner'
|
|||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
|
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||||
import { apiMutate } from '@/lib/api-client'
|
import { apiMutate } from '@/lib/api-client'
|
||||||
import type { JobRow } from '@/types/api'
|
import type { JobRow } from '@/types/api'
|
||||||
import type { QueryClient } from '@tanstack/react-query'
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
function StatusBadgeColored({ status }: { status: string }) {
|
|
||||||
const cls =
|
|
||||||
status === 'succeeded'
|
|
||||||
? 'text-success'
|
|
||||||
: status === 'failed' || status === 'cancelled'
|
|
||||||
? 'text-destructive'
|
|
||||||
: 'text-info'
|
|
||||||
return <span className={`text-sm font-medium ${cls}`}>{status}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OperationsJobsGrid({
|
export function OperationsJobsGrid({
|
||||||
items,
|
items,
|
||||||
nameById,
|
nameById,
|
||||||
@@ -48,22 +40,29 @@ export function OperationsJobsGrid({
|
|||||||
accessorKey: 'kind',
|
accessorKey: 'kind',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex flex-col gap-0.5">
|
<DataGridPrimaryCell
|
||||||
<span className="font-medium">{row.original.kind}</span>
|
title={row.original.kind}
|
||||||
{row.original.meta?.module_id ? (
|
accent="mono"
|
||||||
<span className="text-xs text-muted-foreground">
|
subtitle={
|
||||||
{nameById.get(String(row.original.meta.module_id)) ??
|
row.original.meta?.module_id
|
||||||
String(row.original.meta.module_id)}
|
? (nameById.get(String(row.original.meta.module_id)) ??
|
||||||
</span>
|
String(row.original.meta.module_id))
|
||||||
) : null}
|
: undefined
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Вид' },
|
meta: { headerTitle: 'Вид' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||||
cell: ({ row }) => <StatusBadgeColored status={row.original.status} />,
|
cell: ({ row }) => {
|
||||||
|
const finished = row.original.finished_at
|
||||||
|
const hint = finished
|
||||||
|
? new Date(finished).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
: undefined
|
||||||
|
return <StatusBadge status={row.original.status} hint={hint} />
|
||||||
|
},
|
||||||
meta: { headerTitle: 'Статус' },
|
meta: { headerTitle: 'Статус' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -71,11 +70,11 @@ export function OperationsJobsGrid({
|
|||||||
accessorFn: (row) => row.created_at ?? '',
|
accessorFn: (row) => row.created_at ?? '',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
<DataGridMutedCell>
|
||||||
{row.original.created_at
|
{row.original.created_at
|
||||||
? new Date(row.original.created_at).toLocaleString('ru-RU')
|
? new Date(row.original.created_at).toLocaleString('ru-RU')
|
||||||
: '—'}
|
: '—'}
|
||||||
</span>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Создана' },
|
meta: { headerTitle: 'Создана' },
|
||||||
},
|
},
|
||||||
@@ -84,11 +83,11 @@ export function OperationsJobsGrid({
|
|||||||
accessorFn: (row) => row.finished_at ?? '',
|
accessorFn: (row) => row.finished_at ?? '',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Завершена" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Завершена" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
<DataGridMutedCell>
|
||||||
{row.original.finished_at
|
{row.original.finished_at
|
||||||
? new Date(row.original.finished_at).toLocaleString('ru-RU')
|
? new Date(row.original.finished_at).toLocaleString('ru-RU')
|
||||||
: '—'}
|
: '—'}
|
||||||
</span>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Завершена' },
|
meta: { headerTitle: 'Завершена' },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { toast } from 'sonner'
|
|||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
|
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
import { DataGridSection } from '@/components/data-grid-shell'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
@@ -40,7 +41,7 @@ export function OperationsRevisionsGrid({
|
|||||||
accessorFn: (row) => row.id,
|
accessorFn: (row) => row.id,
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-mono text-xs">{row.original.id.slice(0, 12)}…</span>
|
<DataGridPrimaryCell title={`${row.original.id.slice(0, 12)}…`} accent="mono" />
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'ID' },
|
meta: { headerTitle: 'ID' },
|
||||||
},
|
},
|
||||||
@@ -48,9 +49,9 @@ export function OperationsRevisionsGrid({
|
|||||||
accessorKey: 'created_at',
|
accessorKey: 'created_at',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-xs text-muted-foreground">
|
<DataGridMutedCell>
|
||||||
{new Date(row.original.created_at).toLocaleString('ru-RU')}
|
{new Date(row.original.created_at).toLocaleString('ru-RU')}
|
||||||
</span>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Создана' },
|
meta: { headerTitle: 'Создана' },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { ColumnDef } from '@tanstack/react-table'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
import { Badge } from '@evobgp/ui/components/badge'
|
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
|
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||||
import type { JobRow } from '@/types/api'
|
import type { JobRow } from '@/types/api'
|
||||||
@@ -20,25 +20,19 @@ export function ScheduleJobsGrid({
|
|||||||
{
|
{
|
||||||
accessorKey: 'kind',
|
accessorKey: 'kind',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
||||||
cell: ({ row }) => <span className="font-medium">{row.original.kind}</span>,
|
cell: ({ row }) => <DataGridPrimaryCell title={row.original.kind} accent="mono" />,
|
||||||
meta: { headerTitle: 'Вид' },
|
meta: { headerTitle: 'Вид' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<Badge
|
const finished = row.original.finished_at
|
||||||
variant={
|
const hint = finished
|
||||||
row.original.status === 'succeeded'
|
? new Date(finished).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||||
? 'default'
|
: undefined
|
||||||
: row.original.status === 'failed'
|
return <StatusBadge status={row.original.status} hint={hint} />
|
||||||
? 'destructive'
|
},
|
||||||
: 'secondary'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{row.original.status}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'Статус' },
|
meta: { headerTitle: 'Статус' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -46,11 +40,11 @@ export function ScheduleJobsGrid({
|
|||||||
accessorFn: (row) => row.created_at ?? '',
|
accessorFn: (row) => row.created_at ?? '',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
<DataGridMutedCell>
|
||||||
{row.original.created_at
|
{row.original.created_at
|
||||||
? new Date(row.original.created_at).toLocaleString('ru-RU')
|
? new Date(row.original.created_at).toLocaleString('ru-RU')
|
||||||
: '—'}
|
: '—'}
|
||||||
</span>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Создана' },
|
meta: { headerTitle: 'Создана' },
|
||||||
},
|
},
|
||||||
@@ -59,11 +53,11 @@ export function ScheduleJobsGrid({
|
|||||||
accessorFn: (row) => row.finished_at ?? '',
|
accessorFn: (row) => row.finished_at ?? '',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Завершена" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Завершена" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
<DataGridMutedCell>
|
||||||
{row.original.finished_at
|
{row.original.finished_at
|
||||||
? new Date(row.original.finished_at).toLocaleString('ru-RU')
|
? new Date(row.original.finished_at).toLocaleString('ru-RU')
|
||||||
: '—'}
|
: '—'}
|
||||||
</span>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Завершена' },
|
meta: { headerTitle: 'Завершена' },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { ColumnDef } from '@tanstack/react-table'
|
|||||||
import { RefreshCw } from 'lucide-react'
|
import { RefreshCw } from 'lucide-react'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
import { Badge } from '@evobgp/ui/components/badge'
|
import { CategoryBadge, ModeBadge } from '@/components/category-badge'
|
||||||
|
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
import { DataGridSection } from '@/components/data-grid-shell'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
@@ -26,13 +26,13 @@ export function ScheduleModulesGrid({
|
|||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
|
||||||
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
cell: ({ row }) => <DataGridPrimaryCell title={row.original.name} accent="primary" />,
|
||||||
meta: { headerTitle: 'Модуль' },
|
meta: { headerTitle: 'Модуль' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'type',
|
accessorKey: 'type',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
|
||||||
cell: ({ row }) => <Badge variant="outline">{row.original.type}</Badge>,
|
cell: ({ row }) => <CategoryBadge>{row.original.type}</CategoryBadge>,
|
||||||
meta: { headerTitle: 'Тип' },
|
meta: { headerTitle: 'Тип' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -52,11 +52,11 @@ export function ScheduleModulesGrid({
|
|||||||
accessorFn: (row) => row.last_refreshed_at ?? '',
|
accessorFn: (row) => row.last_refreshed_at ?? '',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
<DataGridMutedCell>
|
||||||
{row.original.last_refreshed_at
|
{row.original.last_refreshed_at
|
||||||
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
|
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
|
||||||
: '—'}
|
: '—'}
|
||||||
</span>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Обновлено' },
|
meta: { headerTitle: 'Обновлено' },
|
||||||
},
|
},
|
||||||
@@ -64,12 +64,9 @@ export function ScheduleModulesGrid({
|
|||||||
id: 'enabled',
|
id: 'enabled',
|
||||||
accessorFn: (row) => (row.enabled ? 'on' : 'off'),
|
accessorFn: (row) => (row.enabled ? 'on' : 'off'),
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) => (
|
||||||
row.original.enabled ? (
|
<ModeBadge enabled={row.original.enabled} onLabel="Вкл" offLabel="Выкл" />
|
||||||
<Badge variant="default">Вкл</Badge>
|
),
|
||||||
) : (
|
|
||||||
<Badge variant="secondary">Выкл</Badge>
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'Статус' },
|
meta: { headerTitle: 'Статус' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { ColumnDef } from '@tanstack/react-table'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
import { DataGridSection } from '@/components/data-grid-shell'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||||
@@ -23,13 +24,13 @@ export function SettingsKvGrid({
|
|||||||
{
|
{
|
||||||
accessorKey: 'key',
|
accessorKey: 'key',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Ключ" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Ключ" />,
|
||||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.key}</span>,
|
cell: ({ row }) => <DataGridPrimaryCell title={row.original.key} accent="mono" />,
|
||||||
meta: { headerTitle: 'Ключ' },
|
meta: { headerTitle: 'Ключ' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'value',
|
accessorKey: 'value',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />,
|
||||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.value}</span>,
|
cell: ({ row }) => <DataGridPrimaryCell title={row.original.value} accent="mono" />,
|
||||||
meta: { headerTitle: 'Значение' },
|
meta: { headerTitle: 'Значение' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,35 +1,71 @@
|
|||||||
import type { ComponentProps } from 'react'
|
import type { ComponentProps } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
|
||||||
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
||||||
|
|
||||||
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||||
active: 'success',
|
active: 'success-light',
|
||||||
ok: 'success',
|
ok: 'success-light',
|
||||||
paid: 'success',
|
paid: 'success-light',
|
||||||
established: 'success',
|
established: 'success-light',
|
||||||
succeeded: 'success',
|
succeeded: 'success-light',
|
||||||
healthy: 'success',
|
healthy: 'success-light',
|
||||||
paused: 'secondary',
|
approved: 'success-light',
|
||||||
disabled: 'secondary',
|
accept: 'success-light',
|
||||||
|
paused: 'invert-light',
|
||||||
|
disabled: 'invert-light',
|
||||||
archived: 'outline',
|
archived: 'outline',
|
||||||
error: 'destructive',
|
error: 'destructive-light',
|
||||||
failed: 'destructive',
|
failed: 'destructive-light',
|
||||||
running: 'info',
|
revoked: 'destructive-light',
|
||||||
queued: 'info',
|
block: 'destructive-light',
|
||||||
overdue: 'warning',
|
cancelled: 'destructive-light',
|
||||||
stale: 'warning',
|
running: 'info-light',
|
||||||
warning: 'warning',
|
queued: 'info-light',
|
||||||
mismatch: 'warning',
|
overdue: 'warning-light',
|
||||||
pending: 'warning',
|
stale: 'warning-light',
|
||||||
approved: 'success',
|
warning: 'warning-light',
|
||||||
revoked: 'destructive',
|
mismatch: 'warning-light',
|
||||||
block: 'destructive',
|
pending: 'warning-light',
|
||||||
accept: 'success',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusBadge({ status, label }: { status: string; label?: string }) {
|
const DOT_COLOR: Record<string, string> = {
|
||||||
const variant = STATUS_VARIANT[status.toLowerCase()] ?? 'outline'
|
'success-light': 'bg-success',
|
||||||
return <Badge variant={variant}>{label ?? status}</Badge>
|
'info-light': 'bg-info',
|
||||||
|
'warning-light': 'bg-warning',
|
||||||
|
'destructive-light': 'bg-destructive',
|
||||||
|
'invert-light': 'bg-muted-foreground',
|
||||||
|
outline: 'bg-muted-foreground',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jobStatusVariant(status: string): BadgeVariant {
|
||||||
|
return STATUS_VARIANT[status.toLowerCase()] ?? 'outline'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({
|
||||||
|
status,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
status: string
|
||||||
|
label?: string
|
||||||
|
hint?: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const variant = jobStatusVariant(status)
|
||||||
|
const dotColor = DOT_COLOR[variant] ?? 'bg-muted-foreground'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col gap-0.5', className)}>
|
||||||
|
<Badge variant={variant} size="sm" radius="full" className="gap-1.5">
|
||||||
|
<span className={cn('size-1.5 shrink-0 rounded-full', dotColor)} aria-hidden />
|
||||||
|
{label ?? status}
|
||||||
|
</Badge>
|
||||||
|
{hint ? <span className="text-xs text-muted-foreground">{hint}</span> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { Activity, AlertTriangle, Bird, Database, HeartPulse, Info, ListTodo, RefreshCw } from 'lucide-react'
|
import { Activity, AlertTriangle, Bird, Database, HeartPulse, Info, ListTodo, RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||||
import { Badge } from '@evobgp/ui/components/badge'
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||||
import { Separator } from '@evobgp/ui/components/separator'
|
import { Separator } from '@evobgp/ui/components/separator'
|
||||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||||
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import {
|
import {
|
||||||
DashboardOperationsFlowCard,
|
DashboardOperationsFlowCard,
|
||||||
MonitoringHealthCard,
|
MonitoringHealthCard,
|
||||||
@@ -199,7 +199,7 @@ function MonitoringComponent() {
|
|||||||
<li key={job.job_id} className="rounded-lg border px-3 py-2 text-sm">
|
<li key={job.job_id} className="rounded-lg border px-3 py-2 text-sm">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<p className="font-medium">{job.kind}</p>
|
<p className="font-medium">{job.kind}</p>
|
||||||
<Badge variant="destructive">{job.status}</Badge>
|
<StatusBadge status={job.status} />
|
||||||
</div>
|
</div>
|
||||||
{job.error ? (
|
{job.error ? (
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user