Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
940f8892f3 | ||
|
|
ac727ad1e3 | ||
|
|
c265c06f93 | ||
|
|
d434eb0d94 | ||
|
|
d0bd4d661d | ||
|
|
642db1a83a | ||
|
|
f66d68d1c7 | ||
|
|
e04fea657c | ||
|
|
452f6b2db0 | ||
|
|
b321aa5321 | ||
|
|
5d1102b497 | ||
|
|
5edbd656ba | ||
|
|
4a4c11c6bf | ||
|
|
e51999c908 | ||
|
|
b7f7669685 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"pid": 39884,
|
||||
"pid": 43636,
|
||||
"version": "0.9.9",
|
||||
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
||||
"startedAt": 1783489774882
|
||||
"startedAt": 1783570299043
|
||||
}
|
||||
|
||||
@@ -1,25 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { Plus, RefreshCw } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
|
||||
import { AccessApiKeysGrid } from '@/components/access/access-api-keys-grid'
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog'
|
||||
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
|
||||
import { useRevokeApiKeyMutation, useRotateApiKeyMutation } from '@/queries/api-keys'
|
||||
import type { ApiKey, ApiKeyCreated } from '@/types/api'
|
||||
|
||||
@@ -58,121 +47,45 @@ export function AccessApiKeysCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base">API-ключи</CardTitle>
|
||||
<CardDescription>
|
||||
Управление ключами tenant. Полный токен показывается только при создании и ротации.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onClick={onRetry} disabled={isLoading}>
|
||||
<DataGridCard
|
||||
title="API-ключи"
|
||||
description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
|
||||
actions={
|
||||
<>
|
||||
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
|
||||
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Button size="sm" type="button" onClick={() => setCreateOpen(true)}>
|
||||
<Plus />
|
||||
Создать
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle="Нет ключей"
|
||||
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
|
||||
skeleton={<TableSkeleton rows={4} cols={6} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(data) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Имя</TableHead>
|
||||
<TableHead>Роль</TableHead>
|
||||
<TableHead>Префикс</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Истекает</TableHead>
|
||||
<TableHead>Последнее использование</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((k) => (
|
||||
<TableRow key={k.id}>
|
||||
<TableCell className="font-medium">{k.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{k.role}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{k.prefix}…
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{k.revoked_at ? (
|
||||
<StatusBadge status="error" label="отозван" />
|
||||
) : (
|
||||
<StatusBadge status="active" label="активен" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatApiKeyDate(k.expires_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatApiKeyDate(k.last_used_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Ротировать"
|
||||
disabled={!!k.revoked_at || rotate.isPending}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
title="Ротировать ключ?"
|
||||
description="Старый токен перестанет работать сразу."
|
||||
confirmLabel="Ротировать"
|
||||
onConfirm={() => handleRotated(k.id)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive"
|
||||
disabled={!!k.revoked_at || revoke.isPending}
|
||||
title="Отозвать"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
title="Отозвать API-ключ?"
|
||||
description={`${k.name} (${k.prefix}…)`}
|
||||
confirmLabel="Отозвать"
|
||||
destructive
|
||||
onConfirm={() => revoke.mutate(k.id)}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle="Нет ключей"
|
||||
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
|
||||
skeleton={<TableSkeleton rows={4} cols={6} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(data) => (
|
||||
<AccessApiKeysGrid
|
||||
items={data}
|
||||
isLoading={isLoading}
|
||||
onRotate={handleRotated}
|
||||
onRevoke={(id) => revoke.mutate(id)}
|
||||
rotatePending={rotate.isPending}
|
||||
revokePending={revoke.isPending}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
|
||||
<ApiKeyCreateDialog
|
||||
open={createOpen}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
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 { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { ApiKey } from '@/types/api'
|
||||
|
||||
export function AccessApiKeysGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
onRotate,
|
||||
onRevoke,
|
||||
rotatePending = false,
|
||||
revokePending = false,
|
||||
}: {
|
||||
items: ApiKey[]
|
||||
isLoading?: boolean
|
||||
onRotate: (id: string) => void
|
||||
onRevoke: (id: string) => void
|
||||
rotatePending?: boolean
|
||||
revokePending?: boolean
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<ApiKey>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||
cell: ({ row }) => <DataGridPrimaryCell title={row.original.name} accent="primary" />,
|
||||
meta: { headerTitle: 'Имя' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
||||
cell: ({ row }) => (
|
||||
<CategoryBadge className="font-mono text-xs">{row.original.role}</CategoryBadge>
|
||||
),
|
||||
meta: { headerTitle: 'Роль' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'prefix',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Префикс" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.prefix}…</span>
|
||||
),
|
||||
meta: { headerTitle: 'Префикс' },
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
enableSorting: false,
|
||||
header: 'Статус',
|
||||
cell: ({ row }) =>
|
||||
row.original.revoked_at ? (
|
||||
<StatusBadge status="error" label="отозван" />
|
||||
) : (
|
||||
<StatusBadge status="active" label="активен" />
|
||||
),
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
{
|
||||
id: 'expires_at',
|
||||
accessorFn: (row) => row.expires_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Истекает" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>{formatApiKeyDate(row.original.expires_at)}</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Истекает' },
|
||||
},
|
||||
{
|
||||
id: 'last_used_at',
|
||||
accessorFn: (row) => row.last_used_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Последнее использование" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Последнее использование' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const k = row.original
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
title="Ротировать"
|
||||
disabled={!!k.revoked_at || rotatePending}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
title="Ротировать ключ?"
|
||||
description="Старый токен перестанет работать сразу."
|
||||
confirmLabel="Ротировать"
|
||||
onConfirm={() => onRotate(k.id)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
disabled={!!k.revoked_at || revokePending}
|
||||
title="Отозвать"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
title="Отозвать API-ключ?"
|
||||
description={`${k.name} (${k.prefix}…)`}
|
||||
confirmLabel="Отозвать"
|
||||
destructive
|
||||
onConfirm={() => onRevoke(k.id)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[onRevoke, onRotate, revokePending, rotatePending],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) =>
|
||||
`${row.name} ${row.role} ${row.prefix} ${row.revoked_at ? 'отозван' : 'активен'}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет ключей"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск API-ключей…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -2,24 +2,12 @@ import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@evobgp/ui/components/dialog'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evobgp/ui/components/select'
|
||||
|
||||
import { FormDrawer } from '@/components/form-drawer'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { API_KEY_ROLE_ITEMS } from '@/lib/access/api-key-labels'
|
||||
import { useCreateApiKeyMutation } from '@/queries/api-keys'
|
||||
import type { ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '@/types/api'
|
||||
@@ -74,59 +62,48 @@ export function ApiKeyCreateDialog({ open, onOpenChange, onCreated }: ApiKeyCrea
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Новый API-ключ</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="key-name">Имя</Label>
|
||||
<Input
|
||||
id="key-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="CI / оператор UI"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="key-role">Роль</Label>
|
||||
<Select
|
||||
items={[...API_KEY_ROLE_ITEMS]}
|
||||
value={role}
|
||||
onValueChange={(v) => v && setRole(v as ApiKeyRole)}
|
||||
>
|
||||
<SelectTrigger id="key-role" className="w-full">
|
||||
<SelectValue placeholder="Выберите роль" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{API_KEY_ROLE_ITEMS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="key-expires">Истекает (опционально)</Label>
|
||||
<Input
|
||||
id="key-expires"
|
||||
type="datetime-local"
|
||||
value={expiresLocal}
|
||||
onChange={(e) => setExpiresLocal(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
title="Новый API-ключ"
|
||||
className="sm:max-w-sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton onClick={save} loading={createMutation.isPending}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="key-name">Имя</Label>
|
||||
<Input
|
||||
id="key-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="CI / оператор UI"
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
id="key-role"
|
||||
label="Роль"
|
||||
items={[...API_KEY_ROLE_ITEMS]}
|
||||
value={role}
|
||||
placeholder="Выберите роль"
|
||||
onValueChange={(v) => v && setRole(v as ApiKeyRole)}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="key-expires">Истекает (опционально)</Label>
|
||||
<Input
|
||||
id="key-expires"
|
||||
type="datetime-local"
|
||||
value={expiresLocal}
|
||||
onChange={(e) => setExpiresLocal(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { AlertTriangle, CheckCircle, Info } from 'lucide-react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { PlatformActivityItem } from '@/lib/metrics'
|
||||
|
||||
const KIND_ICON = {
|
||||
job: Info,
|
||||
revision: CheckCircle,
|
||||
network: AlertTriangle,
|
||||
} as const
|
||||
|
||||
const KIND_ICON_CLASS = {
|
||||
job: 'text-info',
|
||||
revision: 'text-success',
|
||||
network: 'text-warning',
|
||||
} as const
|
||||
|
||||
export function AnalyticsActivityList({
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
items: PlatformActivityItem[]
|
||||
className?: string
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">Нет недавних событий</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={cn('space-y-3', className)}>
|
||||
{items.map((item) => {
|
||||
const Icon = KIND_ICON[item.kind]
|
||||
return (
|
||||
<li key={item.id} className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<span
|
||||
className={cn(
|
||||
'mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-muted/60',
|
||||
KIND_ICON_CLASS[item.kind],
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
<p className="text-sm leading-snug">{item.message}</p>
|
||||
</div>
|
||||
<StatusBadge status={item.status} label={item.statusLabel} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Info } from 'lucide-react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@evobgp/ui/components/tooltip'
|
||||
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
|
||||
export function AnalyticsCardShell({
|
||||
title,
|
||||
description,
|
||||
info,
|
||||
actions,
|
||||
footer,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
info?: string
|
||||
actions?: ReactNode
|
||||
footer?: ReactNode
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const titleNode = (
|
||||
<span className="flex items-center gap-2">
|
||||
{title}
|
||||
{info ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
className="inline-flex text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label="Подробнее"
|
||||
>
|
||||
<Info className="size-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs text-xs">
|
||||
{info}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<PanelCard
|
||||
title={titleNode}
|
||||
description={description}
|
||||
actions={actions}
|
||||
footer={footer}
|
||||
className={cn('overflow-hidden', className)}
|
||||
contentClassName="flex flex-col gap-4 py-4"
|
||||
footerClassName={footer ? 'gap-2 p-3' : undefined}
|
||||
>
|
||||
{children}
|
||||
</PanelCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Minus, TrendingDown, TrendingUp } from 'lucide-react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export type AnalyticsKpiItem = {
|
||||
label: string
|
||||
value: string
|
||||
delta?: {
|
||||
direction: 'up' | 'down' | 'neutral'
|
||||
label: string
|
||||
tone?: 'success' | 'warning' | 'destructive' | 'muted'
|
||||
}
|
||||
}
|
||||
|
||||
const TONE_CLASS = {
|
||||
success: 'text-success',
|
||||
warning: 'text-warning',
|
||||
destructive: 'text-destructive',
|
||||
muted: 'text-muted-foreground',
|
||||
} as const
|
||||
|
||||
function DeltaIcon({ direction }: { direction: AnalyticsKpiItem['delta'] extends infer D ? D extends { direction: infer Dir } ? Dir : never : never }) {
|
||||
if (direction === 'up') return <TrendingUp className="size-3" />
|
||||
if (direction === 'down') return <TrendingDown className="size-3" />
|
||||
return <Minus className="size-3" />
|
||||
}
|
||||
|
||||
export function AnalyticsKpiRow({ items, className }: { items: AnalyticsKpiItem[]; className?: string }) {
|
||||
return (
|
||||
<div className={cn('grid gap-4 sm:grid-cols-3', className)}>
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="min-w-0 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{item.label}</p>
|
||||
<p className="text-2xl font-semibold tracking-tight tabular-nums">{item.value}</p>
|
||||
{item.delta ? (
|
||||
<p
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-xs',
|
||||
TONE_CLASS[item.delta.tone ?? 'muted'],
|
||||
)}
|
||||
>
|
||||
<DeltaIcon direction={item.delta.direction} />
|
||||
{item.delta.label}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
Progress,
|
||||
ProgressIndicator,
|
||||
ProgressTrack,
|
||||
} from '@evobgp/ui/components/progress'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export function AnalyticsProgress({
|
||||
label,
|
||||
hint,
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
label: string
|
||||
hint?: string
|
||||
value: number
|
||||
className?: string
|
||||
}) {
|
||||
const clamped = Math.max(0, Math.min(100, value))
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-medium tabular-nums">{clamped}%</span>
|
||||
</div>
|
||||
{hint ? <p className="text-xs leading-snug text-muted-foreground">{hint}</p> : null}
|
||||
<Progress value={clamped} className="w-full gap-0">
|
||||
<ProgressTrack className="h-2">
|
||||
<ProgressIndicator />
|
||||
</ProgressTrack>
|
||||
</Progress>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { ButtonGroup } from '@evobgp/ui/components/button-group'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export function AnalyticsSegmentControl<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
className,
|
||||
}: {
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
options: { value: T; label: string }[]
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<ButtonGroup className={cn('rounded-lg bg-muted/50 p-0.5', className)}>
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={value === option.value ? 'secondary' : 'ghost'}
|
||||
className={cn(
|
||||
'h-7 rounded-md px-2.5 text-xs',
|
||||
value === option.value && 'bg-background shadow-sm',
|
||||
)}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Bar, BarChart, XAxis } from 'recharts'
|
||||
|
||||
import { ChartContainer, type ChartConfig } from '@evobgp/ui/components/chart'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import type { CapacityBar } from '@/lib/metrics'
|
||||
|
||||
const chartConfig = {
|
||||
value: { label: 'Загрузка', color: 'var(--color-chart-2)' },
|
||||
} satisfies ChartConfig
|
||||
|
||||
export function ChartBarStrip({
|
||||
bars,
|
||||
className,
|
||||
}: {
|
||||
bars: CapacityBar[]
|
||||
className?: string
|
||||
}) {
|
||||
if (bars.length === 0) {
|
||||
return (
|
||||
<div className={cn('flex h-36 items-center justify-center text-sm text-muted-foreground', className)}>
|
||||
Нет данных для графика
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const data = bars.map((bar, index) => ({
|
||||
...bar,
|
||||
slot: index + 1,
|
||||
fill: bar.value >= 80 ? 'var(--color-chart-2)' : 'var(--color-chart-3)',
|
||||
}))
|
||||
|
||||
return (
|
||||
<ChartContainer config={chartConfig} className={cn('aspect-auto h-36 w-full', className)}>
|
||||
<BarChart data={data} margin={{ top: 4, right: 0, left: 0, bottom: 0 }}>
|
||||
<XAxis dataKey="slot" hide />
|
||||
<Bar dataKey="value" radius={[3, 3, 0, 0]} maxBarSize={10} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Cell, Label, Pie, PieChart } from 'recharts'
|
||||
|
||||
import {
|
||||
ChartContainer,
|
||||
type ChartConfig,
|
||||
} from '@evobgp/ui/components/chart'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import type { BreakdownSlice } from '@/lib/metrics'
|
||||
|
||||
export function ChartDonutMetric({
|
||||
slices,
|
||||
centerLabel,
|
||||
centerValue,
|
||||
className,
|
||||
}: {
|
||||
slices: BreakdownSlice[]
|
||||
centerLabel: string
|
||||
centerValue: string | number
|
||||
className?: string
|
||||
}) {
|
||||
const chartConfig = slices.reduce<ChartConfig>((acc, slice) => {
|
||||
acc[slice.key] = { label: slice.label, color: slice.color }
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const data = slices.map((slice) => ({
|
||||
...slice,
|
||||
fill: slice.color,
|
||||
}))
|
||||
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className={cn('flex h-48 items-center justify-center text-sm text-muted-foreground', className)}>
|
||||
Нет данных
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-6', className)}>
|
||||
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="count"
|
||||
nameKey="label"
|
||||
innerRadius={52}
|
||||
outerRadius={72}
|
||||
strokeWidth={2}
|
||||
stroke="var(--color-card)"
|
||||
>
|
||||
{data.map((entry) => (
|
||||
<Cell key={entry.key} fill={entry.fill} />
|
||||
))}
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (!viewBox || !('cx' in viewBox) || !('cy' in viewBox)) return null
|
||||
const { cx, cy } = viewBox
|
||||
return (
|
||||
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle">
|
||||
<tspan x={cx} y={(cy ?? 0) - 6} className="fill-muted-foreground text-xs">
|
||||
{centerLabel}
|
||||
</tspan>
|
||||
<tspan x={cx} y={(cy ?? 0) + 14} className="fill-foreground text-xl font-semibold">
|
||||
{centerValue}
|
||||
</tspan>
|
||||
</text>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
|
||||
<ul className="min-w-0 flex-1 space-y-3">
|
||||
{slices.map((slice) => {
|
||||
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
|
||||
return (
|
||||
<li key={slice.key} className="flex items-center justify-between gap-3 text-sm">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: slice.color }}
|
||||
/>
|
||||
<span className="truncate text-muted-foreground">{slice.label}</span>
|
||||
</div>
|
||||
<div className="shrink-0 text-right tabular-nums">
|
||||
<span className="font-semibold">{slice.count}</span>
|
||||
<span className="ml-2 text-muted-foreground">{pct}%</span>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
} from '@evobgp/ui/components/avatar'
|
||||
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
|
||||
import { ChartBarStrip } from '@/components/analytics/chart-bar-strip'
|
||||
import {
|
||||
capacityUtilization,
|
||||
peerCapacityBars,
|
||||
speakerCapacityBars,
|
||||
} from '@/lib/metrics'
|
||||
import { runningJobCount } from '@/queries/overview'
|
||||
import type { JobRow, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
type CapacityMode = 'peers' | 'speakers'
|
||||
|
||||
function speakerInitials(speaker: SpeakerRow): string {
|
||||
const label = speaker.live?.label ?? speaker.agent_domain ?? speaker.endpoint ?? speaker.id
|
||||
const parts = label.split(/[.\-_@/]/).filter(Boolean)
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
|
||||
return label.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
export function DashboardNetworkCapacityCard({
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const [mode, setMode] = useState<CapacityMode>('peers')
|
||||
|
||||
const bars = useMemo(
|
||||
() => (mode === 'peers' ? peerCapacityBars(peers) : speakerCapacityBars(speakers)),
|
||||
[mode, peers, speakers],
|
||||
)
|
||||
const utilization = capacityUtilization(bars)
|
||||
const queued = runningJobCount(jobs)
|
||||
const previewSpeakers = speakers.slice(0, 3)
|
||||
|
||||
const deltaLabel =
|
||||
mode === 'peers'
|
||||
? `${peers.filter((p) => p.enabled !== false && p.session_state === 'Established').length} установлено`
|
||||
: `${speakers.filter((s) => s.live?.agent_ok).length} в сети`
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
title="Загрузка BGP"
|
||||
description="Текущая утилизация сессий по пирам и спикерам"
|
||||
info="Каждый столбец — включённый пир или спикер. Высота отражает установленную сессию или доступность."
|
||||
actions={
|
||||
<AnalyticsSegmentControl
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ value: 'peers', label: 'Пиры' },
|
||||
{ value: 'speakers', label: 'Спикеры' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="text-3xl font-semibold tracking-tight tabular-nums">
|
||||
{loading ? '—' : `${utilization}%`}
|
||||
</p>
|
||||
<p className="text-sm text-success">{loading ? '…' : `${deltaLabel} · снимок live`}</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-36 items-center justify-center text-sm text-muted-foreground">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartBarStrip bars={bars} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
Активных задач: <span className="font-medium text-foreground">{loading ? '—' : queued}</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<AvatarGroup>
|
||||
{previewSpeakers.map((speaker) => (
|
||||
<Avatar key={speaker.id} size="sm">
|
||||
<AvatarFallback>{speakerInitials(speaker)}</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
{speakers.length > 3 ? (
|
||||
<AvatarGroupCount>+{speakers.length - 3}</AvatarGroupCount>
|
||||
) : null}
|
||||
</AvatarGroup>
|
||||
<span className="text-muted-foreground">{speakers.length} спикеров</span>
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsCardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
|
||||
import type { JobRow, ModuleRow } from '@/types/api'
|
||||
|
||||
type FlowMode = 'jobs' | 'modules'
|
||||
|
||||
export function DashboardOperationsFlowCard({
|
||||
jobs,
|
||||
modules,
|
||||
loading,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
modules: ModuleRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const [mode, setMode] = useState<FlowMode>('jobs')
|
||||
|
||||
const slices = useMemo(
|
||||
() => (mode === 'jobs' ? jobStatusBreakdown(jobs) : moduleTypeBreakdown(modules)),
|
||||
[mode, jobs, modules],
|
||||
)
|
||||
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
const centerLabel = mode === 'jobs' ? 'Задачи' : 'Модули'
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
title="Поток операций"
|
||||
description="Распределение фоновых задач и типов модулей"
|
||||
info="Donut строится по текущей выборке API (до 100 последних задач)."
|
||||
actions={
|
||||
<AnalyticsSegmentControl
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ value: 'jobs', label: 'Задачи' },
|
||||
{ value: 'modules', label: 'Модули' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric
|
||||
slices={slices}
|
||||
centerLabel={centerLabel}
|
||||
centerValue={total}
|
||||
/>
|
||||
)}
|
||||
</AnalyticsCardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { AnalyticsActivityList } from '@/components/analytics/analytics-activity-list'
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
|
||||
import { AnalyticsProgress } from '@/components/analytics/analytics-progress'
|
||||
import {
|
||||
deploymentProgress,
|
||||
deploymentProgressMeta,
|
||||
recentPlatformActivity,
|
||||
} from '@/lib/metrics'
|
||||
import { runningJobCount } from '@/queries/overview'
|
||||
import type { JobRow, ModuleRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
export function DashboardPlatformCard({
|
||||
modules,
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
revisions,
|
||||
loading,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
revisions: RevisionRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const enabledModules = modules.filter((m) => m.enabled !== false).length
|
||||
const peersEnabled = peers.filter((p) => p.enabled !== false).length
|
||||
const peersEstablished = peers.filter(
|
||||
(p) => p.enabled !== false && p.session_state === 'Established',
|
||||
).length
|
||||
const peersMismatch = peers.filter((p) => p.session_mismatch).length
|
||||
const speakersOnline = speakers.filter((s) => s.live?.agent_ok).length
|
||||
const failedJobs = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
const running = runningJobCount(jobs)
|
||||
const riskCount = peersMismatch + failedJobs + Math.max(0, speakers.length - speakersOnline)
|
||||
|
||||
const bgpPct =
|
||||
peersEnabled > 0 ? Math.round((peersEstablished / peersEnabled) * 100) : null
|
||||
|
||||
const deploy = useMemo(() => deploymentProgress(speakers), [speakers])
|
||||
const deployMeta = useMemo(() => deploymentProgressMeta(deploy), [deploy])
|
||||
const activity = useMemo(
|
||||
() => recentPlatformActivity(jobs, revisions, peers, speakers),
|
||||
[jobs, revisions, peers, speakers],
|
||||
)
|
||||
|
||||
const kpis = [
|
||||
{
|
||||
label: 'Модули активны',
|
||||
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
|
||||
delta: {
|
||||
direction: 'neutral' as const,
|
||||
label: `${modules.length} всего`,
|
||||
tone: 'muted' as const,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'BGP готовность',
|
||||
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
|
||||
delta: {
|
||||
direction: (bgpPct !== null && bgpPct >= 90 ? 'up' : bgpPct !== null && bgpPct < 70 ? 'down' : 'neutral') as
|
||||
| 'up'
|
||||
| 'down'
|
||||
| 'neutral',
|
||||
label:
|
||||
bgpPct === null
|
||||
? 'нет включённых пиров'
|
||||
: `${peersEstablished} установлено`,
|
||||
tone: (bgpPct !== null && bgpPct >= 90
|
||||
? 'success'
|
||||
: bgpPct !== null && bgpPct < 70
|
||||
? 'warning'
|
||||
: 'muted') as 'success' | 'warning' | 'muted',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Риски',
|
||||
value: loading ? '—' : String(riskCount),
|
||||
delta: {
|
||||
direction: (riskCount > 0 ? 'down' : 'up') as 'up' | 'down',
|
||||
label: riskCount > 0 ? `${failedJobs} задач, ${peersMismatch} расхождений` : 'в норме',
|
||||
tone: (riskCount > 0 ? 'destructive' : 'success') as 'destructive' | 'success',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const progressLabel = deployMeta.label
|
||||
const progressHint = deployMeta.hint
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
title="Состояние платформы"
|
||||
description="Сводка модулей, BGP и фоновых задач"
|
||||
info="Актуальный снимок без исторических трендов. Обновите данные кнопкой «Обновить» на странице."
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => navigate({ to: '/schedule' })}
|
||||
>
|
||||
Расписание
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => navigate({ to: '/monitoring', search: { tab: 'system' } })}
|
||||
>
|
||||
Мониторинг
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AnalyticsKpiRow items={kpis} />
|
||||
<AnalyticsProgress
|
||||
label={progressLabel}
|
||||
hint={progressHint}
|
||||
value={loading ? 0 : deploy.percent}
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Недавняя активность</span>
|
||||
{!loading ? (
|
||||
<span className="text-xs text-muted-foreground">{running} активных задач</span>
|
||||
) : null}
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Загрузка…</p>
|
||||
) : (
|
||||
<AnalyticsActivityList items={activity} />
|
||||
)}
|
||||
</div>
|
||||
</AnalyticsCardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export { AnalyticsActivityList } from './analytics-activity-list'
|
||||
export { AnalyticsCardShell } from './analytics-card-shell'
|
||||
export { AnalyticsKpiRow, type AnalyticsKpiItem } from './analytics-kpi-row'
|
||||
export { AnalyticsProgress } from './analytics-progress'
|
||||
export { AnalyticsSegmentControl } from './analytics-segment-control'
|
||||
export { ChartBarStrip } from './chart-bar-strip'
|
||||
export { ChartDonutMetric } from './chart-donut-metric'
|
||||
export { DashboardNetworkCapacityCard } from './dashboard-network-capacity-card'
|
||||
export { DashboardOperationsFlowCard } from './dashboard-operations-flow-card'
|
||||
export { DashboardPlatformCard } from './dashboard-platform-card'
|
||||
export { MonitoringHealthCard } from './monitoring-health-card'
|
||||
export { NetworkOverviewAnalyticsCard } from './network-overview-analytics-card'
|
||||
export { OperationsAnalyticsCard } from './operations-analytics-card'
|
||||
@@ -0,0 +1,33 @@
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { readinessBreakdown } from '@/lib/metrics'
|
||||
import type { ReadyStatus } from '@/queries/monitoring'
|
||||
|
||||
export function MonitoringHealthCard({
|
||||
healthOk,
|
||||
ready,
|
||||
loading,
|
||||
}: {
|
||||
healthOk: boolean
|
||||
ready: ReadyStatus | null | undefined
|
||||
loading?: boolean
|
||||
}) {
|
||||
const slices = readinessBreakdown(ready, healthOk)
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
title="Доступность системы"
|
||||
description="Проверки живучести и готовности"
|
||||
info="Диаграмма отражает результат GET /v1/health и проверок из GET /v1/ready."
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric slices={slices} centerLabel="Проверки" centerValue={total} />
|
||||
)}
|
||||
</AnalyticsCardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { peerSessionBreakdown } from '@/lib/metrics'
|
||||
import { aggregateNetworkMetrics } from '@/queries/overview'
|
||||
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
export function NetworkOverviewAnalyticsCard({
|
||||
peers,
|
||||
speakers,
|
||||
loading,
|
||||
}: {
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const net = aggregateNetworkMetrics(peers, speakers)
|
||||
const slices = peerSessionBreakdown(peers)
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
title="Сводка BGP"
|
||||
description="Установленные сессии, доступность спикеров и расхождения по live-данным"
|
||||
info="Снимок текущего состояния пиров и спикеров."
|
||||
>
|
||||
<AnalyticsKpiRow
|
||||
items={[
|
||||
{
|
||||
label: 'Пиры с установленной сессией',
|
||||
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
|
||||
delta: {
|
||||
direction: net.peersMismatch > 0 ? 'down' : 'up',
|
||||
label: net.peersMismatch > 0 ? `${net.peersMismatch} расхождений` : 'сессии в норме',
|
||||
tone: net.peersMismatch > 0 ? 'warning' : 'success',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Спикеры в сети',
|
||||
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
|
||||
delta: {
|
||||
direction: net.speakersOnline < net.speakersTotal ? 'down' : 'up',
|
||||
label:
|
||||
net.speakersOnline < net.speakersTotal
|
||||
? `${net.speakersTotal - net.speakersOnline} не в сети`
|
||||
: 'все в сети',
|
||||
tone: net.speakersOnline < net.speakersTotal ? 'warning' : 'success',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Пиры всего',
|
||||
value: loading ? '—' : String(net.peersTotal),
|
||||
delta: { direction: 'neutral', label: 'в каталоге', tone: 'muted' },
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric slices={slices} centerLabel="Пиры" centerValue={total} />
|
||||
)}
|
||||
</AnalyticsCardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { jobStatusBreakdown } from '@/lib/metrics'
|
||||
import type { JobRow, RevisionRow } from '@/types/api'
|
||||
|
||||
export function OperationsAnalyticsCard({
|
||||
jobs,
|
||||
revisions,
|
||||
loading,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
revisions: RevisionRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const running = jobs.filter((j) => ['running', 'queued'].includes(j.status.toLowerCase())).length
|
||||
const failed = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
const slices = jobStatusBreakdown(jobs)
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
title="Операции и задачи"
|
||||
description="Статистика ревизий и фоновых jobs"
|
||||
info="Данные из GET /v1/jobs и /v1/revisions."
|
||||
>
|
||||
<AnalyticsKpiRow
|
||||
items={[
|
||||
{
|
||||
label: 'Ревизий',
|
||||
value: loading ? '—' : String(revisions.length),
|
||||
delta: { direction: 'neutral', label: 'в выборке', tone: 'muted' },
|
||||
},
|
||||
{
|
||||
label: 'Активных задач',
|
||||
value: loading ? '—' : String(running),
|
||||
delta: {
|
||||
direction: running > 0 ? 'up' : 'neutral',
|
||||
label: running > 0 ? 'выполняются' : 'очередь пуста',
|
||||
tone: (running > 0 ? 'warning' : 'muted') as 'warning' | 'muted',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'С ошибкой',
|
||||
value: loading ? '—' : String(failed),
|
||||
delta: {
|
||||
direction: failed > 0 ? 'down' : 'up',
|
||||
label: failed > 0 ? 'требуют внимания' : 'в норме',
|
||||
tone: failed > 0 ? 'destructive' : 'success',
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric slices={slices} centerLabel="Задачи" centerValue={total} />
|
||||
)}
|
||||
</AnalyticsCardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
|
||||
export type BadgeTabItem = {
|
||||
value: string
|
||||
label: string
|
||||
count?: number
|
||||
badgeVariant?: ComponentProps<typeof Badge>['variant']
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
interface BadgeTabsProps {
|
||||
items: BadgeTabItem[]
|
||||
value?: string
|
||||
defaultValue?: string
|
||||
onValueChange?: (value: string) => void
|
||||
children: ReactNode
|
||||
className?: string
|
||||
listClassName?: string
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
/** Underline tabs with optional badge counts (ReUI c-tabs-7 pattern). */
|
||||
export function BadgeTabs({
|
||||
items,
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
children,
|
||||
className,
|
||||
listClassName,
|
||||
contentClassName,
|
||||
}: BadgeTabsProps) {
|
||||
return (
|
||||
<Tabs
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={onValueChange}
|
||||
className={cn('w-full', className)}
|
||||
>
|
||||
<TabsList
|
||||
variant="line"
|
||||
className={cn(
|
||||
'mb-4 w-full justify-start gap-6',
|
||||
listClassName,
|
||||
)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<TabsTrigger key={item.value} value={item.value} className="gap-2">
|
||||
{item.icon}
|
||||
{item.label}
|
||||
{item.count !== undefined ? (
|
||||
<Badge variant={item.badgeVariant ?? 'primary-light'} size="sm">
|
||||
{item.count}
|
||||
</Badge>
|
||||
) : null}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<div className={cn('w-full min-w-0', contentClassName)}>{children}</div>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
export { TabsContent }
|
||||
@@ -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,53 +1,130 @@
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@evobgp/ui/components/alert-dialog'
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from '@evobgp/ui/components/drawer'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
trigger: ReactElement
|
||||
import {
|
||||
confirmDrawerContentClassName,
|
||||
DrawerActionsFooter,
|
||||
} from '@/components/drawer-layout'
|
||||
|
||||
type ConfirmDialogBaseProps = {
|
||||
title: string
|
||||
description?: ReactNode
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
destructive?: boolean
|
||||
onConfirm: () => void
|
||||
confirmDisabled?: boolean
|
||||
confirmLoading?: boolean
|
||||
confirmLoadingLabel?: string
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
trigger,
|
||||
type ConfirmDialogWithTrigger = ConfirmDialogBaseProps & {
|
||||
trigger: ReactElement
|
||||
open?: never
|
||||
onOpenChange?: never
|
||||
}
|
||||
|
||||
type ConfirmDialogControlled = ConfirmDialogBaseProps & {
|
||||
trigger?: never
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
type ConfirmDialogProps = ConfirmDialogWithTrigger | ConfirmDialogControlled
|
||||
|
||||
function ConfirmDrawerBody({
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Подтвердить',
|
||||
cancelLabel = 'Отмена',
|
||||
destructive,
|
||||
onConfirm,
|
||||
}: ConfirmDialogProps) {
|
||||
confirmDisabled,
|
||||
confirmLoading,
|
||||
confirmLoadingLabel,
|
||||
controlled,
|
||||
}: ConfirmDialogBaseProps & { controlled?: boolean }) {
|
||||
const confirmText =
|
||||
confirmLoading && confirmLoadingLabel
|
||||
? confirmLoadingLabel
|
||||
: confirmLoading
|
||||
? `${confirmLabel}…`
|
||||
: confirmLabel
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={trigger} />
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
{description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
<>
|
||||
<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}
|
||||
>
|
||||
{confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{confirmText}
|
||||
</Button>
|
||||
) : (
|
||||
<DrawerClose
|
||||
render={
|
||||
<Button
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
disabled={confirmDisabled || confirmLoading}
|
||||
/>
|
||||
}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</DrawerClose>
|
||||
)}
|
||||
</DrawerActionsFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
if (props.trigger) {
|
||||
return (
|
||||
<Drawer swipeDirection="right">
|
||||
<DrawerTrigger render={props.trigger} />
|
||||
<DrawerContent className={confirmDrawerContentClassName}>
|
||||
<ConfirmDrawerBody {...bodyProps} />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer open={props.open} onOpenChange={props.onOpenChange} swipeDirection="right">
|
||||
<DrawerContent className={confirmDrawerContentClassName}>
|
||||
<ConfirmDrawerBody {...bodyProps} controlled />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { aggregateNetworkMetrics } from '@/queries/overview'
|
||||
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
function Row({ label, value, variant = 'default' }: { label: string; value: string; variant?: 'default' | 'warning' }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 px-1 text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={
|
||||
variant === 'warning'
|
||||
? 'font-medium text-warning-foreground tabular-nums'
|
||||
: 'font-medium tabular-nums'
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardNetworkPanel({
|
||||
peers,
|
||||
speakers,
|
||||
}: {
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
}) {
|
||||
const m = aggregateNetworkMetrics(peers, speakers)
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<Row label="Пиры с установленной сессией" value={`${m.peersEstablished} / ${m.peersEnabled}`} />
|
||||
<Row label="Спикеры в сети" value={`${m.speakersOnline} / ${m.speakersTotal}`} />
|
||||
{m.peersMismatch > 0 ? (
|
||||
<Row label="Расхождения сессий" value={String(m.peersMismatch)} variant="warning" />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
export function DashboardQuickActions() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/modules" />}>
|
||||
<Plus className="size-4" />
|
||||
Создать модуль
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/directories" />}>
|
||||
<Tags className="size-4" />
|
||||
Добавить BGP-сообщество
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/network" search={{ tab: 'overview' }} />}>
|
||||
<Network className="size-4" />
|
||||
Сеть
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/network" search={{ tab: 'peers' }} />}>
|
||||
<Share2 className="size-4" />
|
||||
Добавить пира
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
render={<Link to="/operations" search={{ tab: 'revisions' }} />}
|
||||
>
|
||||
<Play className="size-4" />
|
||||
Деплой
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/monitoring" search={{ tab: 'system' }} />}>
|
||||
<Gauge className="size-4" />
|
||||
Мониторинг
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
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 { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||
import { jobKindRu } from '@/lib/ui-labels'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { JobRow } from '@/types/api'
|
||||
|
||||
export function DashboardRecentJobsGrid({
|
||||
jobs,
|
||||
nameById,
|
||||
isLoading = false,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
nameById: Map<string, string>
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const data = useMemo(() => jobs.slice(0, 8), [jobs])
|
||||
|
||||
const columns = useMemo<ColumnDef<JobRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'kind',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={jobKindRu(row.original.kind)}
|
||||
accent="mono"
|
||||
subtitle={
|
||||
row.original.meta?.module_id
|
||||
? (nameById.get(String(row.original.meta.module_id)) ?? undefined)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Вид' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
],
|
||||
[nameById],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data,
|
||||
columns,
|
||||
getSearchText: (row) => {
|
||||
const moduleName = row.meta?.module_id
|
||||
? (nameById.get(String(row.meta.module_id)) ?? '')
|
||||
: ''
|
||||
return `${jobKindRu(row.kind)} ${row.status} ${moduleName}`
|
||||
},
|
||||
getRowId: (row) => row.job_id,
|
||||
pageSize: 8,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет задач"
|
||||
showPagination={false}
|
||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск задач…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { RevisionRow } from '@/types/api'
|
||||
|
||||
export function DashboardRecentRevisionsGrid({
|
||||
revisions,
|
||||
isLoading = false,
|
||||
}: {
|
||||
revisions: RevisionRow[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const data = useMemo(() => revisions.slice(0, 8), [revisions])
|
||||
|
||||
const columns = useMemo<ColumnDef<RevisionRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'id',
|
||||
accessorFn: (row) => row.id,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell title={`${row.original.id.slice(0, 10)}…`} accent="mono" />
|
||||
),
|
||||
meta: { headerTitle: 'ID' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>
|
||||
{new Date(row.original.created_at).toLocaleString('ru-RU')}
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Создана' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data,
|
||||
columns,
|
||||
getSearchText: (row) => row.id,
|
||||
getRowId: (row) => row.id,
|
||||
pageSize: 8,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет ревизий"
|
||||
showPagination={false}
|
||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск ревизий…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Table } from '@tanstack/react-table'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import { DataGridToolbar } from '@/components/data-grid-toolbar'
|
||||
import { PanelCard, panelCardContentFlushClassName, panelCardFooterClassName } from '@/components/panel-card'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import {
|
||||
DATA_GRID_MESSAGES_RU,
|
||||
DATA_GRID_PAGINATION_RU,
|
||||
DATA_GRID_TABLE_LAYOUT,
|
||||
} from '@/lib/data-grid-defaults'
|
||||
|
||||
interface DataGridShellProps<TData extends object> {
|
||||
table: Table<TData>
|
||||
recordCount: number
|
||||
isLoading?: boolean
|
||||
emptyMessage?: ReactNode
|
||||
showPagination?: boolean
|
||||
tableLayout?: typeof DATA_GRID_TABLE_LAYOUT
|
||||
className?: string
|
||||
onRowClick?: (row: TData) => void
|
||||
}
|
||||
|
||||
export function DataGridShell<TData extends object>({
|
||||
table,
|
||||
recordCount,
|
||||
isLoading = false,
|
||||
emptyMessage,
|
||||
showPagination = true,
|
||||
tableLayout = DATA_GRID_TABLE_LAYOUT,
|
||||
className,
|
||||
onRowClick,
|
||||
}: DataGridShellProps<TData>) {
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={recordCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyMessage ?? DATA_GRID_MESSAGES_RU.emptyMessage}
|
||||
loadingMessage={DATA_GRID_MESSAGES_RU.loadingMessage}
|
||||
tableLayout={tableLayout}
|
||||
className={className}
|
||||
onRowClick={onRowClick}
|
||||
>
|
||||
<DataGridContainer>
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
{showPagination ? (
|
||||
<div className={cn(panelCardFooterClassName, 'px-3 py-2')}>
|
||||
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
|
||||
</div>
|
||||
) : null}
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
|
||||
interface DataGridCardProps {
|
||||
title?: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DataGridCard({ title, description, actions, children, className }: DataGridCardProps) {
|
||||
return (
|
||||
<PanelCard
|
||||
title={title}
|
||||
description={description}
|
||||
actions={actions}
|
||||
className={className}
|
||||
contentClassName={panelCardContentFlushClassName}
|
||||
>
|
||||
{children}
|
||||
</PanelCard>
|
||||
)
|
||||
}
|
||||
|
||||
interface DataGridSectionProps<TData extends object> extends DataGridShellProps<TData> {
|
||||
searchValue: string
|
||||
onSearchChange: (value: string) => void
|
||||
searchPlaceholder?: string
|
||||
toolbarFilters?: ReactNode
|
||||
toolbarActions?: ReactNode
|
||||
beforeGrid?: ReactNode
|
||||
}
|
||||
|
||||
export function DataGridSection<TData extends object>({
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
searchPlaceholder,
|
||||
toolbarFilters,
|
||||
toolbarActions,
|
||||
beforeGrid,
|
||||
...shellProps
|
||||
}: DataGridSectionProps<TData>) {
|
||||
return (
|
||||
<>
|
||||
<DataGridToolbar
|
||||
searchValue={searchValue}
|
||||
onSearchChange={onSearchChange}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
filters={toolbarFilters}
|
||||
actions={toolbarActions}
|
||||
/>
|
||||
{beforeGrid}
|
||||
<DataGridShell {...shellProps} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Field } from '@evobgp/ui/components/field'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from '@evobgp/ui/components/input-group'
|
||||
import { ListFilterIcon, SearchIcon, XIcon } from 'lucide-react'
|
||||
|
||||
interface DataGridToolbarProps {
|
||||
searchValue: string
|
||||
onSearchChange: (value: string) => void
|
||||
searchPlaceholder?: string
|
||||
filters?: ReactNode
|
||||
actions?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Search row for data grids (ReUI c-input-group-37 pattern). */
|
||||
export function DataGridToolbar({
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
searchPlaceholder = 'Поиск…',
|
||||
filters,
|
||||
actions,
|
||||
className,
|
||||
}: DataGridToolbarProps) {
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-2 border-b px-3 py-2 ${className ?? ''}`}>
|
||||
<Field className="min-w-[200px] flex-1">
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
aria-label={searchPlaceholder}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end" className="gap-1">
|
||||
{searchValue.length > 0 ? (
|
||||
<InputGroupButton
|
||||
aria-label="Очистить поиск"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={() => onSearchChange('')}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
) : null}
|
||||
{filters ? (
|
||||
filters
|
||||
) : (
|
||||
<InputGroupButton
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="pointer-events-none gap-1.5 opacity-0"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ListFilterIcon className="size-3.5" />
|
||||
</InputGroupButton>
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { BgpCommunity } from '@/types/api'
|
||||
|
||||
export function DirectoriesCommunitiesGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: BgpCommunity[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<BgpCommunity>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
||||
cell: ({ row }) => <DataGridPrimaryCell title={row.original.title} accent="primary" />,
|
||||
meta: { headerTitle: 'Название' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'community',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell title={row.original.community} accent="mono" />
|
||||
),
|
||||
meta: { headerTitle: 'Значение' },
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
enableSorting: false,
|
||||
header: 'Тип',
|
||||
cell: () => <CategoryBadge>community</CategoryBadge>,
|
||||
meta: { headerTitle: 'Тип' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) => `${row.title} ${row.community}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет сообществ"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск сообществ…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type { DohProfile } from '@/types/api'
|
||||
|
||||
export function DirectoriesDohGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: DohProfile[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<DohProfile>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'name',
|
||||
accessorFn: (row) => row.name ?? row.url,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.name ?? row.original.url}
|
||||
subtitle={row.original.name ? row.original.url : undefined}
|
||||
accent="primary"
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Название' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'url',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="URL" />,
|
||||
cell: ({ row }) => <DataGridPrimaryCell title={row.original.url} accent="mono" />,
|
||||
meta: { headerTitle: 'URL' },
|
||||
},
|
||||
{
|
||||
id: 'default',
|
||||
enableSorting: false,
|
||||
header: 'По умолчанию',
|
||||
cell: () => <CategoryBadge>—</CategoryBadge>,
|
||||
meta: { headerTitle: 'По умолчанию' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) => `${row.name ?? ''} ${row.url}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет DoH профилей"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск DoH профилей…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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'
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { Checkbox } from "@evobgp/ui/components/checkbox"
|
||||
import { Field } from "@evobgp/ui/components/field"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@evobgp/ui/components/input-group"
|
||||
import { Label } from "@evobgp/ui/components/label"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evobgp/ui/components/popover"
|
||||
import { SearchIcon, XIcon, ListFilterIcon } from "lucide-react"
|
||||
|
||||
const statuses = ["Pending", "Shipped", "Cancelled"] as const
|
||||
|
||||
type Status = (typeof statuses)[number]
|
||||
|
||||
function toggleStatus(values: Status[], value: Status) {
|
||||
return values.includes(value)
|
||||
? values.filter((item) => item !== value)
|
||||
: [...values, value]
|
||||
}
|
||||
|
||||
export function Pattern() {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<Status[]>([])
|
||||
|
||||
return (
|
||||
<Field className="max-w-sm">
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
|
||||
<InputGroupInput
|
||||
placeholder="Search orders..."
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
/>
|
||||
|
||||
<InputGroupAddon align="inline-end" className="gap-1">
|
||||
{searchQuery.length > 0 ? (
|
||||
<InputGroupButton
|
||||
aria-label="Clear search"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
) : null}
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<InputGroupButton
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="gap-1.5"
|
||||
aria-label="Filter order status"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ListFilterIcon className="size-3.5" aria-hidden="true" />
|
||||
Status
|
||||
{selectedStatuses.length > 0 ? (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{selectedStatuses.length}
|
||||
</span>
|
||||
) : null}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-40 p-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
{statuses.map((status) => (
|
||||
<div key={status} className="flex items-center gap-2.5">
|
||||
<Checkbox
|
||||
id={`order-status-${status}`}
|
||||
checked={selectedStatuses.includes(status)}
|
||||
onCheckedChange={() =>
|
||||
setSelectedStatuses((previous) =>
|
||||
toggleStatus(previous, status)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`order-status-${status}`}
|
||||
className="text-sm font-normal"
|
||||
>
|
||||
{status}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Field } from '@evobgp/ui/components/field'
|
||||
|
||||
import { SelectMenu } from '@/components/select-field'
|
||||
|
||||
const items = [
|
||||
{ label: 'Select an item', value: 'placeholder' as const },
|
||||
...Array.from({ length: 100 }).map((_, i) => ({
|
||||
label: `Item ${i}`,
|
||||
value: `item-${i}` as const,
|
||||
})),
|
||||
]
|
||||
|
||||
export function Pattern() {
|
||||
return (
|
||||
<Field className="max-w-xs">
|
||||
<SelectMenu items={items} placeholder="Select an item" />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@evobgp/ui/components/tabs"
|
||||
import { LayoutDashboardIcon, BarChart3Icon, SettingsIcon } from "lucide-react"
|
||||
|
||||
export function Pattern() {
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col gap-6">
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="overview">
|
||||
<LayoutDashboardIcon className="size-4" />
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="analytics">
|
||||
<BarChart3Icon className="size-4" />
|
||||
Analytics
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings">
|
||||
<SettingsIcon className="size-4" />
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview">
|
||||
<Card>
|
||||
<CardContent>Overview dashboard content goes here.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="analytics">
|
||||
<Card>
|
||||
<CardContent>Analytics charts and metrics.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="settings">
|
||||
<Card>
|
||||
<CardContent>Application settings and preferences.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@evobgp/ui/components/tabs"
|
||||
|
||||
export function Pattern() {
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col gap-6">
|
||||
<Tabs defaultValue="inbox">
|
||||
<TabsList variant="line" className="mb-3.5 w-full">
|
||||
<TabsTrigger value="inbox" className="gap-2">
|
||||
Inbox
|
||||
<Badge variant="primary-light" size="sm">
|
||||
12
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="drafts" className="gap-2">
|
||||
Drafts
|
||||
<Badge variant="info-light" size="sm">
|
||||
3
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sent" className="gap-2">
|
||||
Sent
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="spam" className="gap-2">
|
||||
Spam
|
||||
<Badge variant="destructive-light" size="sm">
|
||||
24
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="inbox">
|
||||
<Card>
|
||||
<CardContent>12 unread messages in your inbox.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="drafts">
|
||||
<Card>
|
||||
<CardContent>3 drafts waiting to be sent.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="sent">
|
||||
<Card>
|
||||
<CardContent>All sent messages appear here.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="spam">
|
||||
<Card>
|
||||
<CardContent>24 spam messages detected.</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
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 type { FirewallClient } from '@/types/api'
|
||||
|
||||
function formatPacketCount(value?: number | null): string | null {
|
||||
if (value == null || value <= 0) return null
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
|
||||
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export interface FirewallClientsGridProps {
|
||||
clients: FirewallClient[]
|
||||
isLoading?: boolean
|
||||
onApprove: (id: string) => void
|
||||
onReject: (id: string) => void
|
||||
approvePending?: boolean
|
||||
rejectPending?: boolean
|
||||
emptyTitle?: string
|
||||
}
|
||||
|
||||
export function FirewallClientsGrid({
|
||||
clients,
|
||||
isLoading = false,
|
||||
onApprove,
|
||||
onReject,
|
||||
approvePending = false,
|
||||
rejectPending = false,
|
||||
emptyTitle = 'Нет клиентов',
|
||||
}: FirewallClientsGridProps) {
|
||||
const columns = useMemo<ColumnDef<FirewallClient>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.name}
|
||||
subtitle={row.original.hostname || row.original.token_prefix}
|
||||
accent="primary"
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Имя' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
{
|
||||
id: 'last_seen_at',
|
||||
accessorFn: (row) => row.last_seen_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Последняя активность" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>{row.original.last_seen_at?.slice(0, 19) ?? '—'}</DataGridMutedCell>
|
||||
),
|
||||
sortingFn: (a, b) => {
|
||||
const av = a.original.last_seen_at ?? ''
|
||||
const bv = b.original.last_seen_at ?? ''
|
||||
return av.localeCompare(bv)
|
||||
},
|
||||
meta: { headerTitle: 'Последняя активность' },
|
||||
},
|
||||
{
|
||||
id: 'apply',
|
||||
enableSorting: false,
|
||||
header: 'Применение',
|
||||
cell: ({ row }) => {
|
||||
const c = row.original
|
||||
return (
|
||||
<span className="text-xs">
|
||||
{c.last_apply_status ?? '—'}
|
||||
{c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'Применение' },
|
||||
},
|
||||
{
|
||||
id: 'packets',
|
||||
enableSorting: false,
|
||||
header: 'Пакеты',
|
||||
cell: ({ row }) => {
|
||||
const dropped = formatPacketCount(row.original.last_apply_packets_dropped)
|
||||
const accepted = formatPacketCount(row.original.last_apply_packets_accepted)
|
||||
if (!dropped && !accepted) {
|
||||
return <span className="text-muted-foreground text-xs">—</span>
|
||||
}
|
||||
return (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{dropped ? <span className="text-destructive">↓{dropped}</span> : null}
|
||||
{dropped && accepted ? ' · ' : null}
|
||||
{accepted ? <span className="text-success">↑{accepted}</span> : null}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'Пакеты' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
{c.status === 'pending' ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
disabled={approvePending}
|
||||
onClick={() => onApprove(c.id)}
|
||||
>
|
||||
Одобрить
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
disabled={rejectPending}
|
||||
>
|
||||
Отклонить
|
||||
</Button>
|
||||
}
|
||||
title="Отклонить запрос?"
|
||||
description={`${c.name}${c.hostname ? ` (${c.hostname})` : ''} — запись будет удалена, токен перестанет работать.`}
|
||||
confirmLabel="Отклонить"
|
||||
destructive
|
||||
onConfirm={() => onReject(c.id)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{c.status === 'approved' ? (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
disabled={rejectPending}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
}
|
||||
title="Удалить клиент?"
|
||||
description={`${c.name} — запись будет удалена, blocklist и токен перестанут работать.`}
|
||||
confirmLabel="Удалить"
|
||||
destructive
|
||||
onConfirm={() => onReject(c.id)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[approvePending, onApprove, onReject, rejectPending],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: clients,
|
||||
columns,
|
||||
getSearchText: (row) =>
|
||||
`${row.name} ${row.hostname ?? ''} ${row.token_prefix} ${row.status ?? ''}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyTitle}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск клиентов…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
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 { CommunitySelect } from '@/components/modules/community-select'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { useCreateFirewallRule } from '@/queries/firewall'
|
||||
import type { BgpCommunity } from '@/types/api'
|
||||
|
||||
const FIREWALL_ACTION_ITEMS = [
|
||||
{ value: 'block', label: 'block' },
|
||||
{ value: 'accept', label: 'accept' },
|
||||
] as const
|
||||
|
||||
interface FirewallRuleCreateDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
communities: BgpCommunity[]
|
||||
}
|
||||
|
||||
export function FirewallRuleCreateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
communities,
|
||||
}: FirewallRuleCreateDialogProps) {
|
||||
const createMutation = useCreateFirewallRule()
|
||||
const [action, setAction] = useState<'block' | 'accept'>('block')
|
||||
const [communityId, setCommunityId] = useState<string | null>(null)
|
||||
const [comment, setComment] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setAction('block')
|
||||
setCommunityId(null)
|
||||
setComment('')
|
||||
}, [open])
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
await createMutation.mutateAsync({
|
||||
scope: 'tenant',
|
||||
action,
|
||||
community_id: communityId,
|
||||
comment: comment.trim(),
|
||||
})
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
// toast handled in mutation
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Новое правило"
|
||||
className="sm:max-w-md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" onClick={save} loading={createMutation.isPending}>
|
||||
Добавить
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SelectField
|
||||
id="fw-rule-action"
|
||||
label="Действие"
|
||||
items={[...FIREWALL_ACTION_ITEMS]}
|
||||
value={action}
|
||||
placeholder="Выберите действие"
|
||||
onValueChange={(v) => v && setAction(v as 'block' | 'accept')}
|
||||
/>
|
||||
<CommunitySelect
|
||||
id="fw-rule-community"
|
||||
label="Community"
|
||||
value={communityId}
|
||||
onValueChange={setCommunityId}
|
||||
communities={communities}
|
||||
nullable
|
||||
placeholder="Все communities"
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="fw-rule-comment">Комментарий</Label>
|
||||
<Input
|
||||
id="fw-rule-comment"
|
||||
placeholder="Комментарий"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
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 { communityLabel } from '@/lib/modules/helpers'
|
||||
import type { BgpCommunity, FirewallRule } from '@/types/api'
|
||||
|
||||
export interface FirewallRulesGridProps {
|
||||
rules: FirewallRule[]
|
||||
communities: BgpCommunity[]
|
||||
isLoading?: boolean
|
||||
onDelete: (id: string) => void
|
||||
deletePending?: boolean
|
||||
emptyTitle?: string
|
||||
}
|
||||
|
||||
export function FirewallRulesGrid({
|
||||
rules,
|
||||
communities,
|
||||
isLoading = false,
|
||||
onDelete,
|
||||
deletePending = false,
|
||||
emptyTitle = 'Нет правил — blocklist пуст (default accept).',
|
||||
}: FirewallRulesGridProps) {
|
||||
const columns = useMemo<ColumnDef<FirewallRule>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'priority',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="#" />,
|
||||
cell: ({ row }) => row.original.priority,
|
||||
meta: { headerTitle: '#' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Действие" />,
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge status={row.original.action} label={row.original.action} />
|
||||
),
|
||||
meta: { headerTitle: 'Действие' },
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
enableSorting: false,
|
||||
header: 'Community',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm">
|
||||
{row.original.community_id
|
||||
? communityLabel(row.original.community_id, communities)
|
||||
: 'Все'}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Community' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'comment',
|
||||
enableSorting: false,
|
||||
header: 'Комментарий',
|
||||
cell: ({ row }) => row.original.comment || '—',
|
||||
meta: { headerTitle: 'Комментарий' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
disabled={deletePending}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
}
|
||||
title="Удалить правило?"
|
||||
description={
|
||||
r.comment
|
||||
? `Правило #${r.priority} (${r.action}): ${r.comment}`
|
||||
: `Правило #${r.priority} (${r.action}) будет удалено.`
|
||||
}
|
||||
confirmLabel="Удалить"
|
||||
destructive
|
||||
onConfirm={() => onDelete(r.id)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[communities, deletePending, onDelete],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: rules,
|
||||
columns,
|
||||
getSearchText: (row) =>
|
||||
`${row.priority} ${row.action} ${row.comment ?? ''} ${communityLabel(row.community_id, communities)}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyTitle}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск правил…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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'
|
||||
|
||||
interface FormDrawerProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description?: string
|
||||
children: ReactNode
|
||||
footer: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FormDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -56,7 +56,7 @@ interface NavGroup {
|
||||
const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: 'Обзор',
|
||||
items: [{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }],
|
||||
items: [{ to: '/dashboard', label: 'Панель', icon: LayoutDashboard }],
|
||||
},
|
||||
{
|
||||
label: 'Маршрутизация',
|
||||
@@ -70,7 +70,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
label: 'Операции',
|
||||
items: [
|
||||
{ to: '/operations', label: 'Операции', icon: Cog },
|
||||
{ to: '/firewall', label: 'Firewall', icon: Shield },
|
||||
{ to: '/firewall', label: 'Файрвол', icon: Shield },
|
||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks },
|
||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity },
|
||||
],
|
||||
@@ -111,7 +111,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
</div>
|
||||
<div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate text-sm font-semibold">EvoBGP</span>
|
||||
<span className="truncate text-xs text-muted-foreground">Control Plane</span>
|
||||
<span className="truncate text-xs text-muted-foreground">Плоскость управления</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evobgp/ui/components/select'
|
||||
import { Field, FieldLabel } from '@evobgp/ui/components/field'
|
||||
|
||||
import { SelectMenu } from '@/components/select-field'
|
||||
import {
|
||||
NONE_OPTION,
|
||||
communityOptionLabel,
|
||||
@@ -49,28 +43,27 @@ export function CommunitySelect({
|
||||
|
||||
const selectValue = nullable ? nullableSelectValue(value) : (value ?? '')
|
||||
|
||||
const select = (
|
||||
<SelectMenu
|
||||
id={id}
|
||||
items={items}
|
||||
value={selectValue}
|
||||
placeholder={placeholder}
|
||||
onValueChange={(v) => {
|
||||
if (!v) return
|
||||
onValueChange(nullable ? fromNullableSelect(v) : v)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!label) {
|
||||
return select
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{label ? <Label htmlFor={id}>{label}</Label> : null}
|
||||
<Select
|
||||
items={items}
|
||||
value={selectValue}
|
||||
onValueChange={(v) => {
|
||||
if (!v) return
|
||||
onValueChange(nullable ? fromNullableSelect(v) : v)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={id} className="w-full">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
{select}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@evobgp/ui/components/dialog'
|
||||
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 { ApiError, apiMutate } from '@/lib/api-client'
|
||||
@@ -72,45 +65,43 @@ export function ModuleAsEntryDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{edit ? 'Редактировать запись' : 'Новая AS-запись'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Номер автономной системы и community для политики анонса.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="as-asn">ASN</Label>
|
||||
<Input
|
||||
id="as-asn"
|
||||
type="number"
|
||||
placeholder="12345"
|
||||
value={form.asn || ''}
|
||||
min={1}
|
||||
max={4294967295}
|
||||
onChange={(e) => setForm((s) => ({ ...s, asn: Number(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
<CommunitySelect
|
||||
id="as-comm"
|
||||
label="Community"
|
||||
value={form.community_id ?? null}
|
||||
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
|
||||
communities={communities}
|
||||
nullable
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={edit ? 'Редактировать запись' : 'Новая AS-запись'}
|
||||
description="Номер автономной системы и community для политики анонса."
|
||||
className="sm:max-w-sm"
|
||||
footer={
|
||||
<>
|
||||
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</LoadingButton>
|
||||
<LoadingButton loading={saving} onClick={() => void save()}>
|
||||
{edit ? 'Сохранить' : 'Добавить'}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="as-asn">ASN</Label>
|
||||
<Input
|
||||
id="as-asn"
|
||||
type="number"
|
||||
placeholder="12345"
|
||||
value={form.asn || ''}
|
||||
min={1}
|
||||
max={4294967295}
|
||||
onChange={(e) => setForm((s) => ({ ...s, asn: Number(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
<CommunitySelect
|
||||
id="as-comm"
|
||||
label="Community"
|
||||
value={form.community_id ?? null}
|
||||
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
|
||||
communities={communities}
|
||||
nullable
|
||||
/>
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@evobgp/ui/components/dialog'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evobgp/ui/components/select'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
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 { ApiError, apiMutate } from '@/lib/api-client'
|
||||
import { normalizeCdnSourceKind } from '@/lib/modules/helpers'
|
||||
import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
|
||||
@@ -159,119 +147,107 @@ export function ModuleCdnSourceDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{edit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cdn-url">URL</Label>
|
||||
<Input
|
||||
id="cdn-url"
|
||||
placeholder="https://example.com/list.txt"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cdn-kind">Тип источника</Label>
|
||||
<Select
|
||||
items={kindItems}
|
||||
value={form.source_kind}
|
||||
onValueChange={(v) => v && setForm((s) => ({ ...s, source_kind: v }))}
|
||||
>
|
||||
<SelectTrigger id="cdn-kind" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{kindItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cdn-prefix-path">JSON path (prefix_path)</Label>
|
||||
<Input
|
||||
id="cdn-prefix-path"
|
||||
placeholder="напр. prefixes[] или data.items[].cidr"
|
||||
value={form.prefix_path ?? ''}
|
||||
onChange={(e) => setForm((s) => ({ ...s, prefix_path: e.target.value }))}
|
||||
/>
|
||||
{form.source_kind === 'json' && !form.prefix_path?.trim() ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<CommunitySelect
|
||||
id="cdn-comm"
|
||||
label="Community"
|
||||
value={form.community_id ?? null}
|
||||
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
|
||||
communities={communities}
|
||||
nullable
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cdn-interval">Интервал обновления (сек)</Label>
|
||||
<Input
|
||||
id="cdn-interval"
|
||||
type="number"
|
||||
placeholder="3600"
|
||||
value={form.refresh_interval_sec ?? ''}
|
||||
onChange={(e) =>
|
||||
setForm((s) => ({
|
||||
...s,
|
||||
refresh_interval_sec: e.target.value ? Number(e.target.value) : null,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void previewCdn()}
|
||||
disabled={previewLoading}
|
||||
>
|
||||
{previewLoading ? 'Загрузка…' : 'Предпросмотр'}
|
||||
</Button>
|
||||
{previewError ? (
|
||||
<span className="text-sm text-destructive">{previewError}</span>
|
||||
) : previewOk ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Всего: {previewTotal}
|
||||
{previewTruncated ? (
|
||||
<span className="text-amber-600 dark:text-amber-500"> (обрезано)</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{previewItems.length > 0 ? (
|
||||
<ul className="max-h-48 overflow-y-auto rounded-md border bg-muted/40 p-2 font-mono text-xs">
|
||||
{previewItems.map((item, i) => (
|
||||
<li key={`${i}-${item}`} className="py-0.5">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={edit ? 'Редактировать источник' : 'Новый CDN-источник'}
|
||||
className="sm:max-w-lg"
|
||||
footer={
|
||||
<>
|
||||
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</LoadingButton>
|
||||
<LoadingButton loading={saving} onClick={() => void save()}>
|
||||
{edit ? 'Сохранить' : 'Добавить'}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cdn-url">URL</Label>
|
||||
<Input
|
||||
id="cdn-url"
|
||||
placeholder="https://example.com/list.txt"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
id="cdn-kind"
|
||||
label="Тип источника"
|
||||
items={kindItems}
|
||||
value={form.source_kind}
|
||||
onValueChange={(v) => v && setForm((s) => ({ ...s, source_kind: v }))}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cdn-prefix-path">JSON path (prefix_path)</Label>
|
||||
<Input
|
||||
id="cdn-prefix-path"
|
||||
placeholder="напр. prefixes[] или data.items[].cidr"
|
||||
value={form.prefix_path ?? ''}
|
||||
onChange={(e) => setForm((s) => ({ ...s, prefix_path: e.target.value }))}
|
||||
/>
|
||||
{form.source_kind === 'json' && !form.prefix_path?.trim() ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<CommunitySelect
|
||||
id="cdn-comm"
|
||||
label="Community"
|
||||
value={form.community_id ?? null}
|
||||
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
|
||||
communities={communities}
|
||||
nullable
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cdn-interval">Интервал обновления (сек)</Label>
|
||||
<Input
|
||||
id="cdn-interval"
|
||||
type="number"
|
||||
placeholder="3600"
|
||||
value={form.refresh_interval_sec ?? ''}
|
||||
onChange={(e) =>
|
||||
setForm((s) => ({
|
||||
...s,
|
||||
refresh_interval_sec: e.target.value ? Number(e.target.value) : null,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void previewCdn()}
|
||||
disabled={previewLoading}
|
||||
>
|
||||
{previewLoading ? 'Загрузка…' : 'Предпросмотр'}
|
||||
</Button>
|
||||
{previewError ? (
|
||||
<span className="text-sm text-destructive">{previewError}</span>
|
||||
) : previewOk ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Всего: {previewTotal}
|
||||
{previewTruncated ? (
|
||||
<span className="text-amber-600 dark:text-amber-500"> (обрезано)</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{previewItems.length > 0 ? (
|
||||
<ul className="max-h-48 overflow-y-auto rounded-md border bg-muted/40 p-2 font-mono text-xs">
|
||||
{previewItems.map((item, i) => (
|
||||
<li key={`${i}-${item}`} className="py-0.5">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@evobgp/ui/components/dialog'
|
||||
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 { ApiError, apiMutate } from '@/lib/api-client'
|
||||
@@ -70,39 +64,39 @@ export function ModuleDomainEntryDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{edit ? 'Редактировать домен' : 'Новый домен'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="dom-fqdn">FQDN</Label>
|
||||
<Input
|
||||
id="dom-fqdn"
|
||||
placeholder="example.com"
|
||||
value={form.fqdn}
|
||||
onChange={(e) => setForm((s) => ({ ...s, fqdn: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<CommunitySelect
|
||||
id="dom-comm"
|
||||
label="Community"
|
||||
value={form.community_id ?? null}
|
||||
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
|
||||
communities={communities}
|
||||
nullable
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={edit ? 'Редактировать домен' : 'Новый домен'}
|
||||
className="sm:max-w-sm"
|
||||
footer={
|
||||
<>
|
||||
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</LoadingButton>
|
||||
<LoadingButton loading={saving} onClick={() => void save()}>
|
||||
{edit ? 'Сохранить' : 'Добавить'}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="dom-fqdn">FQDN</Label>
|
||||
<Input
|
||||
id="dom-fqdn"
|
||||
placeholder="example.com"
|
||||
value={form.fqdn}
|
||||
onChange={(e) => setForm((s) => ({ ...s, fqdn: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<CommunitySelect
|
||||
id="dom-comm"
|
||||
label="Community"
|
||||
value={form.community_id ?? null}
|
||||
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
|
||||
communities={communities}
|
||||
nullable
|
||||
/>
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { formatDateTime } from '@/lib/modules/display'
|
||||
import { communityLabel } from '@/lib/modules/helpers'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import type {
|
||||
AsEntry,
|
||||
BgpCommunity,
|
||||
CdnSource,
|
||||
DomainEntry,
|
||||
IpRangeEntry,
|
||||
ModuleRow,
|
||||
} from '@/types/api'
|
||||
|
||||
type DeleteTarget =
|
||||
| { kind: 'domain'; entry: DomainEntry }
|
||||
| { kind: 'ip-range'; entry: IpRangeEntry }
|
||||
| { kind: 'cdn'; entry: CdnSource }
|
||||
| { kind: 'as'; entry: AsEntry }
|
||||
|
||||
function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) {
|
||||
return (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon-sm" type="button" onClick={onEdit} aria-label="Редактировать">
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
onClick={onDelete}
|
||||
aria-label="Удалить"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModuleEntriesGrid({
|
||||
mod,
|
||||
rows,
|
||||
communities,
|
||||
onEdit,
|
||||
onDelete,
|
||||
isLoading = false,
|
||||
}: {
|
||||
mod: ModuleRow
|
||||
rows: Record<string, unknown>[]
|
||||
communities: BgpCommunity[]
|
||||
onEdit: (target: DeleteTarget) => void
|
||||
onDelete: (target: DeleteTarget) => void
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const columns = useMemo(() => {
|
||||
if (mod.type === 'DOMAINS') {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'fqdn',
|
||||
header: ({ column }: { column: { id: string } }) => (
|
||||
<DataGridColumnHeader column={column as never} title="FQDN" />
|
||||
),
|
||||
cell: ({ row }: { row: { original: DomainEntry } }) => (
|
||||
<DataGridPrimaryCell title={row.original.fqdn} accent="mono" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
header: 'Community',
|
||||
cell: ({ row }: { row: { original: DomainEntry } }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{communityLabel(row.original.community_id, communities)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }: { row: { original: DomainEntry } }) => (
|
||||
<RowActions
|
||||
onEdit={() => onEdit({ kind: 'domain', entry: row.original })}
|
||||
onDelete={() => onDelete({ kind: 'domain', entry: row.original })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] as ColumnDef<DomainEntry>[]
|
||||
}
|
||||
|
||||
if (mod.type === 'IP_RANGES') {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'prefix',
|
||||
header: ({ column }: { column: { id: string } }) => (
|
||||
<DataGridColumnHeader column={column as never} title="Префикс (CIDR)" />
|
||||
),
|
||||
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
|
||||
<DataGridPrimaryCell title={row.original.prefix} accent="mono" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
header: 'Community',
|
||||
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{communityLabel(row.original.community_id, communities)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
|
||||
<RowActions
|
||||
onEdit={() => onEdit({ kind: 'ip-range', entry: row.original })}
|
||||
onDelete={() => onDelete({ kind: 'ip-range', entry: row.original })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] as ColumnDef<IpRangeEntry>[]
|
||||
}
|
||||
|
||||
if (mod.type === 'CDN_CIDRS') {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'url',
|
||||
header: ({ column }: { column: { id: string } }) => (
|
||||
<DataGridColumnHeader column={column as never} title="URL" />
|
||||
),
|
||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
||||
<DataGridPrimaryCell title={row.original.url} accent="mono" className="max-w-xs" />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'source_kind',
|
||||
header: 'Тип',
|
||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
||||
<CategoryBadge>{row.original.source_kind}</CategoryBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
header: 'Community',
|
||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{communityLabel(row.original.community_id, communities)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'last_refreshed_at',
|
||||
accessorFn: (row: CdnSource) => row.last_refreshed_at ?? '',
|
||||
header: ({ column }: { column: { id: string } }) => (
|
||||
<DataGridColumnHeader column={column as never} title="Обновлено" />
|
||||
),
|
||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
||||
<DataGridMutedCell>{formatDateTime(row.original.last_refreshed_at)}</DataGridMutedCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
||||
<RowActions
|
||||
onEdit={() => onEdit({ kind: 'cdn', entry: row.original })}
|
||||
onDelete={() => onDelete({ kind: 'cdn', entry: row.original })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] as ColumnDef<CdnSource>[]
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: 'asn',
|
||||
header: ({ column }: { column: { id: string } }) => (
|
||||
<DataGridColumnHeader column={column as never} title="ASN" />
|
||||
),
|
||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
||||
<span className="font-mono text-sm">{row.original.asn}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'asn_name',
|
||||
header: 'Имя',
|
||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
||||
<span className="text-sm">{row.original.asn_name ?? '—'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'prefix_count',
|
||||
header: 'Префиксов',
|
||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
||||
<span className="font-mono text-sm">{row.original.prefix_count ?? '—'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
header: 'Community',
|
||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{communityLabel(row.original.community_id, communities)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }: { row: { original: AsEntry } }) => (
|
||||
<RowActions
|
||||
onEdit={() => onEdit({ kind: 'as', entry: row.original })}
|
||||
onDelete={() => onDelete({ kind: 'as', entry: row.original })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] as ColumnDef<AsEntry>[]
|
||||
}, [communities, mod.type, onDelete, onEdit])
|
||||
|
||||
type RowType = DomainEntry | IpRangeEntry | CdnSource | AsEntry
|
||||
const data = rows as unknown as RowType[]
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data,
|
||||
columns: columns as ColumnDef<RowType>[],
|
||||
getSearchText: (row) => {
|
||||
const r = row as Record<string, unknown>
|
||||
return Object.values(r)
|
||||
.filter((v) => typeof v === 'string' || typeof v === 'number')
|
||||
.join(' ')
|
||||
},
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет записей"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск записей…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type { DeleteTarget as ModuleEntryDeleteTarget }
|
||||
@@ -1,37 +1,19 @@
|
||||
import { useState } from 'react'
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@evobgp/ui/components/alert-dialog'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { ModuleEntriesGrid, type ModuleEntryDeleteTarget } from '@/components/modules/module-entries-grid'
|
||||
import { ModuleAsEntryDialog } from '@/components/modules/module-as-entry-dialog'
|
||||
import { ModuleCdnSourceDialog } from '@/components/modules/module-cdn-source-dialog'
|
||||
import { ModuleDomainEntryDialog } from '@/components/modules/module-domain-entry-dialog'
|
||||
import { ModuleIpRangeEntryDialog } from '@/components/modules/module-ip-range-entry-dialog'
|
||||
import { ApiError, apiMutate } from '@/lib/api-client'
|
||||
import { communityLabel } from '@/lib/modules/helpers'
|
||||
import { formatDateTime } from '@/lib/modules/display'
|
||||
import type {
|
||||
AsEntry,
|
||||
BgpCommunity,
|
||||
@@ -53,11 +35,7 @@ interface ModuleEntriesSectionProps {
|
||||
onChanged: () => void | Promise<void>
|
||||
}
|
||||
|
||||
type DeleteTarget =
|
||||
| { kind: 'domain'; entry: DomainEntry }
|
||||
| { kind: 'ip-range'; entry: IpRangeEntry }
|
||||
| { kind: 'cdn'; entry: CdnSource }
|
||||
| { kind: 'as'; entry: AsEntry }
|
||||
type DeleteTarget = ModuleEntryDeleteTarget
|
||||
|
||||
const CARD_META: Record<
|
||||
ModuleRow['type'],
|
||||
@@ -150,49 +128,45 @@ export function ModuleEntriesSection({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base">{meta.title}</CardTitle>
|
||||
<CardDescription>{meta.description}</CardDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus />
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle={meta.emptyTitle}
|
||||
emptyDescription={meta.emptyDescription}
|
||||
skeleton={<TableSkeleton rows={5} cols={3} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(rows) => (
|
||||
<EntriesTable
|
||||
mod={mod}
|
||||
rows={rows}
|
||||
communities={communities}
|
||||
onEdit={(target) => {
|
||||
if (target.kind === 'domain') setEditDomain(target.entry)
|
||||
if (target.kind === 'ip-range') setEditIpRange(target.entry)
|
||||
if (target.kind === 'cdn') setEditCdn(target.entry)
|
||||
if (target.kind === 'as') setEditAs(target.entry)
|
||||
setDialogOpen(true)
|
||||
}}
|
||||
onDelete={setDeleteTarget}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DataGridCard
|
||||
title={meta.title}
|
||||
description={meta.description}
|
||||
actions={
|
||||
<Button size="sm" type="button" onClick={openCreate}>
|
||||
<Plus />
|
||||
Добавить
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle={meta.emptyTitle}
|
||||
emptyDescription={meta.emptyDescription}
|
||||
skeleton={<TableSkeleton rows={5} cols={3} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(rows) => (
|
||||
<ModuleEntriesGrid
|
||||
mod={mod}
|
||||
rows={rows}
|
||||
communities={communities}
|
||||
isLoading={isLoading}
|
||||
onEdit={(target) => {
|
||||
if (target.kind === 'domain') setEditDomain(target.entry)
|
||||
if (target.kind === 'ip-range') setEditIpRange(target.entry)
|
||||
if (target.kind === 'cdn') setEditCdn(target.entry)
|
||||
if (target.kind === 'as') setEditAs(target.entry)
|
||||
setDialogOpen(true)
|
||||
}}
|
||||
onDelete={setDeleteTarget}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
|
||||
{mod.type === 'DOMAINS' ? (
|
||||
<ModuleDomainEntryDialog
|
||||
@@ -235,24 +209,17 @@ export function ModuleEntriesSection({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Удалить запись?</AlertDialogTitle>
|
||||
<AlertDialogDescription>{deleteDescription(deleteTarget)}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleting}
|
||||
onClick={() => void confirmDelete()}
|
||||
>
|
||||
{deleting ? 'Удаление…' : 'Удалить'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
title="Удалить запись?"
|
||||
description={deleteDescription(deleteTarget)}
|
||||
confirmLabel="Удалить"
|
||||
confirmLoadingLabel="Удаление…"
|
||||
destructive
|
||||
confirmLoading={deleting}
|
||||
onConfirm={() => void confirmDelete()}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -270,168 +237,3 @@ function deleteDescription(target: DeleteTarget | null): string {
|
||||
return `AS${target.entry.asn}`
|
||||
}
|
||||
}
|
||||
|
||||
function EntriesTable({
|
||||
mod,
|
||||
rows,
|
||||
communities,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
mod: ModuleRow
|
||||
rows: Record<string, unknown>[]
|
||||
communities: BgpCommunity[]
|
||||
onEdit: (target: DeleteTarget) => void
|
||||
onDelete: (target: DeleteTarget) => void
|
||||
}) {
|
||||
if (mod.type === 'DOMAINS') {
|
||||
const entries = rows as unknown as DomainEntry[]
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>FQDN</TableHead>
|
||||
<TableHead>Community</TableHead>
|
||||
<TableHead className="w-20" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((entry) => (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell className="font-mono text-sm">{entry.fqdn}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{communityLabel(entry.community_id, communities)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RowActions
|
||||
onEdit={() => onEdit({ kind: 'domain', entry })}
|
||||
onDelete={() => onDelete({ kind: 'domain', entry })}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
if (mod.type === 'IP_RANGES') {
|
||||
const entries = rows as unknown as IpRangeEntry[]
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Префикс (CIDR)</TableHead>
|
||||
<TableHead>Community</TableHead>
|
||||
<TableHead className="w-20" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((entry) => (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell className="font-mono text-sm">{entry.prefix}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{communityLabel(entry.community_id, communities)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RowActions
|
||||
onEdit={() => onEdit({ kind: 'ip-range', entry })}
|
||||
onDelete={() => onDelete({ kind: 'ip-range', entry })}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
if (mod.type === 'CDN_CIDRS') {
|
||||
const entries = rows as unknown as CdnSource[]
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>URL</TableHead>
|
||||
<TableHead>Тип</TableHead>
|
||||
<TableHead>Community</TableHead>
|
||||
<TableHead>Обновлено</TableHead>
|
||||
<TableHead className="w-20" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((entry) => (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell className="max-w-xs truncate font-mono text-xs">{entry.url}</TableCell>
|
||||
<TableCell className="text-sm">{entry.source_kind}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{communityLabel(entry.community_id, communities)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatDateTime(entry.last_refreshed_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RowActions
|
||||
onEdit={() => onEdit({ kind: 'cdn', entry })}
|
||||
onDelete={() => onDelete({ kind: 'cdn', entry })}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
const entries = rows as unknown as AsEntry[]
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ASN</TableHead>
|
||||
<TableHead>Имя</TableHead>
|
||||
<TableHead>Префиксов</TableHead>
|
||||
<TableHead>Community</TableHead>
|
||||
<TableHead className="w-20" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((entry) => (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell className="font-mono text-sm">{entry.asn}</TableCell>
|
||||
<TableCell className="text-sm">{entry.asn_name ?? '—'}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{entry.prefix_count ?? '—'}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{communityLabel(entry.community_id, communities)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RowActions
|
||||
onEdit={() => onEdit({ kind: 'as', entry })}
|
||||
onDelete={() => onDelete({ kind: 'as', entry })}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) {
|
||||
return (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon-sm" onClick={onEdit} aria-label="Редактировать">
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive"
|
||||
onClick={onDelete}
|
||||
aria-label="Удалить"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@evobgp/ui/components/dialog'
|
||||
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 { ApiError, apiMutate } from '@/lib/api-client'
|
||||
@@ -70,39 +64,39 @@ export function ModuleIpRangeEntryDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ip-prefix">Префикс (CIDR)</Label>
|
||||
<Input
|
||||
id="ip-prefix"
|
||||
placeholder="203.0.113.0/24"
|
||||
value={form.prefix}
|
||||
onChange={(e) => setForm((s) => ({ ...s, prefix: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<CommunitySelect
|
||||
id="ip-comm"
|
||||
label="Community (обязательно)"
|
||||
value={form.community_id || null}
|
||||
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v ?? '' }))}
|
||||
communities={communities}
|
||||
placeholder="Выберите community"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}
|
||||
className="sm:max-w-sm"
|
||||
footer={
|
||||
<>
|
||||
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</LoadingButton>
|
||||
<LoadingButton loading={saving} onClick={() => void save()}>
|
||||
{edit ? 'Сохранить' : 'Добавить'}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ip-prefix">Префикс (CIDR)</Label>
|
||||
<Input
|
||||
id="ip-prefix"
|
||||
placeholder="203.0.113.0/24"
|
||||
value={form.prefix}
|
||||
onChange={(e) => setForm((s) => ({ ...s, prefix: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<CommunitySelect
|
||||
id="ip-comm"
|
||||
label="Community (обязательно)"
|
||||
value={form.community_id || null}
|
||||
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v ?? '' }))}
|
||||
communities={communities}
|
||||
placeholder="Выберите community"
|
||||
/>
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Boxes } from 'lucide-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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import { moduleTypeRu } from '@/lib/ui-labels'
|
||||
import type { ModuleRow } from '@/types/api'
|
||||
|
||||
export function ModulesListGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: ModuleRow[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const columns = useMemo<ColumnDef<ModuleRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Boxes className="size-4 shrink-0 text-muted-foreground" />
|
||||
<DataGridPrimaryCell title={row.original.name} accent="primary" className="max-w-[280px]" />
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: 'Название' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => <CategoryBadge>{moduleTypeRu(row.original.type)}</CategoryBadge>,
|
||||
meta: { headerTitle: 'Тип' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'priority',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Приоритет" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
|
||||
),
|
||||
meta: { headerTitle: 'Приоритет' },
|
||||
},
|
||||
{
|
||||
id: 'enabled',
|
||||
accessorFn: (row) => (row.enabled ? 'enabled' : 'disabled'),
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => <ModeBadge enabled={row.original.enabled} />,
|
||||
meta: { headerTitle: 'Состояние' },
|
||||
},
|
||||
{
|
||||
id: 'last_refreshed_at',
|
||||
accessorFn: (row) => row.last_refreshed_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>
|
||||
{row.original.last_refreshed_at
|
||||
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
sortingFn: (a, b) => {
|
||||
const av = a.original.last_refreshed_at ?? ''
|
||||
const bv = b.original.last_refreshed_at ?? ''
|
||||
return av.localeCompare(bv)
|
||||
},
|
||||
meta: { headerTitle: 'Обновлено' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) => `${row.name} ${row.type} ${row.enabled ? 'включён' : 'выключен'}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет модулей"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск модулей…"
|
||||
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { Database, HardDrive, HeartPulse, ListTodo, ShieldCheck } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
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 type { ReadyStatus } from '@/queries/monitoring'
|
||||
|
||||
const READY_CHECK_ICONS: Record<string, typeof Database> = {
|
||||
postgres: Database,
|
||||
store: HardDrive,
|
||||
jobs: ListTodo,
|
||||
}
|
||||
|
||||
interface ReadyCheckRow {
|
||||
id: string
|
||||
label: string
|
||||
subtitle?: string
|
||||
icon: typeof Database
|
||||
status: string
|
||||
statusLabel: string
|
||||
}
|
||||
|
||||
export function MonitoringReadyGrid({
|
||||
health,
|
||||
ready,
|
||||
}: {
|
||||
health?: { ok?: boolean; status?: string; error?: string } | null
|
||||
ready: ReadyStatus
|
||||
}) {
|
||||
const data = useMemo<ReadyCheckRow[]>(() => {
|
||||
const checks = ready.checks ?? {}
|
||||
const rows: ReadyCheckRow[] = [
|
||||
{
|
||||
id: 'liveness',
|
||||
label: 'Живучесть',
|
||||
subtitle: '/v1/health',
|
||||
icon: HeartPulse,
|
||||
status: health?.ok ? 'ok' : 'error',
|
||||
statusLabel: health?.ok ? 'В норме' : 'Ошибка',
|
||||
},
|
||||
{
|
||||
id: 'readiness',
|
||||
label: 'Готовность',
|
||||
subtitle: '/v1/ready',
|
||||
icon: ShieldCheck,
|
||||
status: ready.status === 'ok' ? 'ok' : 'warning',
|
||||
statusLabel: ready.status === 'ok' ? 'Готов' : jobStatusRu(ready.status ?? 'pending'),
|
||||
},
|
||||
]
|
||||
for (const key of Object.keys(checks)) {
|
||||
const value = checks[key]
|
||||
const ok = typeof value === 'boolean' ? value : value?.ok !== false
|
||||
rows.push({
|
||||
id: key,
|
||||
label: readyCheckRu(key),
|
||||
icon: READY_CHECK_ICONS[key] ?? ListTodo,
|
||||
status: ok ? 'ok' : 'error',
|
||||
statusLabel: ok ? 'В норме' : 'Ошибка',
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}, [health?.ok, ready.checks, ready.status])
|
||||
|
||||
const columns = useMemo<ColumnDef<ReadyCheckRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'label',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Проверка" />,
|
||||
cell: ({ row }) => {
|
||||
const Icon = row.original.icon
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.label}
|
||||
subtitle={row.original.subtitle}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'Проверка' },
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
enableSorting: false,
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge status={row.original.status} label={row.original.statusLabel} />
|
||||
),
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data,
|
||||
columns,
|
||||
getSearchText: (row) => `${row.label} ${row.subtitle ?? ''} ${row.statusLabel}`,
|
||||
getRowId: (row) => row.id,
|
||||
pageSize: 20,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
showPagination={false}
|
||||
emptyMessage="Нет проверок"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск проверок…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { NetworkPeersGrid } from '@/components/network/network-peers-grid'
|
||||
import { PeerFormDialog } from '@/components/network/peer-form-dialog'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
interface NetworkPeersCardProps {
|
||||
items: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
error: unknown
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
export function NetworkPeersCard({
|
||||
items,
|
||||
speakers,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
onRetry,
|
||||
}: NetworkPeersCardProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataGridCard
|
||||
title="Пиры"
|
||||
description="BGP-соседи и привязка к спикерам"
|
||||
actions={
|
||||
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
|
||||
<Plus />
|
||||
Добавить пира
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle="Нет пиров"
|
||||
emptyDescription="Добавьте первого BGP-соседа для установки сессии."
|
||||
skeleton={<TableSkeleton rows={6} cols={4} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(data) => (
|
||||
<NetworkPeersGrid items={data} isLoading={isLoading && data.length > 0} />
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
|
||||
<PeerFormDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
speakers={speakers}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
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 { 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 { bgpSessionStateRu } from '@/lib/ui-labels'
|
||||
import type { PeerRow } from '@/types/api'
|
||||
|
||||
export function NetworkPeersGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: PeerRow[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<PeerRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'name',
|
||||
accessorFn: (row) => row.name ?? row.neighbor,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={row.original.name ?? row.original.neighbor}
|
||||
subtitle={row.original.name ? row.original.neighbor : undefined}
|
||||
accent="primary"
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Имя' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'neighbor',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
|
||||
),
|
||||
meta: { headerTitle: 'Адрес соседа' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'remote_asn',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span>
|
||||
),
|
||||
meta: { headerTitle: 'ASN' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'session_state',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<StatusBadge
|
||||
status={row.original.session_state ?? '—'}
|
||||
label={bgpSessionStateRu(row.original.session_state)}
|
||||
/>
|
||||
{row.original.session_mismatch ? (
|
||||
<CategoryBadge tone="warning">расхождение</CategoryBadge>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: 'Состояние' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) =>
|
||||
`${row.name ?? ''} ${row.neighbor} ${row.remote_asn ?? ''} ${row.session_state ?? ''}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет пиров"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск пиров…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { NetworkSpeakersGrid } from '@/components/network/network-speakers-grid'
|
||||
import { SpeakerFormDialog } from '@/components/network/speaker-form-dialog'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import type { SpeakerRow } from '@/types/api'
|
||||
|
||||
interface NetworkSpeakersCardProps {
|
||||
items: SpeakerRow[]
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
error: unknown
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
export function NetworkSpeakersCard({
|
||||
items,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
onRetry,
|
||||
}: NetworkSpeakersCardProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataGridCard
|
||||
title="Спикеры"
|
||||
description="BIRD-агенты на нодах tenant"
|
||||
actions={
|
||||
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
|
||||
<Plus />
|
||||
Добавить спикера
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle="Нет спикеров"
|
||||
emptyDescription="Добавьте первого BIRD-агента на ноде."
|
||||
skeleton={<TableSkeleton rows={6} cols={4} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(data) => (
|
||||
<NetworkSpeakersGrid items={data} isLoading={isLoading && data.length > 0} />
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
|
||||
<SpeakerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
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 { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import { speakerOnlineLabel } from '@/lib/ui-labels'
|
||||
import type { SpeakerRow } from '@/types/api'
|
||||
|
||||
export function NetworkSpeakersGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: SpeakerRow[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<SpeakerRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'endpoint',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
|
||||
),
|
||||
meta: { headerTitle: 'Конечная точка' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
||||
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
|
||||
meta: { headerTitle: 'Роль' },
|
||||
},
|
||||
{
|
||||
id: 'agent',
|
||||
enableSorting: false,
|
||||
header: 'Агент',
|
||||
cell: ({ row }) => {
|
||||
const live = row.original.live
|
||||
if (live?.agent_ok === true) return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
|
||||
if (live?.agent_ok === false) return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
|
||||
return <Badge variant="outline" size="sm" radius="full">—</Badge>
|
||||
},
|
||||
meta: { headerTitle: 'Агент' },
|
||||
},
|
||||
{
|
||||
id: 'bgp',
|
||||
enableSorting: false,
|
||||
header: 'BGP',
|
||||
cell: ({ row }) => {
|
||||
const live = row.original.live
|
||||
if (!live) return '—'
|
||||
return (
|
||||
<span className="text-xs">
|
||||
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'BGP' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) =>
|
||||
`${row.endpoint} ${row.role} ${row.agent_domain ?? ''} ${row.node_ipv4 ?? ''}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет спикеров"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск спикеров…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
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 { SelectField } from '@/components/select-field'
|
||||
import { useCreatePeerMutation, useUpdatePeerMutation } from '@/queries/network'
|
||||
import type { BgpPeerCreate, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
interface PeerFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
speakers: SpeakerRow[]
|
||||
editTarget?: PeerRow | null
|
||||
}
|
||||
|
||||
function speakerLabel(s: SpeakerRow): string {
|
||||
if (s.role === 'master') {
|
||||
const host = s.agent_domain ?? s.endpoint
|
||||
return host ? `CP · ${host}` : 'CP (master)'
|
||||
}
|
||||
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
||||
}
|
||||
|
||||
export function PeerFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
speakers,
|
||||
editTarget = null,
|
||||
}: PeerFormDialogProps) {
|
||||
const createMutation = useCreatePeerMutation()
|
||||
const updateMutation = useUpdatePeerMutation()
|
||||
const saving = createMutation.isPending || updateMutation.isPending
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [neighbor, setNeighbor] = useState('')
|
||||
const [remoteAsn, setRemoteAsn] = useState('')
|
||||
const [bgpSpeakerId, setBgpSpeakerId] = useState<string | null>(null)
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (editTarget) {
|
||||
setName(editTarget.name ?? '')
|
||||
setNeighbor(editTarget.neighbor)
|
||||
setRemoteAsn(String(editTarget.remote_asn ?? ''))
|
||||
setBgpSpeakerId(editTarget.bgp_speaker_id ?? null)
|
||||
setEnabled(editTarget.enabled !== false)
|
||||
} else {
|
||||
setName('')
|
||||
setNeighbor('')
|
||||
setRemoteAsn('')
|
||||
setBgpSpeakerId(null)
|
||||
setEnabled(true)
|
||||
}
|
||||
}, [editTarget, open])
|
||||
|
||||
const speakerItems = [
|
||||
{ value: '', label: 'Все спикеры' },
|
||||
...speakers.map((s) => ({ value: s.id, label: speakerLabel(s) })),
|
||||
]
|
||||
|
||||
async function save() {
|
||||
if (!neighbor.trim()) {
|
||||
toast.error('Укажите адрес соседа')
|
||||
return
|
||||
}
|
||||
const asn = Number(remoteAsn)
|
||||
if (!asn || asn <= 0) {
|
||||
toast.error('Remote ASN должен быть больше 0')
|
||||
return
|
||||
}
|
||||
const body: BgpPeerCreate = {
|
||||
name: name.trim() || undefined,
|
||||
neighbor: neighbor.trim(),
|
||||
remote_asn: asn,
|
||||
bgp_speaker_id: bgpSpeakerId || null,
|
||||
enabled,
|
||||
}
|
||||
try {
|
||||
if (editTarget) {
|
||||
await updateMutation.mutateAsync({ id: editTarget.id, body })
|
||||
} else {
|
||||
await createMutation.mutateAsync(body)
|
||||
}
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
// toast in mutation
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={editTarget ? 'Редактировать пира' : 'Новый пир'}
|
||||
description="BGP-сосед для установки сессии"
|
||||
className="sm:max-w-sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={saving} onClick={save}>
|
||||
{editTarget ? 'Сохранить' : 'Создать'}
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="peer-name">Имя пира (опционально)</Label>
|
||||
<Input
|
||||
id="peer-name"
|
||||
placeholder="Core-RTR-1"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="peer-neighbor">Адрес соседа</Label>
|
||||
<Input
|
||||
id="peer-neighbor"
|
||||
placeholder="192.0.2.1"
|
||||
value={neighbor}
|
||||
onChange={(e) => setNeighbor(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="peer-asn">Remote ASN</Label>
|
||||
<Input
|
||||
id="peer-asn"
|
||||
type="number"
|
||||
placeholder="65000"
|
||||
value={remoteAsn}
|
||||
onChange={(e) => setRemoteAsn(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
id="peer-speaker"
|
||||
label="Спикер (опционально)"
|
||||
items={speakerItems}
|
||||
value={bgpSpeakerId ?? ''}
|
||||
onValueChange={(v) => setBgpSpeakerId(v || null)}
|
||||
placeholder="Все спикеры"
|
||||
/>
|
||||
<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="peer-enabled">Включён</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Выключенный пир не попадает в конфиг BIRD до следующей ревизии.
|
||||
</p>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="peer-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={(v) => setEnabled(v === true)}
|
||||
/>
|
||||
</div>
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
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 { SelectField } from '@/components/select-field'
|
||||
import { useCreateSpeakerMutation } from '@/queries/network'
|
||||
import type { BgpSpeakerCreate } from '@/types/api'
|
||||
|
||||
interface SpeakerFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
function parseIpv4FromEndpoint(ep: string): string {
|
||||
try {
|
||||
const u = ep.includes('://') ? new URL(ep) : new URL(`https://${ep}`)
|
||||
const host = u.hostname
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return host
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function buildMetaJson(agentDomain: string, nodeIpv4: string, bgpSource: string): string {
|
||||
const meta: Record<string, string> = {}
|
||||
if (agentDomain.trim()) meta.agent_domain = agentDomain.trim()
|
||||
if (nodeIpv4.trim()) meta.node_ipv4 = nodeIpv4.trim()
|
||||
if (bgpSource.trim()) meta.bird_bgp_source_ipv4 = bgpSource.trim()
|
||||
return JSON.stringify(meta)
|
||||
}
|
||||
|
||||
export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps) {
|
||||
const createMutation = useCreateSpeakerMutation()
|
||||
|
||||
const [endpoint, setEndpoint] = useState('')
|
||||
const [role, setRole] = useState('replica')
|
||||
const [agentDomain, setAgentDomain] = useState('')
|
||||
const [nodeIpv4, setNodeIpv4] = useState('')
|
||||
const [bgpSourceIpv4, setBgpSourceIpv4] = useState('')
|
||||
const [bgpSourceManual, setBgpSourceManual] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setEndpoint('')
|
||||
setRole('replica')
|
||||
setAgentDomain('')
|
||||
setNodeIpv4('')
|
||||
setBgpSourceIpv4('')
|
||||
setBgpSourceManual(false)
|
||||
}, [open])
|
||||
|
||||
function handleEndpointChange(value: string) {
|
||||
setEndpoint(value)
|
||||
const ip = parseIpv4FromEndpoint(value)
|
||||
if (ip && !nodeIpv4) {
|
||||
handleNodeIpv4Change(ip)
|
||||
}
|
||||
}
|
||||
|
||||
function handleNodeIpv4Change(value: string) {
|
||||
setNodeIpv4(value)
|
||||
if (!bgpSourceManual) {
|
||||
setBgpSourceIpv4(value)
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const ep =
|
||||
endpoint.trim() || (agentDomain.trim() ? `https://${agentDomain.trim()}` : '')
|
||||
if (!ep) {
|
||||
toast.error('Укажите endpoint или agent domain')
|
||||
return
|
||||
}
|
||||
const body: BgpSpeakerCreate = {
|
||||
endpoint: ep,
|
||||
role: role.trim() || 'replica',
|
||||
meta_json: buildMetaJson(agentDomain, nodeIpv4, bgpSourceIpv4),
|
||||
}
|
||||
try {
|
||||
await createMutation.mutateAsync(body)
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
// toast in mutation
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Новый спикер"
|
||||
description="BIRD-агент на ноде реплики или control plane"
|
||||
className="sm:max-w-md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-endpoint">Конечная точка</Label>
|
||||
<Input
|
||||
id="speaker-endpoint"
|
||||
placeholder="https://node.example.com:8443"
|
||||
value={endpoint}
|
||||
onChange={(e) => handleEndpointChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
id="speaker-role"
|
||||
label="Роль"
|
||||
items={[
|
||||
{ value: 'replica', label: 'Реплика' },
|
||||
{ value: 'master', label: 'Мастер (CP)' },
|
||||
]}
|
||||
value={role}
|
||||
onValueChange={(v) => setRole(v ?? 'replica')}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-agent-domain">Домен агента</Label>
|
||||
<Input
|
||||
id="speaker-agent-domain"
|
||||
placeholder="bird-agent.example.com"
|
||||
value={agentDomain}
|
||||
onChange={(e) => setAgentDomain(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-node-ipv4">IPv4 ноды</Label>
|
||||
<Input
|
||||
id="speaker-node-ipv4"
|
||||
placeholder="203.0.113.10"
|
||||
value={nodeIpv4}
|
||||
onChange={(e) => handleNodeIpv4Change(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-bgp-source">BGP source IPv4</Label>
|
||||
<Input
|
||||
id="speaker-bgp-source"
|
||||
placeholder="203.0.113.10"
|
||||
value={bgpSourceIpv4}
|
||||
onChange={(e) => {
|
||||
setBgpSourceManual(true)
|
||||
setBgpSourceIpv4(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
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 { apiMutate } from '@/lib/api-client'
|
||||
import { jobKindRu } from '@/lib/ui-labels'
|
||||
import type { JobRow } from '@/types/api'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
export function OperationsJobsGrid({
|
||||
items,
|
||||
nameById,
|
||||
qc,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: JobRow[]
|
||||
nameById: Map<string, string>
|
||||
qc: QueryClient
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: (jobId: string) => apiMutate(`/v1/jobs/${jobId}/cancel`, 'POST', {}),
|
||||
onSuccess: () => {
|
||||
toast.success('Задача отменена')
|
||||
void qc.invalidateQueries({ queryKey: ['operations'] })
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отменить'),
|
||||
})
|
||||
|
||||
const columns = useMemo<ColumnDef<JobRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'kind',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
title={jobKindRu(row.original.kind)}
|
||||
accent="mono"
|
||||
subtitle={
|
||||
row.original.meta?.module_id
|
||||
? (nameById.get(String(row.original.meta.module_id)) ??
|
||||
String(row.original.meta.module_id))
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: 'Вид' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||
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: 'Статус' },
|
||||
},
|
||||
{
|
||||
id: 'created_at',
|
||||
accessorFn: (row) => row.created_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>
|
||||
{row.original.created_at
|
||||
? new Date(row.original.created_at).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Создана' },
|
||||
},
|
||||
{
|
||||
id: 'finished_at',
|
||||
accessorFn: (row) => row.finished_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Завершена" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>
|
||||
{row.original.finished_at
|
||||
? new Date(row.original.finished_at).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Завершена' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) =>
|
||||
row.original.status === 'running' || row.original.status === 'queued' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
className="text-destructive"
|
||||
onClick={() => cancelMutation.mutate(row.original.job_id)}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
],
|
||||
[cancelMutation, nameById],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) => {
|
||||
const moduleName = row.meta?.module_id
|
||||
? (nameById.get(String(row.meta.module_id)) ?? String(row.meta.module_id))
|
||||
: ''
|
||||
return `${jobKindRu(row.kind)} ${row.status} ${row.job_id} ${moduleName}`
|
||||
},
|
||||
getRowId: (row) => row.job_id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет задач"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск задач…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import { apiMutate } from '@/lib/api-client'
|
||||
import type { RevisionRow } from '@/types/api'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
export function OperationsRevisionsGrid({
|
||||
items,
|
||||
qc,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: RevisionRow[]
|
||||
qc: QueryClient
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const rollbackMutation = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiMutate(`/v1/revisions/${id}/rollback`, 'POST', {}).then(() => id),
|
||||
onSuccess: () => {
|
||||
toast.success('Откат выполнен')
|
||||
void qc.invalidateQueries({ queryKey: ['operations'] })
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось откатить'),
|
||||
})
|
||||
|
||||
const columns = useMemo<ColumnDef<RevisionRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'id',
|
||||
accessorFn: (row) => row.id,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell title={`${row.original.id.slice(0, 12)}…`} accent="mono" />
|
||||
),
|
||||
meta: { headerTitle: 'ID' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>
|
||||
{new Date(row.original.created_at).toLocaleString('ru-RU')}
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Создана' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'materialized_prefix_count',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Префиксов" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm tabular-nums">
|
||||
{row.original.materialized_prefix_count}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Префиксов' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) => (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" type="button" className="text-destructive">
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
|
||||
description="Будет создана новая ревизия на основе выбранной. Требуется роль operator."
|
||||
confirmLabel="Откатить"
|
||||
destructive
|
||||
onConfirm={() => rollbackMutation.mutate(row.original.id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[rollbackMutation],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) => `${row.id} ${row.materialized_prefix_count}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет ревизий"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск ревизий…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@evobgp/ui/components/card'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
/** REUI c-data-grid-19: flush card shell with compact header spacing. */
|
||||
export const panelCardClassName = 'gap-0 py-0'
|
||||
export const panelCardHeaderClassName = 'border-b [.border-b]:pb-3'
|
||||
export const panelCardContentFlushClassName = 'px-0'
|
||||
export const panelCardFooterClassName = 'border-t bg-transparent py-0'
|
||||
|
||||
interface PanelCardProps {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
footer?: ReactNode
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
headerClassName?: string
|
||||
contentClassName?: string
|
||||
footerClassName?: string
|
||||
size?: 'default' | 'sm'
|
||||
}
|
||||
|
||||
export function PanelCard({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
footer,
|
||||
children,
|
||||
className,
|
||||
headerClassName,
|
||||
contentClassName,
|
||||
footerClassName,
|
||||
size = 'sm',
|
||||
}: PanelCardProps) {
|
||||
const hasHeader = Boolean(title || description || actions)
|
||||
|
||||
return (
|
||||
<Card size={size} className={cn(panelCardClassName, className)}>
|
||||
{hasHeader ? (
|
||||
<CardHeader className={cn(panelCardHeaderClassName, headerClassName)}>
|
||||
{title ? <CardTitle>{title}</CardTitle> : null}
|
||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||
{actions ? <CardAction>{actions}</CardAction> : null}
|
||||
</CardHeader>
|
||||
) : null}
|
||||
{children != null && children !== false ? (
|
||||
<CardContent className={contentClassName}>{children}</CardContent>
|
||||
) : null}
|
||||
{footer ? (
|
||||
<CardFooter className={cn(panelCardFooterClassName, footerClassName)}>{footer}</CardFooter>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,11 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3",
|
||||
[
|
||||
"relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -19,19 +23,19 @@ const badgeVariants = cva(
|
||||
focus: "bg-focus text-focus-foreground",
|
||||
invert: "bg-invert text-invert-foreground",
|
||||
"primary-light":
|
||||
"bg-primary/10 border-none text-primary dark:bg-primary/20",
|
||||
"border-primary/10 bg-primary/10 text-primary dark:border-primary/25 dark:bg-primary/15 dark:text-primary",
|
||||
"warning-light":
|
||||
"bg-warning/10 border-none text-warning-foreground dark:bg-warning/20",
|
||||
"border-warning/15 bg-warning/10 text-warning-foreground dark:border-warning/25 dark:bg-warning/15 dark:text-warning",
|
||||
"success-light":
|
||||
"bg-success/10 border-none text-success-foreground dark:bg-success/20",
|
||||
"border-success/15 bg-success/10 text-success-foreground dark:border-success/25 dark:bg-success/15 dark:text-success",
|
||||
"info-light":
|
||||
"bg-info/10 border-none text-info-foreground dark:bg-info/20",
|
||||
"border-info/15 bg-info/10 text-info-foreground dark:border-info/25 dark:bg-info/15 dark:text-info",
|
||||
"destructive-light":
|
||||
"bg-destructive/10 border-none text-destructive-foreground dark:bg-destructive/20",
|
||||
"border-destructive/15 bg-destructive/10 text-destructive-foreground dark:border-destructive/25 dark:bg-destructive/15 dark:text-destructive",
|
||||
"invert-light":
|
||||
"bg-invert/10 border-none text-foreground dark:bg-invert/20",
|
||||
"border-invert/15 bg-invert/10 text-foreground dark:border-invert/45 dark:bg-invert/35 dark:text-invert-foreground",
|
||||
"focus-light":
|
||||
"bg-focus/10 border-none text-focus-foreground dark:bg-focus/20",
|
||||
"border-focus/15 bg-focus/10 text-focus-foreground dark:border-focus/25 dark:bg-focus/15 dark:text-focus",
|
||||
"primary-outline":
|
||||
"bg-background border-border text-primary dark:bg-input/30",
|
||||
"warning-outline":
|
||||
@@ -54,7 +58,7 @@ const badgeVariants = cva(
|
||||
lg: "px-1.5 py-0.5 text-xs h-5.5 min-w-5.5 gap-1",
|
||||
xl: "px-2 py-0.75 text-sm h-6 min-w-6 gap-1.5",
|
||||
},
|
||||
/** `default`: per-theme radius. `full`: max radius per theme (Lyra stays `rounded-none`). */
|
||||
/** `default`: active style radius. `full`: pill radius. */
|
||||
radius: {
|
||||
default:
|
||||
"rounded-sm",
|
||||
|
||||
@@ -4,12 +4,8 @@ import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
SelectMenu,
|
||||
} from "@/components/select-field"
|
||||
import { Skeleton } from "@evobgp/ui/components/skeleton"
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
@@ -144,36 +140,31 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
mergedProps?.className
|
||||
)}
|
||||
>
|
||||
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
|
||||
<div className="order-2 flex flex-wrap items-center gap-2 pb-2.5 sm:order-1 sm:pb-0">
|
||||
{isLoading ? (
|
||||
mergedProps?.sizesSkeleton
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<div className="shrink-0 text-sm text-muted-foreground whitespace-nowrap">
|
||||
{mergedProps.rowsPerPageLabel}
|
||||
</div>
|
||||
<Select
|
||||
items={mergedProps?.sizes?.map((size: number) => ({
|
||||
value: `${size}`,
|
||||
label: `${size}`,
|
||||
}))}
|
||||
<SelectMenu
|
||||
items={
|
||||
mergedProps?.sizes?.map((size: number) => ({
|
||||
value: `${size}`,
|
||||
label: `${size}`,
|
||||
})) ?? []
|
||||
}
|
||||
value={`${pageSize}`}
|
||||
triggerClassName="min-w-20 w-auto tabular-nums"
|
||||
size="sm"
|
||||
side="top"
|
||||
contentClassName="min-w-20"
|
||||
onValueChange={(value) => {
|
||||
const newPageSize = Number(value)
|
||||
table.setPageSize(newPageSize)
|
||||
if (!value) return
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-14" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top" className="min-w-18">
|
||||
{mergedProps?.sizes?.map((size: number) => (
|
||||
<SelectItem key={size} value={`${size}`}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
DataGridTableViewport,
|
||||
getDataGridTableRowSections,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import { DATA_GRID_MESSAGES_RU } from "@/lib/data-grid-defaults"
|
||||
import { flexRender, HeaderGroup, Row, Table } from "@tanstack/react-table"
|
||||
import {
|
||||
useVirtualizer,
|
||||
@@ -304,9 +305,9 @@ function DataGridTableVirtual<TData>({
|
||||
|
||||
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
||||
const loadingMoreMessage =
|
||||
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
|
||||
props.fetchingMoreMessage || props.loadingMessage || DATA_GRID_MESSAGES_RU.loadingMessage
|
||||
const allRowsLoadedMessage =
|
||||
props.allRowsLoadedMessage || "All records loaded"
|
||||
props.allRowsLoadedMessage || DATA_GRID_MESSAGES_RU.allRecordsLoadedMessage
|
||||
|
||||
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
||||
setViewportElements({
|
||||
|
||||
@@ -28,6 +28,7 @@ import { cva } from "class-variance-authority"
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import { Checkbox } from "@evobgp/ui/components/checkbox"
|
||||
import { Spinner } from "@evobgp/ui/components/spinner"
|
||||
import { DATA_GRID_MESSAGES_RU } from "@/lib/data-grid-defaults"
|
||||
|
||||
const headerCellSpacingVariants = cva("", {
|
||||
variants: {
|
||||
@@ -1098,7 +1099,7 @@ function DataGridTableEmpty() {
|
||||
colSpan={Math.max(visibleColumnCount, 1)}
|
||||
className="text-muted-foreground text-sm py-6 text-center"
|
||||
>
|
||||
{props.emptyMessage || "No data available"}
|
||||
{props.emptyMessage || DATA_GRID_MESSAGES_RU.emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
@@ -1111,7 +1112,7 @@ function DataGridTableLoader() {
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="text-muted-foreground bg-card rounded-lg text-sm flex items-center gap-2 border px-4 py-2 leading-none font-medium">
|
||||
<Spinner className="size-5 opacity-60" />
|
||||
{props.loadingMessage || "Loading..."}
|
||||
{props.loadingMessage || DATA_GRID_MESSAGES_RU.loadingMessage}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -1123,7 +1124,7 @@ function DataGridTableRowPin<TData>({ row }: { row: Row<TData> }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isPinned ? "Unpin row" : "Pin row"}
|
||||
aria-label={isPinned ? DATA_GRID_MESSAGES_RU.unpinRowLabel : DATA_GRID_MESSAGES_RU.pinRowLabel}
|
||||
onClick={() => {
|
||||
if (isPinned) {
|
||||
row.pin(false)
|
||||
@@ -1179,7 +1180,7 @@ function DataGridTableRowSelect<TData>({ row }: { row: Row<TData> }) {
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
aria-label={DATA_GRID_MESSAGES_RU.selectRowLabel}
|
||||
className="align-[inherit]"
|
||||
/>
|
||||
</>
|
||||
@@ -1198,7 +1199,7 @@ function DataGridTableRowSelectAll() {
|
||||
indeterminate={isSomeSelected && !isAllSelected}
|
||||
disabled={isLoading || recordCount === 0}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
aria-label={DATA_GRID_MESSAGES_RU.selectAllLabel}
|
||||
className="align-[inherit]"
|
||||
/>
|
||||
)
|
||||
@@ -1249,7 +1250,7 @@ function DataGridTableBodyRows<TData>({ table }: { table: Table<TData> }) {
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{props.loadingMessage || "Loading..."}
|
||||
{props.loadingMessage || DATA_GRID_MESSAGES_RU.loadingMessage}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
/**
|
||||
* CSS variable architecture for FramePanel theming:
|
||||
*
|
||||
* The Frame parent sets --frame-panel-bg and --frame-panel-border-color.
|
||||
* FramePanel consumes them directly via bg-(--frame-panel-bg) and
|
||||
* border-(--frame-panel-border-color). This means:
|
||||
*
|
||||
* - variant="inverse" overrides those vars on Frame → all panels pick it up
|
||||
* - <FramePanel className="bg-blue-50"> adds a direct utility on the element
|
||||
* which wins over bg-(--frame-panel-bg) by Tailwind source order — no
|
||||
* :not() or !important needed
|
||||
*/
|
||||
const frameVariants = cva(
|
||||
[
|
||||
"relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)",
|
||||
"(--radius-xl)] [--frame-radius:var(--radius-xl)]",
|
||||
"(--radius-none)] (--radius-2xl)] (--radius-lg)] (--radius-none)]",
|
||||
"[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]",
|
||||
"[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]",
|
||||
"[--frame-panel-px:calc(var(--frame-panel-px-base)_+_var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)_+_var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)_+_var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)_+_var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)_+_var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)_+_var(--frame-panel-footer-py-adjust))]",
|
||||
"(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]",
|
||||
// Default panel token values — overridden per-variant below
|
||||
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border border-[var(--frame-border-color)] bg-clip-padding",
|
||||
inverse:
|
||||
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
|
||||
ghost: "",
|
||||
},
|
||||
spacing: {
|
||||
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(1)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(1)] (3)] (1)] (3)] (3)]",
|
||||
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(2.5)] (2)] (2)] (2)]",
|
||||
default:
|
||||
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(3)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(3)] (2)] (2)] (2)]",
|
||||
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(4)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(4)] (2)] (2)] (2)]",
|
||||
},
|
||||
stacked: {
|
||||
true: [
|
||||
"gap-0 *:has-[+[data-slot=frame-panel]]:rounded-b-none",
|
||||
"*:has-[+[data-slot=frame-panel]]:before:hidden",
|
||||
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:rounded-t-none",
|
||||
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:border-t-0",
|
||||
],
|
||||
false: [
|
||||
"data-[spacing=sm]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-0.5",
|
||||
"data-[spacing=default]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-1",
|
||||
"data-[spacing=lg]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-2",
|
||||
],
|
||||
},
|
||||
dense: {
|
||||
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars
|
||||
true: "p-0 gap-0 border-[var(--frame-border-color)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
spacing: "default",
|
||||
stacked: false,
|
||||
dense: false,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Frame({
|
||||
className,
|
||||
variant,
|
||||
spacing,
|
||||
stacked,
|
||||
dense,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof frameVariants>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
frameVariants({ variant, spacing, stacked, dense }),
|
||||
className
|
||||
)}
|
||||
data-slot="frame"
|
||||
data-spacing={spacing}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FramePanel({
|
||||
className,
|
||||
fit,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { fit?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
|
||||
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
|
||||
// via className overrides these by Tailwind source order - no ! needed.
|
||||
"relative overflow-hidden rounded-(--frame-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
|
||||
// `fit` sizes the panel to its content; otherwise it grows to fill the frame.
|
||||
!fit && "grow",
|
||||
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-radius)-1px)] before:shadow-black/5",
|
||||
"dark:bg-clip-border dark:before:shadow-white/5",
|
||||
"px-(--frame-panel-px) py-(--frame-panel-py)",
|
||||
className
|
||||
)}
|
||||
data-slot="frame-panel"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FrameHeader({ className, ...props }: React.ComponentProps<"header">) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex flex-col gap-(--frame-panel-header-gap) px-(--frame-panel-header-px) py-(--frame-panel-header-py)",
|
||||
className
|
||||
)}
|
||||
data-slot="frame-panel-header"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FrameTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
data-slot="frame-panel-title"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FrameDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
data-slot="frame-panel-description"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FrameFooter({ className, ...props }: React.ComponentProps<"footer">) {
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
"flex flex-col gap-(--frame-panel-footer-gap) px-(--frame-panel-footer-px) py-(--frame-panel-footer-py)",
|
||||
className
|
||||
)}
|
||||
data-slot="frame-panel-footer"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Frame,
|
||||
FramePanel,
|
||||
FrameHeader,
|
||||
FrameTitle,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
frameVariants,
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
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 { jobKindRu } from '@/lib/ui-labels'
|
||||
import type { JobRow } from '@/types/api'
|
||||
|
||||
export function ScheduleJobsGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: JobRow[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<JobRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'kind',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
||||
cell: ({ row }) => <DataGridPrimaryCell title={jobKindRu(row.original.kind)} accent="mono" />,
|
||||
meta: { headerTitle: 'Вид' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||
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: 'Статус' },
|
||||
},
|
||||
{
|
||||
id: 'created_at',
|
||||
accessorFn: (row) => row.created_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>
|
||||
{row.original.created_at
|
||||
? new Date(row.original.created_at).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Создана' },
|
||||
},
|
||||
{
|
||||
id: 'finished_at',
|
||||
accessorFn: (row) => row.finished_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Завершена" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>
|
||||
{row.original.finished_at
|
||||
? new Date(row.original.finished_at).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Завершена' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'error',
|
||||
enableSorting: false,
|
||||
header: 'Ошибка',
|
||||
cell: ({ row }) => (
|
||||
<span className="max-w-xs truncate text-xs text-destructive">
|
||||
{row.original.error ?? ''}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Ошибка' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) => `${jobKindRu(row.kind)} ${row.status} ${row.error ?? ''}`,
|
||||
getRowId: (row) => row.job_id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет задач"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск задач…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { RefreshCw } from 'lucide-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 { LoadingButton } from '@/components/loading-button'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
import { moduleTypeRu } from '@/lib/ui-labels'
|
||||
import type { ModuleRow } from '@/types/api'
|
||||
|
||||
export function ScheduleModulesGrid({
|
||||
items,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: ModuleRow[]
|
||||
refreshing: Record<string, boolean>
|
||||
onRefresh: (id: string) => void
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<ModuleRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
|
||||
cell: ({ row }) => <DataGridPrimaryCell title={row.original.name} accent="primary" />,
|
||||
meta: { headerTitle: 'Модуль' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => <CategoryBadge>{moduleTypeRu(row.original.type)}</CategoryBadge>,
|
||||
meta: { headerTitle: 'Тип' },
|
||||
},
|
||||
{
|
||||
id: 'schedule',
|
||||
accessorFn: (row) => row.cron_expr ?? String(row.refresh_interval_sec ?? ''),
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Расписание" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{row.original.cron_expr ??
|
||||
(row.original.refresh_interval_sec ? `${row.original.refresh_interval_sec}s` : '—')}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Расписание' },
|
||||
},
|
||||
{
|
||||
id: 'last_refreshed_at',
|
||||
accessorFn: (row) => row.last_refreshed_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>
|
||||
{row.original.last_refreshed_at
|
||||
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Обновлено' },
|
||||
},
|
||||
{
|
||||
id: 'enabled',
|
||||
accessorFn: (row) => (row.enabled ? 'on' : 'off'),
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => (
|
||||
<ModeBadge enabled={row.original.enabled} onLabel="Вкл" offLabel="Выкл" />
|
||||
),
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
loading={!!refreshing[row.original.id]}
|
||||
onClick={() => onRefresh(row.original.id)}
|
||||
>
|
||||
<RefreshCw />
|
||||
Обновить
|
||||
</LoadingButton>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[onRefresh, refreshing],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) => `${row.name} ${row.type} ${row.enabled ? 'вкл' : 'выкл'}`,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет модулей"
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск модулей…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export function SectionCards({ items, className }: { items: SectionCardItem[]; c
|
||||
{items.map((item, idx) => {
|
||||
const clickable = Boolean(item.onClick)
|
||||
const content = (
|
||||
<CardContent className="flex items-start gap-2.5 px-3 py-2.5">
|
||||
<CardContent className="flex items-start gap-2.5 px-3 py-2">
|
||||
{item.icon ? (
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground">
|
||||
{item.icon}
|
||||
@@ -80,8 +80,9 @@ export function SectionCards({ items, className }: { items: SectionCardItem[]; c
|
||||
return (
|
||||
<Card
|
||||
key={typeof item.label === 'string' ? item.label : idx}
|
||||
size="sm"
|
||||
className={cn(
|
||||
'gap-0',
|
||||
'gap-0 py-0',
|
||||
VARIANT_CLASS[item.variant ?? 'default'],
|
||||
item.active && 'border-primary ring-1 ring-primary/30',
|
||||
clickable && 'cursor-pointer transition-colors hover:bg-muted/40',
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
|
||||
import { Field, FieldDescription, FieldLabel } from '@evobgp/ui/components/field'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evobgp/ui/components/select'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export type SelectMenuItem<T extends string = string> = {
|
||||
value: T
|
||||
label: ReactNode
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type SelectRootProps = ComponentProps<typeof Select>
|
||||
|
||||
export interface SelectMenuProps<T extends string = string>
|
||||
extends Omit<SelectRootProps, 'children' | 'onValueChange' | 'value' | 'defaultValue'> {
|
||||
items: ReadonlyArray<SelectMenuItem<T>>
|
||||
value?: T | null
|
||||
defaultValue?: T | null
|
||||
onValueChange?: (value: T | null) => void
|
||||
placeholder?: string
|
||||
id?: string
|
||||
triggerClassName?: string
|
||||
contentClassName?: string
|
||||
size?: 'sm' | 'default'
|
||||
side?: ComponentProps<typeof SelectContent>['side']
|
||||
}
|
||||
|
||||
/** ReUI c-select-4: `items` на Root, опции в `SelectGroup`. */
|
||||
export function SelectMenu<T extends string = string>({
|
||||
items,
|
||||
placeholder,
|
||||
id,
|
||||
triggerClassName,
|
||||
contentClassName,
|
||||
size = 'default',
|
||||
side,
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
...selectProps
|
||||
}: SelectMenuProps<T>) {
|
||||
return (
|
||||
<Select
|
||||
items={items}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={(next) => onValueChange?.(next as T | null)}
|
||||
{...selectProps}
|
||||
>
|
||||
<SelectTrigger id={id} className={cn('w-full', triggerClassName)} size={size}>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side={side} className={contentClassName}>
|
||||
<SelectGroup>
|
||||
{items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value} disabled={item.disabled}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SelectFieldProps<T extends string = string> extends SelectMenuProps<T> {
|
||||
label?: ReactNode
|
||||
description?: ReactNode
|
||||
fieldClassName?: string
|
||||
}
|
||||
|
||||
export function SelectField<T extends string = string>({
|
||||
label,
|
||||
description,
|
||||
fieldClassName,
|
||||
id,
|
||||
...menuProps
|
||||
}: SelectFieldProps<T>) {
|
||||
return (
|
||||
<Field className={fieldClassName}>
|
||||
{label ? <FieldLabel htmlFor={id}>{label}</FieldLabel> : null}
|
||||
<SelectMenu id={id} {...menuProps} />
|
||||
{description ? (
|
||||
typeof description === 'string' ? (
|
||||
<FieldDescription className="font-mono text-xs">{description}</FieldDescription>
|
||||
) : (
|
||||
description
|
||||
)
|
||||
) : null}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGridSection } from '@/components/data-grid-shell'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||
|
||||
export interface SettingsKvRow {
|
||||
id: string | number
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export function SettingsKvGrid({
|
||||
items,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: SettingsKvRow[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<SettingsKvRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'key',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Ключ" />,
|
||||
cell: ({ row }) => <DataGridPrimaryCell title={row.original.key} accent="mono" />,
|
||||
meta: { headerTitle: 'Ключ' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'value',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />,
|
||||
cell: ({ row }) => <DataGridPrimaryCell title={row.original.value} accent="mono" />,
|
||||
meta: { headerTitle: 'Значение' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||
data: items,
|
||||
columns,
|
||||
getSearchText: (row) => `${row.key} ${row.value}`,
|
||||
getRowId: (row) => String(row.id),
|
||||
pageSize: 25,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridSection
|
||||
table={table}
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет дополнительных настроек"
|
||||
showPagination={items.length > 10}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск настроек…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SectionCards } from './section-cards'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
|
||||
import { SectionCards } from './section-cards'
|
||||
|
||||
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
|
||||
return (
|
||||
@@ -14,18 +15,50 @@ export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function AnalyticsDashboardSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-3 lg:items-start">
|
||||
<Card size="sm" className="gap-0 py-0">
|
||||
<CardContent className="space-y-4 py-4">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-2 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card size="sm" className="gap-0 py-0">
|
||||
<CardContent className="space-y-4 py-4">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
<Skeleton className="h-36 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card size="sm" className="gap-0 py-0">
|
||||
<CardContent className="space-y-4 py-4">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-44 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
|
||||
return (
|
||||
<Card className="gap-0">
|
||||
<Card size="sm" className="gap-0 py-0">
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex gap-2 border-b p-3">
|
||||
<div className="flex gap-2 border-b px-3 py-2">
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<Skeleton className="h-4 flex-1" key={`h-${i}`} />
|
||||
))}
|
||||
</div>
|
||||
{Array.from({ length: rows }).map((_, r) => (
|
||||
<div className="flex gap-2 border-b p-3" key={`r-${r}`}>
|
||||
<div className="flex gap-2 border-b px-3 py-2" key={`r-${r}`}>
|
||||
{Array.from({ length: cols }).map((_, c) => (
|
||||
<Skeleton className="h-4 flex-1" key={`c-${r}-${c}`} />
|
||||
))}
|
||||
|
||||
@@ -1,30 +1,72 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { jobStatusRu } from '@/lib/ui-labels'
|
||||
|
||||
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
||||
|
||||
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||
active: 'success',
|
||||
ok: 'success',
|
||||
paid: 'success',
|
||||
established: 'success',
|
||||
succeeded: 'success',
|
||||
healthy: 'success',
|
||||
paused: 'secondary',
|
||||
disabled: 'secondary',
|
||||
active: 'success-light',
|
||||
ok: 'success-light',
|
||||
paid: 'success-light',
|
||||
established: 'success-light',
|
||||
succeeded: 'success-light',
|
||||
healthy: 'success-light',
|
||||
approved: 'success-light',
|
||||
accept: 'success-light',
|
||||
paused: 'invert-light',
|
||||
disabled: 'invert-light',
|
||||
archived: 'outline',
|
||||
error: 'destructive',
|
||||
failed: 'destructive',
|
||||
running: 'info',
|
||||
queued: 'info',
|
||||
overdue: 'warning',
|
||||
stale: 'warning',
|
||||
warning: 'warning',
|
||||
mismatch: 'warning',
|
||||
error: 'destructive-light',
|
||||
failed: 'destructive-light',
|
||||
revoked: 'destructive-light',
|
||||
block: 'destructive-light',
|
||||
cancelled: 'destructive-light',
|
||||
running: 'info-light',
|
||||
queued: 'info-light',
|
||||
overdue: 'warning-light',
|
||||
stale: 'warning-light',
|
||||
warning: 'warning-light',
|
||||
mismatch: 'warning-light',
|
||||
pending: 'warning-light',
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, label }: { status: string; label?: string }) {
|
||||
const variant = STATUS_VARIANT[status.toLowerCase()] ?? 'outline'
|
||||
return <Badge variant={variant}>{label ?? status}</Badge>
|
||||
const DOT_COLOR: Record<string, string> = {
|
||||
'success-light': 'bg-success',
|
||||
'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 ?? jobStatusRu(status)}
|
||||
</Badge>
|
||||
{hint ? <span className="text-xs text-muted-foreground">{hint}</span> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
type ColumnDef,
|
||||
type FilterFn,
|
||||
type TableOptions,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
|
||||
import {
|
||||
createClientDataGridOptions,
|
||||
createTextGlobalFilter,
|
||||
} from '@/lib/data-grid-defaults'
|
||||
|
||||
interface UseClientDataGridOptions<TData extends object> {
|
||||
data: TData[]
|
||||
columns: ColumnDef<TData>[]
|
||||
getSearchText: (row: TData) => string
|
||||
getRowId: (row: TData) => string
|
||||
pageSize?: number
|
||||
tableOptions?: Partial<TableOptions<TData>>
|
||||
globalFilterFn?: FilterFn<TData>
|
||||
}
|
||||
|
||||
export function useClientDataGrid<TData extends object>({
|
||||
data,
|
||||
columns,
|
||||
getSearchText,
|
||||
getRowId,
|
||||
pageSize = 10,
|
||||
tableOptions,
|
||||
globalFilterFn,
|
||||
}: UseClientDataGridOptions<TData>) {
|
||||
const [globalFilter, setGlobalFilter] = useState('')
|
||||
|
||||
const filterFn = useMemo(
|
||||
() => globalFilterFn ?? createTextGlobalFilter(getSearchText),
|
||||
[getSearchText, globalFilterFn],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: { globalFilter },
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
globalFilterFn: filterFn,
|
||||
...createClientDataGridOptions<TData>({
|
||||
initialState: { pagination: { pageSize } },
|
||||
...tableOptions,
|
||||
}),
|
||||
getRowId,
|
||||
})
|
||||
|
||||
const filteredCount = table.getFilteredRowModel().rows.length
|
||||
|
||||
return {
|
||||
table,
|
||||
globalFilter,
|
||||
setGlobalFilter,
|
||||
filteredCount,
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { ApiKeyRole } from '@/types/api'
|
||||
|
||||
import { apiKeyRoleRu } from '@/lib/ui-labels'
|
||||
|
||||
export const API_KEY_ROLE_ITEMS: ReadonlyArray<{ value: ApiKeyRole; label: string }> = [
|
||||
{ value: 'viewer', label: 'viewer — только чтение' },
|
||||
{ value: 'editor', label: 'editor — CRUD без apply' },
|
||||
{ value: 'operator', label: 'operator — полный доступ' },
|
||||
{ value: 'node', label: 'node — только API ноды' },
|
||||
{ value: 'viewer', label: `${apiKeyRoleRu('viewer')} — только чтение` },
|
||||
{ value: 'editor', label: `${apiKeyRoleRu('editor')} — CRUD без применения` },
|
||||
{ value: 'operator', label: `${apiKeyRoleRu('operator')} — полный доступ` },
|
||||
{ value: 'node', label: `${apiKeyRoleRu('node')} — только API ноды` },
|
||||
]
|
||||
|
||||
export function apiKeyRoleLabel(role: ApiKeyRole): string {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
type FilterFn,
|
||||
type TableOptions,
|
||||
} from '@tanstack/react-table'
|
||||
|
||||
import type { DataGridProps } from '@/components/reui/data-grid/data-grid'
|
||||
|
||||
export const DATA_GRID_TABLE_LAYOUT: NonNullable<DataGridProps<object>['tableLayout']> = {
|
||||
dense: true,
|
||||
headerSticky: true,
|
||||
rowBorder: true,
|
||||
}
|
||||
|
||||
export const DATA_GRID_PAGINATION_RU = {
|
||||
sizes: [10, 25, 50] as number[],
|
||||
sizesLabel: 'Показать',
|
||||
sizesDescription: 'на странице',
|
||||
info: '{from}–{to} из {count}',
|
||||
rowsPerPageLabel: 'Строк на странице',
|
||||
previousPageLabel: 'Предыдущая страница',
|
||||
nextPageLabel: 'Следующая страница',
|
||||
}
|
||||
|
||||
export const DATA_GRID_MESSAGES_RU = {
|
||||
emptyMessage: 'Нет данных',
|
||||
loadingMessage: 'Загрузка…',
|
||||
fetchingMoreMessage: 'Загрузка…',
|
||||
selectAllLabel: 'Выбрать все',
|
||||
selectRowLabel: 'Выбрать строку',
|
||||
pinRowLabel: 'Закрепить строку',
|
||||
unpinRowLabel: 'Открепить строку',
|
||||
allRecordsLoadedMessage: 'Все записи загружены',
|
||||
}
|
||||
|
||||
export const DATA_GRID_DENSE_LAYOUT: NonNullable<DataGridProps<object>['tableLayout']> = {
|
||||
...DATA_GRID_TABLE_LAYOUT,
|
||||
dense: true,
|
||||
}
|
||||
|
||||
export function createTextGlobalFilter<TData extends object>(
|
||||
getSearchText: (row: TData) => string,
|
||||
): FilterFn<TData> {
|
||||
return (row, _columnId, filterValue) => {
|
||||
const query = String(filterValue ?? '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
if (!query) return true
|
||||
return getSearchText(row.original).toLowerCase().includes(query)
|
||||
}
|
||||
}
|
||||
|
||||
export function createClientDataGridOptions<TData extends object>(
|
||||
overrides?: Partial<TableOptions<TData>>,
|
||||
): Pick<
|
||||
TableOptions<TData>,
|
||||
| 'getCoreRowModel'
|
||||
| 'getFilteredRowModel'
|
||||
| 'getSortedRowModel'
|
||||
| 'getPaginationRowModel'
|
||||
| 'initialState'
|
||||
> {
|
||||
return {
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
initialState: { pagination: { pageSize: 10 } },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { SpeakerRow } from '@/types/api'
|
||||
|
||||
import type { DeploymentProgress } from './types'
|
||||
|
||||
export function deploymentProgress(speakers: SpeakerRow[]): DeploymentProgress {
|
||||
if (speakers.length === 0) {
|
||||
return { percent: 0, synced: 0, total: 0, mode: 'online' }
|
||||
}
|
||||
|
||||
const withRevision = speakers.filter(
|
||||
(s) => s.published_revision_id && s.last_applied_revision_id,
|
||||
)
|
||||
if (withRevision.length > 0) {
|
||||
const synced = withRevision.filter(
|
||||
(s) => s.published_revision_id === s.last_applied_revision_id,
|
||||
).length
|
||||
return {
|
||||
percent: Math.round((synced / withRevision.length) * 100),
|
||||
synced,
|
||||
total: withRevision.length,
|
||||
mode: 'revision',
|
||||
}
|
||||
}
|
||||
|
||||
const online = speakers.filter((s) => s.live?.agent_ok).length
|
||||
return {
|
||||
percent: Math.round((online / speakers.length) * 100),
|
||||
synced: online,
|
||||
total: speakers.length,
|
||||
mode: 'online',
|
||||
}
|
||||
}
|
||||
|
||||
export function deploymentProgressMeta(deploy: DeploymentProgress): {
|
||||
label: string
|
||||
hint: string
|
||||
} {
|
||||
if (deploy.total === 0) {
|
||||
return {
|
||||
label: 'Деплой на спикерах',
|
||||
hint: 'Нет зарегистрированных BIRD-спикеров',
|
||||
}
|
||||
}
|
||||
if (deploy.mode === 'revision') {
|
||||
return {
|
||||
label: `Применена ревизия (${deploy.synced} из ${deploy.total} спикеров)`,
|
||||
hint: 'Доля спикеров, на которых последняя опубликованная ревизия уже применена',
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: `Спикеры в сети (${deploy.synced} из ${deploy.total})`,
|
||||
hint: 'Ревизии ещё не публиковались — показана доступность агента на нодах',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './types'
|
||||
export * from './job-status-breakdown'
|
||||
export * from './module-type-breakdown'
|
||||
export * from './peer-capacity-bars'
|
||||
export * from './deployment-progress'
|
||||
export * from './readiness-breakdown'
|
||||
export * from './peer-session-breakdown'
|
||||
export * from './recent-platform-activity'
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { JobRow } from '@/types/api'
|
||||
|
||||
import type { BreakdownSlice } from './types'
|
||||
|
||||
const STATUS_BUCKETS: { keys: string[]; label: string; color: string }[] = [
|
||||
{ keys: ['succeeded', 'success'], label: 'Успешно', color: 'var(--color-chart-2)' },
|
||||
{ keys: ['running', 'queued'], label: 'Активные', color: 'var(--color-chart-1)' },
|
||||
{ keys: ['failed', 'error', 'cancelled'], label: 'Ошибки', color: 'var(--color-destructive)' },
|
||||
]
|
||||
|
||||
function bucketForStatus(status: string): string {
|
||||
const s = status.toLowerCase()
|
||||
for (const bucket of STATUS_BUCKETS) {
|
||||
if (bucket.keys.includes(s)) return bucket.label
|
||||
}
|
||||
return 'Прочее'
|
||||
}
|
||||
|
||||
export function jobStatusBreakdown(jobs: JobRow[]): BreakdownSlice[] {
|
||||
const counts = new Map<string, number>()
|
||||
for (const job of jobs) {
|
||||
const label = bucketForStatus(job.status)
|
||||
counts.set(label, (counts.get(label) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const slices: BreakdownSlice[] = []
|
||||
for (const bucket of STATUS_BUCKETS) {
|
||||
const count = counts.get(bucket.label) ?? 0
|
||||
if (count > 0) {
|
||||
slices.push({
|
||||
key: bucket.label,
|
||||
label: bucket.label,
|
||||
count,
|
||||
color: bucket.color,
|
||||
})
|
||||
}
|
||||
}
|
||||
const other = counts.get('Прочее') ?? 0
|
||||
if (other > 0) {
|
||||
slices.push({
|
||||
key: 'other',
|
||||
label: 'Прочее',
|
||||
count: other,
|
||||
color: 'var(--color-chart-4)',
|
||||
})
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
export function jobStatusTotal(jobs: JobRow[]): number {
|
||||
return jobs.length
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ModuleRow, ModuleType } from '@/types/api'
|
||||
|
||||
import type { BreakdownSlice } from './types'
|
||||
|
||||
const TYPE_META: Record<ModuleType, { label: string; color: string }> = {
|
||||
AS_PREFIXES: { label: 'AS / префиксы', color: 'var(--color-chart-1)' },
|
||||
DOMAINS: { label: 'Домены', color: 'var(--color-chart-2)' },
|
||||
CDN_CIDRS: { label: 'CDN', color: 'var(--color-chart-3)' },
|
||||
IP_RANGES: { label: 'IP-диапазоны', color: 'var(--color-chart-4)' },
|
||||
}
|
||||
|
||||
export function moduleTypeBreakdown(modules: ModuleRow[]): BreakdownSlice[] {
|
||||
const counts = new Map<ModuleType, number>()
|
||||
for (const mod of modules) {
|
||||
counts.set(mod.type, (counts.get(mod.type) ?? 0) + 1)
|
||||
}
|
||||
|
||||
return (Object.keys(TYPE_META) as ModuleType[])
|
||||
.map((type) => ({
|
||||
key: type,
|
||||
label: TYPE_META[type].label,
|
||||
count: counts.get(type) ?? 0,
|
||||
color: TYPE_META[type].color,
|
||||
}))
|
||||
.filter((slice) => slice.count > 0)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
import type { CapacityBar } from './types'
|
||||
|
||||
function peerLabel(peer: PeerRow): string {
|
||||
return peer.name?.trim() || peer.neighbor || peer.id.slice(0, 8)
|
||||
}
|
||||
|
||||
function speakerLabel(speaker: SpeakerRow): string {
|
||||
return speaker.live?.label?.trim() || speaker.agent_domain || speaker.endpoint || speaker.id.slice(0, 8)
|
||||
}
|
||||
|
||||
export function peerCapacityBars(peers: PeerRow[], max = 24): CapacityBar[] {
|
||||
return peers
|
||||
.filter((p) => p.enabled !== false)
|
||||
.slice(0, max)
|
||||
.map((peer) => ({
|
||||
id: peer.id,
|
||||
name: peerLabel(peer),
|
||||
value: peer.session_state === 'Established' ? 100 : peer.session_state ? 40 : 10,
|
||||
}))
|
||||
}
|
||||
|
||||
export function speakerCapacityBars(speakers: SpeakerRow[], max = 24): CapacityBar[] {
|
||||
return speakers.slice(0, max).map((speaker) => {
|
||||
const online = speaker.live?.agent_ok === true
|
||||
const established = speaker.live?.bgp_established ?? 0
|
||||
const total = speaker.live?.bgp_sessions_total ?? 0
|
||||
const ratio = total > 0 ? Math.round((established / total) * 100) : online ? 100 : 15
|
||||
return {
|
||||
id: speaker.id,
|
||||
name: speakerLabel(speaker),
|
||||
value: online ? ratio : 10,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function capacityUtilization(bars: CapacityBar[]): number {
|
||||
if (bars.length === 0) return 0
|
||||
const sum = bars.reduce((acc, bar) => acc + bar.value, 0)
|
||||
return Math.round(sum / bars.length)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { PeerRow } from '@/types/api'
|
||||
|
||||
import type { BreakdownSlice } from './types'
|
||||
|
||||
export function peerSessionBreakdown(peers: PeerRow[]): BreakdownSlice[] {
|
||||
const enabled = peers.filter((p) => p.enabled !== false)
|
||||
const established = enabled.filter((p) => p.session_state === 'Established').length
|
||||
const pending = enabled.filter(
|
||||
(p) => p.session_state && p.session_state !== 'Established',
|
||||
).length
|
||||
const disabled = peers.length - enabled.length
|
||||
|
||||
const slices: BreakdownSlice[] = []
|
||||
if (established > 0) {
|
||||
slices.push({
|
||||
key: 'established',
|
||||
label: 'Установлена',
|
||||
count: established,
|
||||
color: 'var(--color-chart-2)',
|
||||
})
|
||||
}
|
||||
if (pending > 0) {
|
||||
slices.push({
|
||||
key: 'pending',
|
||||
label: 'Не установлена',
|
||||
count: pending,
|
||||
color: 'var(--color-warning)',
|
||||
})
|
||||
}
|
||||
if (disabled > 0) {
|
||||
slices.push({
|
||||
key: 'disabled',
|
||||
label: 'Выключены',
|
||||
count: disabled,
|
||||
color: 'var(--color-chart-4)',
|
||||
})
|
||||
}
|
||||
return slices.length > 0
|
||||
? slices
|
||||
: [{ key: 'empty', label: 'Нет пиров', count: 1, color: 'var(--color-muted-foreground)' }]
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { ReadyStatus } from '@/queries/monitoring'
|
||||
|
||||
import type { BreakdownSlice } from './types'
|
||||
|
||||
function checkOk(value: boolean | { ok?: boolean; error?: string } | undefined): boolean {
|
||||
if (typeof value === 'boolean') return value
|
||||
if (value && typeof value === 'object') return value.ok === true
|
||||
return false
|
||||
}
|
||||
|
||||
export function readinessBreakdown(
|
||||
ready: ReadyStatus | null | undefined,
|
||||
healthOk: boolean,
|
||||
): BreakdownSlice[] {
|
||||
if (!healthOk) {
|
||||
return [
|
||||
{
|
||||
key: 'health-fail',
|
||||
label: 'API недоступен',
|
||||
count: 1,
|
||||
color: 'var(--color-destructive)',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const checks = ready?.checks ?? {}
|
||||
let okCount = 0
|
||||
let failCount = 0
|
||||
|
||||
for (const value of Object.values(checks)) {
|
||||
if (checkOk(value)) okCount += 1
|
||||
else failCount += 1
|
||||
}
|
||||
|
||||
const slices: BreakdownSlice[] = [
|
||||
{
|
||||
key: 'health',
|
||||
label: 'API доступен',
|
||||
count: 1,
|
||||
color: 'var(--color-chart-2)',
|
||||
},
|
||||
]
|
||||
|
||||
if (okCount > 0) {
|
||||
slices.push({
|
||||
key: 'checks-ok',
|
||||
label: 'Проверки в норме',
|
||||
count: okCount,
|
||||
color: 'var(--color-chart-1)',
|
||||
})
|
||||
}
|
||||
if (failCount > 0) {
|
||||
slices.push({
|
||||
key: 'checks-fail',
|
||||
label: 'Ошибки проверок',
|
||||
count: failCount,
|
||||
color: 'var(--color-warning)',
|
||||
})
|
||||
}
|
||||
|
||||
if (slices.length === 1 && okCount === 0 && failCount === 0) {
|
||||
slices.push({
|
||||
key: 'ready',
|
||||
label: ready?.status === 'ok' ? 'Готов' : 'Ожидает готовности',
|
||||
count: 1,
|
||||
color: 'var(--color-chart-4)',
|
||||
})
|
||||
}
|
||||
|
||||
return slices
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
import { jobKindRu, jobStatusRu } from '@/lib/ui-labels'
|
||||
|
||||
import type { PlatformActivityItem } from './types'
|
||||
|
||||
function jobMessage(job: JobRow): string {
|
||||
return `${jobKindRu(job.kind)} · ${jobStatusRu(job.status)}`
|
||||
}
|
||||
|
||||
export function recentPlatformActivity(
|
||||
jobs: JobRow[],
|
||||
revisions: RevisionRow[],
|
||||
peers: PeerRow[],
|
||||
speakers: SpeakerRow[],
|
||||
limit = 5,
|
||||
): PlatformActivityItem[] {
|
||||
const items: PlatformActivityItem[] = []
|
||||
|
||||
for (const job of jobs.slice(0, 3)) {
|
||||
items.push({
|
||||
id: `job-${job.job_id}`,
|
||||
message: jobMessage(job),
|
||||
status: job.status,
|
||||
kind: 'job',
|
||||
})
|
||||
}
|
||||
|
||||
for (const rev of revisions.slice(0, 2)) {
|
||||
items.push({
|
||||
id: `rev-${rev.id}`,
|
||||
message: `Ревизия ${rev.id.slice(0, 8)}… · ${rev.materialized_prefix_count} префиксов`,
|
||||
status: 'ok',
|
||||
statusLabel: 'Создана',
|
||||
kind: 'revision',
|
||||
})
|
||||
}
|
||||
|
||||
for (const peer of peers.filter((p) => p.session_mismatch).slice(0, 2)) {
|
||||
items.push({
|
||||
id: `peer-${peer.id}`,
|
||||
message: `Расхождение сессии: ${peer.name ?? peer.neighbor}`,
|
||||
status: 'mismatch',
|
||||
kind: 'network',
|
||||
})
|
||||
}
|
||||
|
||||
for (const speaker of speakers.filter((s) => s.live?.bgp_poll_error || s.live?.agent_ok === false).slice(0, 2)) {
|
||||
items.push({
|
||||
id: `speaker-${speaker.id}`,
|
||||
message: `Нода недоступна: ${speaker.live?.label ?? speaker.endpoint}`,
|
||||
status: speaker.live?.agent_ok === false ? 'error' : 'warning',
|
||||
kind: 'network',
|
||||
})
|
||||
}
|
||||
|
||||
return items.slice(0, limit)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export type BreakdownSlice = {
|
||||
key: string
|
||||
label: string
|
||||
count: number
|
||||
color: string
|
||||
}
|
||||
|
||||
export type CapacityBar = {
|
||||
id: string
|
||||
name: string
|
||||
/** 0–100 utilization */
|
||||
value: number
|
||||
}
|
||||
|
||||
export type PlatformActivityItem = {
|
||||
id: string
|
||||
message: string
|
||||
status: string
|
||||
statusLabel?: string
|
||||
kind: 'job' | 'revision' | 'network'
|
||||
}
|
||||
|
||||
export type DeploymentProgress = {
|
||||
percent: number
|
||||
synced: number
|
||||
total: number
|
||||
mode: 'revision' | 'online'
|
||||
}
|
||||
@@ -27,3 +27,97 @@ export function moduleTypeRu(type: string): string {
|
||||
return type
|
||||
}
|
||||
}
|
||||
|
||||
const JOB_KIND_RU: Record<string, string> = {
|
||||
module_refresh: 'Обновление модуля',
|
||||
apply: 'Применение конфигурации',
|
||||
rollback: 'Откат ревизии',
|
||||
bird_reload: 'Перезагрузка BIRD',
|
||||
}
|
||||
|
||||
export function jobKindRu(kind: string): string {
|
||||
return JOB_KIND_RU[kind] ?? kind
|
||||
}
|
||||
|
||||
const JOB_STATUS_RU: Record<string, string> = {
|
||||
queued: 'В очереди',
|
||||
running: 'Выполняется',
|
||||
succeeded: 'Успешно',
|
||||
failed: 'Ошибка',
|
||||
error: 'Ошибка',
|
||||
cancelled: 'Отменена',
|
||||
canceled: 'Отменена',
|
||||
pending: 'Ожидает',
|
||||
approved: 'Одобрен',
|
||||
revoked: 'Отозван',
|
||||
active: 'Активен',
|
||||
ok: 'В норме',
|
||||
mismatch: 'Расхождение',
|
||||
established: 'Установлена',
|
||||
healthy: 'В норме',
|
||||
warning: 'Предупреждение',
|
||||
stale: 'Устарело',
|
||||
overdue: 'Просрочено',
|
||||
paused: 'Приостановлен',
|
||||
disabled: 'Выключен',
|
||||
archived: 'В архиве',
|
||||
block: 'block',
|
||||
accept: 'accept',
|
||||
}
|
||||
|
||||
export function jobStatusRu(status: string): string {
|
||||
return JOB_STATUS_RU[status.toLowerCase()] ?? status
|
||||
}
|
||||
|
||||
export function bgpSessionStateRu(state: string | null | undefined): string {
|
||||
if (!state) return '—'
|
||||
if (state === 'Established') return 'Установлена'
|
||||
return state
|
||||
}
|
||||
|
||||
export function speakerOnlineLabel(agentOk: boolean | undefined): string {
|
||||
if (agentOk === true) return 'В сети'
|
||||
if (agentOk === false) return 'Не в сети'
|
||||
return '—'
|
||||
}
|
||||
|
||||
export function firewallClientStatusRu(status: string): string {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'Ожидает'
|
||||
case 'approved':
|
||||
return 'Одобрен'
|
||||
case 'revoked':
|
||||
return 'Отозван'
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
export function apiKeyRoleRu(role: string): string {
|
||||
switch (role) {
|
||||
case 'viewer':
|
||||
return 'Наблюдатель'
|
||||
case 'editor':
|
||||
return 'Редактор'
|
||||
case 'operator':
|
||||
return 'Оператор'
|
||||
case 'node':
|
||||
return 'Нода'
|
||||
default:
|
||||
return role
|
||||
}
|
||||
}
|
||||
|
||||
export function readyCheckRu(key: string): string {
|
||||
switch (key) {
|
||||
case 'postgres':
|
||||
return 'PostgreSQL'
|
||||
case 'store':
|
||||
return 'Хранилище'
|
||||
case 'jobs':
|
||||
return 'Очередь задач'
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { apiJSON } from '@/lib/api-client'
|
||||
import type {
|
||||
FirewallClient,
|
||||
@@ -51,8 +53,23 @@ export function useApproveFirewallClient() {
|
||||
mutationFn: (id: string) =>
|
||||
apiJSON<FirewallClient>(`/v1/firewall/clients/${id}/approve`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Клиент одобрен')
|
||||
void qc.invalidateQueries({ queryKey: firewallKeys.clients() })
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось одобрить'),
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteFirewallClient() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiJSON<void>(`/v1/firewall/clients/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Клиент удалён')
|
||||
void qc.invalidateQueries({ queryKey: firewallKeys.clients() })
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось удалить'),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,8 +83,10 @@ export function useCreateFirewallRule() {
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Правило добавлено')
|
||||
void qc.invalidateQueries({ queryKey: firewallKeys.all })
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось добавить правило'),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { apiJSON } from '@/lib/api-client'
|
||||
import type { BirdStatus, PeersResponse, SpeakersResponse } from '@/types/api'
|
||||
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { apiJSON, apiMutate } from '@/lib/api-client'
|
||||
import type {
|
||||
BgpPeerCreate,
|
||||
BgpPeerPatch,
|
||||
BgpSpeakerCreate,
|
||||
BgpSpeakerPatch,
|
||||
BirdStatus,
|
||||
PeerRow,
|
||||
PeersResponse,
|
||||
SpeakerRow,
|
||||
SpeakersResponse,
|
||||
} from '@/types/api'
|
||||
|
||||
export const NETWORK_AUTO_REFRESH_MS = 30_000
|
||||
|
||||
@@ -34,3 +46,86 @@ export function networkBirdQueryOptions() {
|
||||
staleTime: 15_000,
|
||||
})
|
||||
}
|
||||
|
||||
function invalidateNetwork(qc: ReturnType<typeof useQueryClient>) {
|
||||
void qc.invalidateQueries({ queryKey: networkKeys.all })
|
||||
void qc.invalidateQueries({ queryKey: ['overview'] })
|
||||
}
|
||||
|
||||
export function useCreatePeerMutation() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: BgpPeerCreate) =>
|
||||
apiMutate<PeerRow>('/v1/peers', 'POST', body, { idempotent: false }),
|
||||
onSuccess: () => {
|
||||
toast.success('Пир создан')
|
||||
invalidateNetwork(qc)
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось создать пира'),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdatePeerMutation() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: BgpPeerPatch }) =>
|
||||
apiMutate<PeerRow>(`/v1/peers/${id}`, 'PATCH', body, { idempotent: false }),
|
||||
onSuccess: () => {
|
||||
toast.success('Пир обновлён')
|
||||
invalidateNetwork(qc)
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось обновить пира'),
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeletePeerMutation() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiMutate(`/v1/peers/${id}`, 'DELETE', undefined, { idempotent: false }),
|
||||
onSuccess: () => {
|
||||
toast.success('Пир удалён')
|
||||
invalidateNetwork(qc)
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось удалить пира'),
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateSpeakerMutation() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: BgpSpeakerCreate) =>
|
||||
apiMutate<SpeakerRow>('/v1/speakers', 'POST', body, { idempotent: false }),
|
||||
onSuccess: () => {
|
||||
toast.success('Спикер создан')
|
||||
invalidateNetwork(qc)
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось создать спикера'),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateSpeakerMutation() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: BgpSpeakerPatch }) =>
|
||||
apiMutate<SpeakerRow>(`/v1/speakers/${id}`, 'PATCH', body, { idempotent: false }),
|
||||
onSuccess: () => {
|
||||
toast.success('Спикер обновлён')
|
||||
invalidateNetwork(qc)
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось обновить спикера'),
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteSpeakerMutation() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiMutate(`/v1/speakers/${id}`, 'DELETE', undefined, { idempotent: false }),
|
||||
onSuccess: () => {
|
||||
toast.success('Спикер удалён')
|
||||
invalidateNetwork(qc)
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось удалить спикера'),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export function overviewRevisionsQueryOptions() {
|
||||
export function overviewJobsQueryOptions() {
|
||||
return queryOptions<JobsResponse>({
|
||||
queryKey: overviewKeys.jobs(),
|
||||
queryFn: () => apiJSON<JobsResponse>('/v1/jobs?limit=10'),
|
||||
queryFn: () => apiJSON<JobsResponse>('/v1/jobs?limit=100'),
|
||||
staleTime: 15_000,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Info, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react'
|
||||
import { KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
|
||||
import { AccessApiKeysCard } from '@/components/access/access-api-keys-card'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
@@ -79,29 +78,12 @@ function AccessComponent() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert className="border-info/30 bg-info/5">
|
||||
<Info className="text-info" />
|
||||
<AlertTitle>О API-ключах</AlertTitle>
|
||||
<AlertDescription>
|
||||
Роли: <code className="text-xs">viewer</code> (чтение),{' '}
|
||||
<code className="text-xs">editor</code> (CRUD), <code className="text-xs">operator</code>{' '}
|
||||
(apply и настройки), <code className="text-xs">node</code> (API ноды). Полный токен
|
||||
показывается один раз при создании и ротации. Токен браузера — в{' '}
|
||||
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
|
||||
настройках
|
||||
</Link>
|
||||
; для локальной разработки с demo-seed подойдёт <code className="text-xs">dev</code>{' '}
|
||||
(роль operator).
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{session ? (
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Текущая сессия</CardTitle>
|
||||
<CardDescription>Tenant и роль ключа, с которым открыта панель.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 p-4 text-sm sm:grid-cols-2">
|
||||
<PanelCard
|
||||
title="Текущая сессия"
|
||||
description="Tenant и роль ключа, с которым открыта панель."
|
||||
contentClassName="grid gap-3 py-4 text-sm sm:grid-cols-2"
|
||||
>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Tenant</p>
|
||||
<p className="break-all font-mono text-xs">{session.tenant_id}</p>
|
||||
@@ -110,11 +92,9 @@ function AccessComponent() {
|
||||
<p className="text-muted-foreground">Роль</p>
|
||||
<p className="font-mono">{session.role}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PanelCard>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-6 text-sm text-muted-foreground">
|
||||
<PanelCard contentClassName="py-4 text-sm text-muted-foreground">
|
||||
Не удалось определить сессию. Укажите токен в{' '}
|
||||
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
|
||||
настройках
|
||||
@@ -123,8 +103,7 @@ function AccessComponent() {
|
||||
{sessionQuery.isError && sessionQuery.error instanceof Error ? (
|
||||
<span className="mt-2 block text-destructive">{sessionQuery.error.message}</span>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PanelCard>
|
||||
)}
|
||||
|
||||
{isOperator ? (
|
||||
@@ -143,14 +122,12 @@ function AccessComponent() {
|
||||
/>
|
||||
</>
|
||||
) : session ? (
|
||||
<Card>
|
||||
<CardContent className="py-6 text-sm text-muted-foreground">
|
||||
Управление API-ключами доступно только роли <strong>operator</strong>. Текущая роль:{' '}
|
||||
<span className="font-mono">{session.role}</span>. Для выдачи ключей войдите с
|
||||
operator-ключом или создайте ключ через API / переменную{' '}
|
||||
<code className="text-xs">EVOBGP_API_KEYS</code>.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PanelCard contentClassName="py-4 text-sm text-muted-foreground">
|
||||
Управление API-ключами доступно только роли <strong>operator</strong>. Текущая роль:{' '}
|
||||
<span className="font-mono">{session.role}</span>. Для выдачи ключей войдите с
|
||||
operator-ключом или создайте ключ через API / переменную{' '}
|
||||
<code className="text-xs">EVOBGP_API_KEYS</code>.
|
||||
</PanelCard>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,44 +1,31 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import {
|
||||
Boxes,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
GitBranch,
|
||||
Info,
|
||||
Network,
|
||||
Play,
|
||||
Plus,
|
||||
Radio,
|
||||
RefreshCw,
|
||||
Activity,
|
||||
Share2,
|
||||
Tags,
|
||||
Gauge,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
|
||||
import {
|
||||
DashboardNetworkCapacityCard,
|
||||
DashboardOperationsFlowCard,
|
||||
DashboardPlatformCard,
|
||||
} from '@/components/analytics'
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { DashboardQuickActions } from '@/components/dashboard/dashboard-quick-actions'
|
||||
import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid'
|
||||
import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import { AnalyticsDashboardSkeleton } from '@/components/skeletons'
|
||||
|
||||
import {
|
||||
aggregateNetworkMetrics,
|
||||
moduleNameById,
|
||||
overviewHealthQueryOptions,
|
||||
overviewJobsQueryOptions,
|
||||
overviewModulesQueryOptions,
|
||||
overviewPeersQueryOptions,
|
||||
overviewRevisionsQueryOptions,
|
||||
overviewSpeakersQueryOptions,
|
||||
runningJobCount,
|
||||
} from '@/queries/overview'
|
||||
|
||||
export const Route = createFileRoute('/_auth/dashboard')({
|
||||
@@ -50,7 +37,6 @@ function DashboardComponent() {
|
||||
|
||||
const results = useQueries({
|
||||
queries: [
|
||||
overviewHealthQueryOptions(),
|
||||
overviewModulesQueryOptions(),
|
||||
overviewPeersQueryOptions(),
|
||||
overviewSpeakersQueryOptions(),
|
||||
@@ -59,7 +45,7 @@ function DashboardComponent() {
|
||||
],
|
||||
})
|
||||
|
||||
const [healthQ, modulesQ, peersQ, speakersQ, revisionsQ, jobsQ] = results
|
||||
const [modulesQ, peersQ, speakersQ, revisionsQ, jobsQ] = results
|
||||
const initialLoading =
|
||||
modulesQ.isLoading || peersQ.isLoading || speakersQ.isLoading || revisionsQ.isLoading || jobsQ.isLoading
|
||||
const refreshing = results.some((r) => r.isFetching && !r.isLoading)
|
||||
@@ -78,60 +64,13 @@ function DashboardComponent() {
|
||||
const speakers = speakersQ.data?.items ?? []
|
||||
const revisions = revisionsQ.data?.items ?? []
|
||||
const jobs = jobsQ.data?.items ?? []
|
||||
const modulesHasMore = modulesQ.data?.has_more ?? false
|
||||
const peersHasMore = peersQ.data?.has_more ?? false
|
||||
const speakersHasMore = speakersQ.data?.has_more ?? false
|
||||
const revisionsHasMore = revisionsQ.data?.has_more ?? false
|
||||
|
||||
const net = aggregateNetworkMetrics(peers, speakers)
|
||||
const running = runningJobCount(jobs)
|
||||
const nameById = moduleNameById(modules)
|
||||
|
||||
const countBadge = (n: number, hasMore: boolean, suffix: string) => (hasMore ? '200+' : suffix)
|
||||
|
||||
const items: SectionCardItem[] = [
|
||||
{
|
||||
label: 'Модули',
|
||||
value: initialLoading ? '—' : String(modules.length),
|
||||
icon: <Boxes className="size-4" />,
|
||||
hint: countBadge(modules.length, modulesHasMore, 'AS, CDN, домены, IP'),
|
||||
onClick: () => window.location.assign('/modules'),
|
||||
},
|
||||
{
|
||||
label: 'Пиры',
|
||||
value: initialLoading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
|
||||
icon: <GitBranch className="size-4" />,
|
||||
hint: countBadge(peers.length, peersHasMore, 'Established / включённых'),
|
||||
badge: net.peersMismatch > 0 ? `mismatch ${net.peersMismatch}` : undefined,
|
||||
variant: net.peersMismatch > 0 ? 'warning' : 'default',
|
||||
onClick: () => window.location.assign('/network?tab=peers'),
|
||||
},
|
||||
{
|
||||
label: 'Спикеры',
|
||||
value: initialLoading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
|
||||
icon: <Radio className="size-4" />,
|
||||
hint: countBadge(speakers.length, speakersHasMore, 'online / всего'),
|
||||
variant: net.speakersOnline < net.speakersTotal ? 'warning' : 'default',
|
||||
onClick: () => window.location.assign('/network?tab=overview'),
|
||||
},
|
||||
{
|
||||
label: 'Ревизии',
|
||||
value: initialLoading ? '—' : String(revisions.length),
|
||||
icon: <Activity className="size-4" />,
|
||||
hint: countBadge(revisions.length, revisionsHasMore, 'configs'),
|
||||
onClick: () => window.location.assign('/operations'),
|
||||
},
|
||||
{
|
||||
label: 'Активных задач',
|
||||
value: initialLoading ? '—' : String(running),
|
||||
icon: <Clock className="size-4" />,
|
||||
hint: 'queued и running',
|
||||
onClick: () => window.location.assign('/operations?tab=jobs'),
|
||||
},
|
||||
]
|
||||
const activityLoading =
|
||||
refreshing && jobs.length === 0 && revisions.length === 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
<PageHeader
|
||||
title="Обзор"
|
||||
description={
|
||||
@@ -147,254 +86,54 @@ function DashboardComponent() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert className="border-info/30 bg-info/5">
|
||||
<Info className="text-info" />
|
||||
<AlertTitle>Панель управления EvoBGP</AlertTitle>
|
||||
<AlertDescription>
|
||||
Сводка по модулям, сети и фоновым задачам. BGP и ноды — «Сеть», префиксы — «Модули»,
|
||||
деплой — «Операции», здоровье API — «Мониторинг».
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{initialLoading ? (
|
||||
<AnalyticsDashboardSkeleton />
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-3 lg:items-start">
|
||||
<DashboardPlatformCard
|
||||
modules={modules}
|
||||
peers={peers}
|
||||
speakers={speakers}
|
||||
jobs={jobs}
|
||||
revisions={revisions}
|
||||
/>
|
||||
<DashboardNetworkCapacityCard peers={peers} speakers={speakers} jobs={jobs} />
|
||||
<DashboardOperationsFlowCard jobs={jobs} modules={modules} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HealthAlert
|
||||
loading={healthQ.isLoading}
|
||||
ok={healthQ.data === true}
|
||||
loadError={modulesQ.isError || peersQ.isError ? 'Некоторые данные не загружены' : null}
|
||||
/>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<DataGridCard
|
||||
title="Недавние задачи"
|
||||
description="Последние фоновые операции"
|
||||
className="h-full"
|
||||
>
|
||||
{activityLoading ? (
|
||||
<Skeleton className="m-3 h-24 w-auto" />
|
||||
) : (
|
||||
<DashboardRecentJobsGrid jobs={jobs.slice(0, 10)} nameById={nameById} isLoading={refreshing} />
|
||||
)}
|
||||
</DataGridCard>
|
||||
|
||||
{initialLoading ? <SectionCardsSkeleton count={5} /> : <SectionCards items={items} />}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<RecentJobsCard jobs={jobs} nameById={nameById} loading={refreshing} />
|
||||
<RecentRevisionsCard revisions={revisions} loading={refreshing} />
|
||||
<NetworkStatusCard peers={peers} speakers={speakers} loading={refreshing} />
|
||||
<DataGridCard
|
||||
title="Последние ревизии"
|
||||
description="История конфигураций"
|
||||
className="h-full"
|
||||
>
|
||||
{activityLoading ? (
|
||||
<Skeleton className="m-3 h-24 w-auto" />
|
||||
) : (
|
||||
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
|
||||
)}
|
||||
</DataGridCard>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Быстрые действия</CardTitle>
|
||||
<CardDescription>Частые переходы к настройке и деплою</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2 p-4">
|
||||
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
|
||||
<Plus className="size-4" />
|
||||
Создать модуль
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/directories')}>
|
||||
<Tags className="size-4" />
|
||||
Добавить community
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/network?tab=overview')}>
|
||||
<Network className="size-4" />
|
||||
Сеть
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/network?tab=peers')}>
|
||||
<Share2 className="size-4" />
|
||||
Добавить пира
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/operations')}>
|
||||
<Play className="size-4" />
|
||||
Деплой (Apply)
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/monitoring')}>
|
||||
<Gauge className="size-4" />
|
||||
Мониторинг
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HealthAlert({
|
||||
loading,
|
||||
ok,
|
||||
loadError,
|
||||
}: {
|
||||
loading: boolean
|
||||
ok: boolean | undefined
|
||||
loadError: string | null
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Alert>
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
<AlertTitle>Проверка API…</AlertTitle>
|
||||
<AlertDescription>
|
||||
Запрос к <code className="text-xs">/v1/health</code>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
if (ok && !loadError) {
|
||||
return (
|
||||
<Alert className="border-success/30 bg-success/5">
|
||||
<CheckCircle className="text-success" />
|
||||
<AlertTitle>API работает</AlertTitle>
|
||||
<AlertDescription>Сервер отвечает на запросы health-check.</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
if (ok && loadError) {
|
||||
return (
|
||||
<Alert className="border-warning/30 bg-warning/5">
|
||||
<Info className="text-warning" />
|
||||
<AlertTitle>API доступен, данные не загружены</AlertTitle>
|
||||
<AlertDescription>{loadError}. Проверьте Bearer-токен в «Настройках».</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Alert variant="destructive" className="border-destructive/30 bg-destructive/5">
|
||||
<XCircle className="text-destructive" />
|
||||
<AlertTitle>API недоступен</AlertTitle>
|
||||
<AlertDescription>
|
||||
Не удалось получить ответ от сервера. Проверьте, что API запущен (порт 8080) и в dev работает
|
||||
прокси Vite.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
function RecentJobsCard({
|
||||
jobs,
|
||||
nameById,
|
||||
loading,
|
||||
}: {
|
||||
jobs: import('@/types/api').JobRow[]
|
||||
nameById: Map<string, string>
|
||||
loading: boolean
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Недавние задачи</CardTitle>
|
||||
<CardDescription>Последние фоновые операции</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3">
|
||||
{loading && jobs.length === 0 ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : jobs.length === 0 ? (
|
||||
<p className="px-2 py-6 text-center text-sm text-muted-foreground">Нет задач</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{jobs.slice(0, 8).map((j) => (
|
||||
<li
|
||||
key={j.job_id}
|
||||
className="flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/40"
|
||||
>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate font-mono text-xs text-muted-foreground">{j.kind}</span>
|
||||
<span className="truncate text-xs">
|
||||
{j.meta?.module_id ? nameById.get(String(j.meta.module_id)) ?? '' : ''}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
j.status === 'succeeded'
|
||||
? 'text-xs text-success'
|
||||
: j.status === 'failed'
|
||||
? 'text-xs text-destructive'
|
||||
: 'text-xs text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{j.status}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function RecentRevisionsCard({
|
||||
revisions,
|
||||
loading,
|
||||
}: {
|
||||
revisions: import('@/types/api').RevisionRow[]
|
||||
loading: boolean
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Последние ревизии</CardTitle>
|
||||
<CardDescription>История конфигураций</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3">
|
||||
{loading && revisions.length === 0 ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : revisions.length === 0 ? (
|
||||
<p className="px-2 py-6 text-center text-sm text-muted-foreground">Нет ревизий</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{revisions.slice(0, 8).map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
className="flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/40"
|
||||
>
|
||||
<span className="truncate font-mono text-xs text-muted-foreground">{r.id.slice(0, 10)}…</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(r.created_at).toLocaleString('ru-RU')}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function NetworkStatusCard({
|
||||
peers,
|
||||
speakers,
|
||||
loading,
|
||||
}: {
|
||||
peers: import('@/types/api').PeerRow[]
|
||||
speakers: import('@/types/api').SpeakerRow[]
|
||||
loading: boolean
|
||||
}) {
|
||||
const m = aggregateNetworkMetrics(peers, speakers)
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Состояние сети</CardTitle>
|
||||
<CardDescription>BGP-сессии и спикеры</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3">
|
||||
{loading && peers.length === 0 && speakers.length === 0 ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 px-1 py-1 text-sm">
|
||||
<Row label="Пиры Established" value={`${m.peersEstablished} / ${m.peersEnabled}`} />
|
||||
<Row label="Спикеры online" value={`${m.speakersOnline} / ${m.speakersTotal}`} />
|
||||
{m.peersMismatch > 0 ? (
|
||||
<Row label="Mismatches" value={String(m.peersMismatch)} variant="warning" />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
variant = 'default',
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
variant?: 'default' | 'warning'
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className={variant === 'warning' ? 'font-medium text-warning-foreground' : 'font-medium tabular-nums'}>
|
||||
{value}
|
||||
</span>
|
||||
<PanelCard
|
||||
title="Быстрые действия"
|
||||
description="Частые переходы к настройке и деплою"
|
||||
footer={<DashboardQuickActions />}
|
||||
footerClassName="gap-2 p-3"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { BookText, Globe, Info, RefreshCw, Tags } from 'lucide-react'
|
||||
import { BookText, Globe, RefreshCw, Tags } from 'lucide-react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Badge } from '@evobgp/ui/components/badge'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
|
||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { DirectoriesCommunitiesGrid } from '@/components/directories/directories-communities-grid'
|
||||
import { DirectoriesDohGrid } from '@/components/directories/directories-doh-grid'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||
@@ -76,111 +67,62 @@ function DirectoriesComponent() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert className="border-info/30 bg-info/5">
|
||||
<Info className="text-info" />
|
||||
<AlertTitle>О справочниках</AlertTitle>
|
||||
<AlertDescription>
|
||||
Сообщества BGP используются в AS- и CDN-модулях для тегирования префиксов. DoH-профили — в
|
||||
доменных модулях для DNS-over-HTTPS резолвинга.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
|
||||
|
||||
<Tabs defaultValue="communities">
|
||||
<TabsList>
|
||||
<TabsTrigger value="communities">Сообщества BGP</TabsTrigger>
|
||||
<TabsTrigger value="doh">DoH профили</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="communities" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Сообщества BGP</CardTitle>
|
||||
<CardDescription>Теги для префиксов в фильтрах BIRD</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={communities}
|
||||
isLoading={communitiesQ.isLoading}
|
||||
isError={communitiesQ.isError}
|
||||
error={communitiesQ.error}
|
||||
empty={communities.length === 0}
|
||||
emptyTitle="Нет сообществ"
|
||||
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||
onRetry={() => communitiesQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Название</TableHead>
|
||||
<TableHead>Значение</TableHead>
|
||||
<TableHead>Тип</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-medium">{c.title}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{c.community}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">community</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<BadgeTabs
|
||||
defaultValue="communities"
|
||||
items={[
|
||||
{ value: 'communities', label: 'Сообщества BGP', count: communities.length },
|
||||
{ value: 'doh', label: 'DoH профили', count: dohProfiles.length, badgeVariant: 'info-light' },
|
||||
]}
|
||||
>
|
||||
<TabsContent value="communities" className="mt-0">
|
||||
<DataGridCard
|
||||
title="Сообщества BGP"
|
||||
description="Теги для префиксов в фильтрах BIRD"
|
||||
>
|
||||
<QueryState
|
||||
data={communities}
|
||||
isLoading={communitiesQ.isLoading}
|
||||
isError={communitiesQ.isError}
|
||||
error={communitiesQ.error}
|
||||
empty={communities.length === 0}
|
||||
emptyTitle="Нет сообществ"
|
||||
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||
onRetry={() => communitiesQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<DirectoriesCommunitiesGrid
|
||||
items={items}
|
||||
isLoading={communitiesQ.isFetching && !communitiesQ.isLoading}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="doh" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">DoH профили</CardTitle>
|
||||
<CardDescription>Резолверы DNS-over-HTTPS для доменных модулей</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={dohProfiles}
|
||||
isLoading={dohQ.isLoading}
|
||||
isError={dohQ.isError}
|
||||
error={dohQ.error}
|
||||
empty={dohProfiles.length === 0}
|
||||
emptyTitle="Нет DoH профилей"
|
||||
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||
onRetry={() => dohQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Название</TableHead>
|
||||
<TableHead>URL</TableHead>
|
||||
<TableHead>По умолчанию</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium">{p.name ?? p.url}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{p.url}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">—</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TabsContent value="doh" className="mt-0">
|
||||
<DataGridCard title="DoH профили" description="Резолверы DNS-over-HTTPS для доменных модулей">
|
||||
<QueryState
|
||||
data={dohProfiles}
|
||||
isLoading={dohQ.isLoading}
|
||||
isError={dohQ.isError}
|
||||
error={dohQ.error}
|
||||
empty={dohProfiles.length === 0}
|
||||
emptyTitle="Нет DoH профилей"
|
||||
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||
onRetry={() => dohQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<DirectoriesDohGrid
|
||||
items={items}
|
||||
isLoading={dohQ.isFetching && !dohQ.isLoading}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</BadgeTabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,38 +1,30 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Copy, Info, RefreshCw, Shield } from 'lucide-react'
|
||||
import { Copy, Plus, RefreshCw, Shield } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
|
||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { FirewallClientsGrid } from '@/components/firewall/firewall-clients-grid'
|
||||
import { FirewallRuleCreateDialog } from '@/components/firewall/firewall-rule-create-dialog'
|
||||
import { FirewallRulesGrid } from '@/components/firewall/firewall-rules-grid'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { CommunitySelect } from '@/components/modules/community-select'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { communityLabel } from '@/lib/modules/helpers'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { directoriesCommunitiesQueryOptions } from '@/queries/directories'
|
||||
import {
|
||||
firewallClientsQueryOptions,
|
||||
firewallInstallContextQueryOptions,
|
||||
firewallRulesQueryOptions,
|
||||
useApproveFirewallClient,
|
||||
useCreateFirewallRule,
|
||||
useDeleteFirewallClient,
|
||||
useDeleteFirewallRule,
|
||||
} from '@/queries/firewall'
|
||||
import type { BgpCommunity, FirewallClient } from '@/types/api'
|
||||
|
||||
function httpsOrigin(origin: string): string {
|
||||
try {
|
||||
@@ -54,7 +46,7 @@ function FirewallPage() {
|
||||
const clientsQ = useQuery(firewallClientsQueryOptions())
|
||||
const rulesQ = useQuery(firewallRulesQueryOptions('tenant'))
|
||||
const approve = useApproveFirewallClient()
|
||||
const createRule = useCreateFirewallRule()
|
||||
const deleteClient = useDeleteFirewallClient()
|
||||
const deleteRule = useDeleteFirewallRule()
|
||||
|
||||
const installCtx = installCtxQ.data
|
||||
@@ -64,6 +56,7 @@ function FirewallPage() {
|
||||
typeof window !== 'undefined' ? httpsOrigin(window.location.origin) : 'https://api.example.com',
|
||||
)
|
||||
const [seed, setSeed] = useState('')
|
||||
const [createRuleOpen, setCreateRuleOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (installCtx?.suggested_cp_url) {
|
||||
@@ -73,14 +66,16 @@ function FirewallPage() {
|
||||
setSeed(installCtx.bundle_seed)
|
||||
}
|
||||
}, [installCtx?.bundle_seed, installCtx?.suggested_cp_url])
|
||||
const [ruleAction, setRuleAction] = useState<'block' | 'accept'>('block')
|
||||
const [ruleCommunityId, setRuleCommunityId] = useState<string | null>(null)
|
||||
const [ruleComment, setRuleComment] = useState('')
|
||||
|
||||
const communities = communitiesQ.data?.items ?? []
|
||||
|
||||
const clients = clientsQ.data?.items ?? []
|
||||
const pending = clients.filter((c) => c.status === 'pending')
|
||||
const { activeClients, pending } = useMemo(() => {
|
||||
const all = clientsQ.data?.items ?? []
|
||||
return {
|
||||
activeClients: all.filter((c) => c.status !== 'revoked'),
|
||||
pending: all.filter((c) => c.status === 'pending'),
|
||||
}
|
||||
}, [clientsQ.data?.items])
|
||||
const rules = rulesQ.data?.items ?? []
|
||||
|
||||
const installCmd = useMemo(() => {
|
||||
@@ -97,7 +92,7 @@ function FirewallPage() {
|
||||
toast.error(
|
||||
installCtx?.bundle_seed_configured === false
|
||||
? 'На CP не задан EVOBGP_BUNDLE_SEED_HEX'
|
||||
: 'Bundle seed недоступен (нужна роль operator)',
|
||||
: 'Seed бандла недоступен (нужна роль оператора)',
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -112,7 +107,7 @@ function FirewallPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Firewall blocklist"
|
||||
title="Файрвол: blocklist"
|
||||
description="Linux-серверы: синхронизация CIDR по policy block/accept"
|
||||
actions={
|
||||
<Button
|
||||
@@ -130,25 +125,16 @@ function FirewallPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert className="border-info/30 bg-info/5">
|
||||
<Info className="text-info" />
|
||||
<AlertTitle>Политика</AlertTitle>
|
||||
<AlertDescription>
|
||||
Правила сопоставляются с <strong>BGP community</strong> префиксов опубликованной revision.{' '}
|
||||
<strong>block</strong> добавляет префиксы community в kernel; <strong>accept</strong> — не блокирует.
|
||||
Community «Все» — правило для любого community. Default без совпадений — accept.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="size-5" />
|
||||
<PanelCard
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Shield className="size-4" />
|
||||
Установка на сервер
|
||||
</CardTitle>
|
||||
<CardDescription>One-liner для root на целевом Linux (bash, curl). После enroll — approve в «Запросы».</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
</span>
|
||||
}
|
||||
description="Команда для root на целевом Linux (bash, curl). После регистрации — одобрите клиента во вкладке «Запросы»."
|
||||
contentClassName="flex flex-col gap-4 py-4"
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fw-name">Имя сервера</Label>
|
||||
@@ -159,7 +145,7 @@ function FirewallPage() {
|
||||
<Input id="fw-url" value={cpUrl} onChange={(e) => setCpUrl(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fw-seed">Bundle seed</Label>
|
||||
<Label htmlFor="fw-seed">Seed бандла</Label>
|
||||
<Input
|
||||
id="fw-seed"
|
||||
type="password"
|
||||
@@ -170,10 +156,10 @@ function FirewallPage() {
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{installCtxQ.isLoading
|
||||
? 'Загрузка из control plane…'
|
||||
? 'Загрузка с плоскости управления…'
|
||||
: installCtx?.bundle_seed_configured
|
||||
? 'Из переменной EVOBGP_BUNDLE_SEED_HEX на CP (docker compose / .env)'
|
||||
: 'На CP не задан EVOBGP_BUNDLE_SEED_HEX — enroll невозможен'}
|
||||
: 'На CP не задан EVOBGP_BUNDLE_SEED_HEX — регистрация невозможна'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,180 +168,109 @@ function FirewallPage() {
|
||||
<Copy />
|
||||
Копировать команду
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PanelCard>
|
||||
|
||||
<Tabs defaultValue="clients">
|
||||
<TabsList>
|
||||
<TabsTrigger value="clients">Клиенты ({clients.length})</TabsTrigger>
|
||||
<TabsTrigger value="rules">Правила ({rules.length})</TabsTrigger>
|
||||
<TabsTrigger value="requests">Запросы ({pending.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="clients" className="mt-4">
|
||||
<ClientsTable clients={clients} onApprove={(id) => approve.mutate(id)} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rules" className="mt-4 space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Действие</Label>
|
||||
<select
|
||||
className="border-input bg-background h-9 rounded-md border px-2 text-sm"
|
||||
value={ruleAction}
|
||||
onChange={(e) => setRuleAction(e.target.value as 'block' | 'accept')}
|
||||
>
|
||||
<option value="block">block</option>
|
||||
<option value="accept">accept</option>
|
||||
</select>
|
||||
</div>
|
||||
<CommunitySelect
|
||||
id="fw-rule-community"
|
||||
label="Community"
|
||||
value={ruleCommunityId}
|
||||
onValueChange={setRuleCommunityId}
|
||||
communities={communities}
|
||||
nullable
|
||||
placeholder="Все communities"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="fw-rule-comment">Комментарий</Label>
|
||||
<Input
|
||||
id="fw-rule-comment"
|
||||
className="max-w-xs"
|
||||
placeholder="Комментарий"
|
||||
value={ruleComment}
|
||||
onChange={(e) => setRuleComment(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="mb-0.5"
|
||||
onClick={() =>
|
||||
createRule.mutate({
|
||||
scope: 'tenant',
|
||||
action: ruleAction,
|
||||
community_id: ruleCommunityId,
|
||||
comment: ruleComment,
|
||||
})
|
||||
}
|
||||
<BadgeTabs
|
||||
defaultValue="clients"
|
||||
items={[
|
||||
{ value: 'clients', label: 'Клиенты', count: activeClients.length },
|
||||
{ value: 'rules', label: 'Правила', count: rules.length, badgeVariant: 'info-light' },
|
||||
{
|
||||
value: 'requests',
|
||||
label: 'Запросы',
|
||||
count: pending.length,
|
||||
badgeVariant: pending.length > 0 ? 'warning-light' : 'primary-light',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TabsContent value="clients" className="mt-0">
|
||||
<DataGridCard title="Клиенты" description="Активные Linux-серверы с синхронизацией списка блокировок">
|
||||
<QueryState
|
||||
data={clientsQ.data}
|
||||
isLoading={clientsQ.isLoading}
|
||||
isError={clientsQ.isError}
|
||||
error={clientsQ.error}
|
||||
onRetry={() => void clientsQ.refetch()}
|
||||
skeleton={<TableSkeleton rows={5} cols={6} />}
|
||||
>
|
||||
Добавить правило
|
||||
</Button>
|
||||
</div>
|
||||
<RulesTable
|
||||
rules={rules}
|
||||
{() => (
|
||||
<FirewallClientsGrid
|
||||
clients={activeClients}
|
||||
isLoading={clientsQ.isFetching && !clientsQ.isLoading}
|
||||
onApprove={(id) => approve.mutate(id)}
|
||||
onReject={(id) => deleteClient.mutate(id)}
|
||||
approvePending={approve.isPending}
|
||||
rejectPending={deleteClient.isPending}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rules" className="mt-0">
|
||||
<DataGridCard
|
||||
title="Правила"
|
||||
actions={
|
||||
<Button size="sm" type="button" onClick={() => setCreateRuleOpen(true)}>
|
||||
<Plus />
|
||||
Добавить правило
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={rulesQ.data}
|
||||
isLoading={rulesQ.isLoading}
|
||||
isError={rulesQ.isError}
|
||||
error={rulesQ.error}
|
||||
onRetry={() => void rulesQ.refetch()}
|
||||
skeleton={<TableSkeleton rows={5} cols={5} />}
|
||||
>
|
||||
{() => (
|
||||
<FirewallRulesGrid
|
||||
rules={rules}
|
||||
communities={communities}
|
||||
isLoading={rulesQ.isFetching && !rulesQ.isLoading}
|
||||
onDelete={(id) => deleteRule.mutate(id)}
|
||||
deletePending={deleteRule.isPending}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
<FirewallRuleCreateDialog
|
||||
open={createRuleOpen}
|
||||
onOpenChange={setCreateRuleOpen}
|
||||
communities={communities}
|
||||
onDelete={(id) => deleteRule.mutate(id)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="requests" className="mt-4">
|
||||
<ClientsTable
|
||||
clients={pending}
|
||||
onApprove={(id) => approve.mutate(id)}
|
||||
emptyTitle="Нет pending-запросов"
|
||||
/>
|
||||
<TabsContent value="requests" className="mt-0">
|
||||
<DataGridCard
|
||||
title="Запросы"
|
||||
description="Запросы на регистрацию — одобрите или отклоните новые клиенты"
|
||||
>
|
||||
<QueryState
|
||||
data={clientsQ.data}
|
||||
isLoading={clientsQ.isLoading}
|
||||
isError={clientsQ.isError}
|
||||
error={clientsQ.error}
|
||||
onRetry={() => void clientsQ.refetch()}
|
||||
skeleton={<TableSkeleton rows={3} cols={6} />}
|
||||
>
|
||||
{() => (
|
||||
<FirewallClientsGrid
|
||||
clients={pending}
|
||||
isLoading={clientsQ.isFetching && !clientsQ.isLoading}
|
||||
onApprove={(id) => approve.mutate(id)}
|
||||
onReject={(id) => deleteClient.mutate(id)}
|
||||
approvePending={approve.isPending}
|
||||
rejectPending={deleteClient.isPending}
|
||||
emptyTitle="Нет ожидающих запросов"
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</BadgeTabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ClientsTable({
|
||||
clients,
|
||||
onApprove,
|
||||
emptyTitle = 'Нет клиентов',
|
||||
}: {
|
||||
clients: FirewallClient[]
|
||||
onApprove: (id: string) => void
|
||||
emptyTitle?: string
|
||||
}) {
|
||||
if (clients.length === 0) {
|
||||
return <p className="text-muted-foreground text-sm">{emptyTitle}</p>
|
||||
}
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Имя</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Last seen</TableHead>
|
||||
<TableHead>Apply</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{clients.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{c.name}</div>
|
||||
<div className="text-muted-foreground text-xs">{c.hostname || c.token_prefix}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={c.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{c.last_seen_at?.slice(0, 19) ?? '—'}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{c.last_apply_status ?? '—'}
|
||||
{c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{c.status === 'pending' ? (
|
||||
<Button size="sm" variant="outline" onClick={() => onApprove(c.id)}>
|
||||
Approve
|
||||
</Button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
function RulesTable({
|
||||
rules,
|
||||
communities,
|
||||
onDelete,
|
||||
}: {
|
||||
rules: { id: string; priority: number; action: string; community_id?: string | null; comment?: string }[]
|
||||
communities: BgpCommunity[]
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
if (rules.length === 0) {
|
||||
return <p className="text-muted-foreground text-sm">Нет правил — blocklist пуст (default accept).</p>
|
||||
}
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>#</TableHead>
|
||||
<TableHead>Действие</TableHead>
|
||||
<TableHead>Community</TableHead>
|
||||
<TableHead>Комментарий</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rules.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>{r.priority}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={r.action} label={r.action} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{r.community_id ? communityLabel(r.community_id, communities) : 'Все'}
|
||||
</TableCell>
|
||||
<TableCell>{r.comment || '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Button size="sm" variant="ghost" onClick={() => onDelete(r.id)}>
|
||||
Удалить
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ArrowLeft, Info, RefreshCw } from 'lucide-react'
|
||||
import { ArrowLeft, RefreshCw } from 'lucide-react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
@@ -17,25 +16,12 @@ import {
|
||||
directoriesDohQueryOptions,
|
||||
} from '@/queries/directories'
|
||||
import { moduleDetailQueryOptions, moduleEntriesQueryOptions, modulesKeys } from '@/queries/modules'
|
||||
import type { AsEntry, ModuleRow } from '@/types/api'
|
||||
import type { AsEntry } from '@/types/api'
|
||||
|
||||
export const Route = createFileRoute('/_auth/modules/$moduleId')({
|
||||
component: ModuleDetailComponent,
|
||||
})
|
||||
|
||||
function moduleTypeAlert(type: ModuleRow['type']): string {
|
||||
switch (type) {
|
||||
case 'AS_PREFIXES':
|
||||
return 'Модуль AS получает префиксы через RIPEstat по указанным ASN. После refresh счётчики префиксов обновляются в таблице записей.'
|
||||
case 'CDN_CIDRS':
|
||||
return 'Модуль CDN скачивает списки CIDR по URL (plaintext или JSON). Используйте предпросмотр при добавлении источника.'
|
||||
case 'DOMAINS':
|
||||
return 'Модуль доменов резолвит FQDN через DoH-профили и конвертирует IP в префиксы. Политика и профили настраиваются в редактировании модуля.'
|
||||
case 'IP_RANGES':
|
||||
return 'Модуль IP-диапазонов использует статические CIDR без внешнего refresh (сервер может вернуть 204). Записи участвуют в агрегации напрямую.'
|
||||
}
|
||||
}
|
||||
|
||||
function ModuleDetailComponent() {
|
||||
const { moduleId } = Route.useParams()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -114,12 +100,6 @@ function ModuleDetailComponent() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertTitle>О модуле</AlertTitle>
|
||||
<AlertDescription>{moduleTypeAlert(m.type)}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<ModuleKpiCards
|
||||
mod={m}
|
||||
communities={communities}
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
import { Link, createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Boxes, Plus, RefreshCw } from 'lucide-react'
|
||||
import { Plus, RefreshCw } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { ModulesListGrid } from '@/components/modules/modules-list-grid'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { TruncatedText } from '@/components/truncated-text'
|
||||
import { modulesListQueryOptions } from '@/queries/modules'
|
||||
import type { ModuleRow } from '@/types/api'
|
||||
|
||||
export const Route = createFileRoute('/_auth/modules/')({
|
||||
component: ModulesListComponent,
|
||||
@@ -48,76 +38,34 @@ function ModulesListComponent() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between border-b py-3">
|
||||
<CardTitle className="text-base">Все модули</CardTitle>
|
||||
<DataGridCard
|
||||
title="Все модули"
|
||||
actions={
|
||||
<Button size="sm" render={<Link to="/modules/new" />}>
|
||||
<Plus />
|
||||
Создать
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={query.data?.items}
|
||||
isLoading={query.isLoading}
|
||||
isError={query.isError}
|
||||
error={query.error}
|
||||
empty={query.data?.items?.length === 0}
|
||||
emptyTitle="Нет модулей"
|
||||
emptyDescription="Создайте первый модуль (AS, CDN, домены, IP)."
|
||||
skeleton={<TableSkeleton rows={6} cols={5} />}
|
||||
onRetry={() => query.refetch()}
|
||||
>
|
||||
{(items) => <ModulesTable items={items} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={query.data?.items}
|
||||
isLoading={query.isLoading}
|
||||
isError={query.isError}
|
||||
error={query.error}
|
||||
empty={query.data?.items?.length === 0}
|
||||
emptyTitle="Нет модулей"
|
||||
emptyDescription="Создайте первый модуль (AS, CDN, домены, IP)."
|
||||
skeleton={<TableSkeleton rows={6} cols={5} />}
|
||||
onRetry={() => query.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<ModulesListGrid
|
||||
items={items}
|
||||
isLoading={query.isFetching && !query.isLoading}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModulesTable({ items }: { items: ModuleRow[] }) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Название</TableHead>
|
||||
<TableHead>Тип</TableHead>
|
||||
<TableHead>Приоритет</TableHead>
|
||||
<TableHead>Состояние</TableHead>
|
||||
<TableHead>Обновлено</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((m) => (
|
||||
<TableRow
|
||||
key={m.id}
|
||||
className="cursor-pointer hover:bg-muted/40"
|
||||
onClick={() => (window.location.href = `/modules/${m.id}`)}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Boxes className="size-4 text-muted-foreground" />
|
||||
<TruncatedText className="max-w-[280px]">{m.name}</TruncatedText>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{m.type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm tabular-nums">{m.priority}</TableCell>
|
||||
<TableCell>
|
||||
{m.enabled ? (
|
||||
<Badge variant="success">включён</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">выключен</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{m.last_refreshed_at ? new Date(m.last_refreshed_at).toLocaleString('ru-RU') : '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,36 +1,32 @@
|
||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Activity, AlertTriangle, Bird, Database, Gauge, HardDrive, HeartPulse, Info, ListTodo, RefreshCw, ShieldCheck } from 'lucide-react'
|
||||
import { Activity, AlertTriangle, Bird, RefreshCw } from 'lucide-react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Badge } from '@evobgp/ui/components/badge'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { Separator } from '@evobgp/ui/components/separator'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { jobKindRu } from '@/lib/ui-labels'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
|
||||
DashboardOperationsFlowCard,
|
||||
MonitoringHealthCard,
|
||||
} from '@/components/analytics'
|
||||
import { MonitoringReadyGrid } from '@/components/monitoring/monitoring-ready-grid'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import { AnalyticsDashboardSkeleton } from '@/components/skeletons'
|
||||
|
||||
import {
|
||||
monitoringHealthQueryOptions,
|
||||
monitoringReadyQueryOptions,
|
||||
monitoringVersionQueryOptions,
|
||||
type ReadyStatus,
|
||||
type VersionInfo,
|
||||
} from '@/queries/monitoring'
|
||||
import { networkBirdQueryOptions } from '@/queries/network'
|
||||
import { operationsJobsQueryOptions } from '@/queries/operations'
|
||||
import { overviewModulesQueryOptions } from '@/queries/overview'
|
||||
|
||||
export const Route = createFileRoute('/_auth/monitoring')({
|
||||
component: MonitoringComponent,
|
||||
@@ -44,58 +40,31 @@ export const Route = createFileRoute('/_auth/monitoring')({
|
||||
|
||||
function MonitoringComponent() {
|
||||
const search = useSearch({ from: '/_auth/monitoring' })
|
||||
const navigate = Route.useNavigate()
|
||||
const healthQ = useQuery(monitoringHealthQueryOptions())
|
||||
const readyQ = useQuery(monitoringReadyQueryOptions())
|
||||
const versionQ = useQuery(monitoringVersionQueryOptions())
|
||||
const birdQ = useQuery(networkBirdQueryOptions())
|
||||
const jobsQ = useQuery(operationsJobsQueryOptions())
|
||||
const modulesQ = useQuery(overviewModulesQueryOptions())
|
||||
|
||||
const refreshing =
|
||||
healthQ.isFetching ||
|
||||
readyQ.isFetching ||
|
||||
versionQ.isFetching ||
|
||||
birdQ.isFetching ||
|
||||
jobsQ.isFetching
|
||||
jobsQ.isFetching ||
|
||||
modulesQ.isFetching
|
||||
|
||||
const jobs = jobsQ.data?.items ?? []
|
||||
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
|
||||
const modules = modulesQ.data?.items ?? []
|
||||
const failed = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
|
||||
const versionText = formatVersion(versionQ.data)
|
||||
|
||||
const items: SectionCardItem[] = [
|
||||
{
|
||||
label: 'Общий статус',
|
||||
value: overallStatusLabel({ health: healthQ.data, ready: readyQ.data, jobsFailed: failed }),
|
||||
icon: <Gauge className="size-4" />,
|
||||
hint: overallHint({ health: healthQ.data, jobsFailed: failed }),
|
||||
},
|
||||
{
|
||||
label: 'BGP сессии',
|
||||
value: birdQ.data
|
||||
? `${birdQ.data.bgp_established}/${birdQ.data.bgp_sessions_total}`
|
||||
: '—',
|
||||
icon: <Bird className="size-4" />,
|
||||
hint: birdQ.data?.birdc_configured
|
||||
? 'Established / total на API-хосте'
|
||||
: 'birdc не настроен',
|
||||
},
|
||||
{
|
||||
label: 'Задачи',
|
||||
value: running,
|
||||
icon: <Activity className="size-4" />,
|
||||
hint: `активных из ${jobs.length}`,
|
||||
variant: failed > 0 ? 'warning' : 'default',
|
||||
},
|
||||
{
|
||||
label: 'Версия',
|
||||
value: versionText,
|
||||
icon: <Gauge className="size-4" />,
|
||||
hint: versionQ.data?.git_sha ?? versionQ.data?.build_time ?? 'GET /v1/version',
|
||||
},
|
||||
]
|
||||
const analyticsLoading =
|
||||
(healthQ.isLoading || readyQ.isLoading || jobsQ.isLoading) && jobs.length === 0
|
||||
|
||||
function refetchAll() {
|
||||
void healthQ.refetch()
|
||||
@@ -103,6 +72,7 @@ function MonitoringComponent() {
|
||||
void versionQ.refetch()
|
||||
void birdQ.refetch()
|
||||
void jobsQ.refetch()
|
||||
void modulesQ.refetch()
|
||||
}
|
||||
|
||||
const failedJobs = jobs
|
||||
@@ -113,7 +83,13 @@ function MonitoringComponent() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Мониторинг"
|
||||
description="Состояние API, BGP и задач для диагностики инцидентов"
|
||||
description={`Состояние API, BGP и задач для диагностики инцидентов${
|
||||
versionText !== '—'
|
||||
? ` · версия ${versionText}${
|
||||
versionQ.data?.git_sha ? ` (${versionQ.data.git_sha.slice(0, 8)})` : ''
|
||||
}`
|
||||
: ''
|
||||
}`}
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
|
||||
@@ -122,45 +98,58 @@ function MonitoringComponent() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue={search.tab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="system">Система</TabsTrigger>
|
||||
<TabsTrigger value="postgres">PostgreSQL</TabsTrigger>
|
||||
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="system" className="mt-4 flex flex-col gap-6">
|
||||
{refreshing ? <SectionCardsSkeleton count={4} /> : <SectionCards items={items} />}
|
||||
<BadgeTabs
|
||||
value={search.tab}
|
||||
onValueChange={(tab) =>
|
||||
navigate({ search: { tab: tab as 'system' | 'postgres' | 'runtime-logs' } })
|
||||
}
|
||||
items={[
|
||||
{ value: 'system', label: 'Система' },
|
||||
{ value: 'postgres', label: 'PostgreSQL' },
|
||||
{ value: 'runtime-logs', label: 'Файловые логи' },
|
||||
]}
|
||||
>
|
||||
<TabsContent value="system" className="mt-0 flex flex-col gap-6">
|
||||
{analyticsLoading ? (
|
||||
<AnalyticsDashboardSkeleton />
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<MonitoringHealthCard
|
||||
healthOk={healthQ.data?.ok ?? false}
|
||||
ready={readyQ.data}
|
||||
loading={healthQ.isLoading || readyQ.isLoading}
|
||||
/>
|
||||
<DashboardOperationsFlowCard jobs={jobs} modules={modules} loading={jobsQ.isLoading} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Доступность и готовность</CardTitle>
|
||||
<CardDescription>GET /v1/health · GET /v1/ready</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<QueryState
|
||||
data={readyQ.data}
|
||||
isLoading={readyQ.isLoading}
|
||||
isError={readyQ.isError}
|
||||
error={readyQ.error}
|
||||
skeleton={<div className="h-40" />}
|
||||
onRetry={() => readyQ.refetch()}
|
||||
>
|
||||
{(ready) => <ReadyTable health={healthQ.data} ready={ready} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DataGridCard
|
||||
title="Доступность и готовность"
|
||||
description="GET /v1/health · GET /v1/ready"
|
||||
>
|
||||
<QueryState
|
||||
data={readyQ.data}
|
||||
isLoading={readyQ.isLoading}
|
||||
isError={readyQ.isError}
|
||||
error={readyQ.error}
|
||||
skeleton={<div className="h-40" />}
|
||||
onRetry={() => readyQ.refetch()}
|
||||
>
|
||||
{(ready) => <MonitoringReadyGrid health={healthQ.data} ready={ready} />}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<PanelCard
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Bird className="size-4" />
|
||||
BGP на API-хосте
|
||||
</CardTitle>
|
||||
<CardDescription>GET /v1/bird/status</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
</span>
|
||||
}
|
||||
description="GET /v1/bird/status"
|
||||
contentClassName="py-4"
|
||||
>
|
||||
<QueryState
|
||||
data={birdQ.data}
|
||||
isLoading={birdQ.isLoading}
|
||||
@@ -171,22 +160,22 @@ function MonitoringComponent() {
|
||||
>
|
||||
{(bird) => <BirdSummary bird={bird} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PanelCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<PanelCard
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Activity className="size-4" />
|
||||
Задачи
|
||||
</CardTitle>
|
||||
<CardDescription>Последние 100 задач · GET /v1/jobs</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
</span>
|
||||
}
|
||||
description="Последние 100 задач · GET /v1/jobs"
|
||||
contentClassName="space-y-4 py-4"
|
||||
>
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<Metric label="Активных" value={running} />
|
||||
<Metric label="Активных" value={jobs.filter((j) => j.status === 'running' || j.status === 'queued').length} />
|
||||
<Metric
|
||||
label="С ошибками"
|
||||
value={failed}
|
||||
@@ -202,8 +191,8 @@ function MonitoringComponent() {
|
||||
{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">
|
||||
<p className="font-medium">{job.kind}</p>
|
||||
<Badge variant="destructive">{job.status}</Badge>
|
||||
<p className="font-medium">{jobKindRu(job.kind)}</p>
|
||||
<StatusBadge status={job.status} />
|
||||
</div>
|
||||
{job.error ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
@@ -220,91 +209,68 @@ function MonitoringComponent() {
|
||||
Критичных сбоев в последних 100 задачах нет.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PanelCard>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<PanelCard
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-4 text-muted-foreground" />
|
||||
Что проверять при деградации
|
||||
</CardTitle>
|
||||
<CardDescription>Короткая шпаргалка для triage</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Alert>
|
||||
<HeartPulse className="size-4" />
|
||||
<AlertTitle>API недоступен</AlertTitle>
|
||||
<AlertDescription>
|
||||
Если <code className="text-xs">/v1/health</code> возвращает ошибку — проверьте процесс
|
||||
API и его логи.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Alert>
|
||||
<Database className="size-4" />
|
||||
<AlertTitle>Readiness не «Готов»</AlertTitle>
|
||||
<AlertDescription>
|
||||
Сначала <code className="text-xs">postgres</code>, затем{' '}
|
||||
<code className="text-xs">store</code> и <code className="text-xs">jobs</code> в checks.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Alert>
|
||||
<Bird className="size-4" />
|
||||
<AlertTitle>Низкий ratio BGP</AlertTitle>
|
||||
<AlertDescription>
|
||||
Проверьте <code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Alert>
|
||||
<ListTodo className="size-4" />
|
||||
<AlertTitle>Ошибки задач</AlertTitle>
|
||||
<AlertDescription>
|
||||
Откройте Операции и проверьте последние неуспешные jobs.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</span>
|
||||
}
|
||||
description="Краткая шпаргалка для первичной диагностики"
|
||||
contentClassName="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>
|
||||
</PanelCard>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="postgres" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">PostgreSQL</CardTitle>
|
||||
<CardDescription>Статус соединения и пул</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert>
|
||||
<Database className="size-4" />
|
||||
<AlertTitle>Статус готовности</AlertTitle>
|
||||
<AlertDescription>
|
||||
PostgreSQL-соединение отображается в readiness-проверке на вкладке «Система» (check{' '}
|
||||
<code className="text-xs">postgres</code>).
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TabsContent value="postgres" className="mt-0">
|
||||
<PanelCard
|
||||
title="PostgreSQL"
|
||||
description={
|
||||
<>
|
||||
Статус соединения и пул. PostgreSQL отображается в readiness-проверке на вкладке «Система»
|
||||
(check <code className="text-xs">postgres</code>).
|
||||
</>
|
||||
}
|
||||
contentClassName="py-4"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="runtime-logs" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Файловые логи</CardTitle>
|
||||
<CardDescription>Логи API и pipeline</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert>
|
||||
<Info className="size-4" />
|
||||
<AlertTitle>Логи на сервере</AlertTitle>
|
||||
<AlertDescription>
|
||||
Файловые логи настраиваются переменной <code className="text-xs">EVOBGP_LOG_*</code> и
|
||||
управляются tenant-settings на странице «Настройки BIRD».
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TabsContent value="runtime-logs" className="mt-0">
|
||||
<PanelCard
|
||||
title="Файловые логи"
|
||||
description={
|
||||
<>
|
||||
Логи API и pipeline настраиваются переменной <code className="text-xs">EVOBGP_LOG_*</code> и
|
||||
управляются в tenant-settings.
|
||||
</>
|
||||
}
|
||||
contentClassName="py-4"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</BadgeTabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -323,103 +289,6 @@ function formatVersion(version?: VersionInfo | null): string {
|
||||
return version.version ?? version.app ?? '—'
|
||||
}
|
||||
|
||||
interface OverallInput {
|
||||
health?: { ok?: boolean } | null
|
||||
ready?: ReadyStatus | null
|
||||
jobsFailed: number
|
||||
}
|
||||
|
||||
function overallStatusLabel(input: OverallInput): string {
|
||||
if (!input.health?.ok) return 'Ошибка'
|
||||
if (input.jobsFailed > 0) return 'Внимание'
|
||||
if (input.ready?.status && input.ready.status !== 'ok') return 'Внимание'
|
||||
return 'В норме'
|
||||
}
|
||||
|
||||
function overallHint(input: OverallInput): string {
|
||||
if (!input.health?.ok) return 'API недоступен или возвращает ошибку'
|
||||
if (input.jobsFailed > 0) return `Есть провальные задачи (${input.jobsFailed})`
|
||||
return 'Все системы работают в штатном режиме'
|
||||
}
|
||||
|
||||
function ReadyTable({
|
||||
health,
|
||||
ready,
|
||||
}: {
|
||||
health?: { ok?: boolean; status?: string; error?: string } | null
|
||||
ready: ReadyStatus
|
||||
}) {
|
||||
const checks = ready.checks ?? {}
|
||||
const iconByKey: Record<string, typeof Database> = {
|
||||
postgres: Database,
|
||||
store: HardDrive,
|
||||
jobs: ListTodo,
|
||||
}
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[55%]">Проверка</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<HeartPulse className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Liveness</p>
|
||||
<p className="text-xs text-muted-foreground">/v1/health</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={health?.ok ? 'default' : 'destructive'}>
|
||||
{health?.ok ? 'OK' : 'Ошибка'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Readiness</p>
|
||||
<p className="text-xs text-muted-foreground">/v1/ready</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ready.status === 'ok' ? 'default' : 'secondary'}>
|
||||
{ready.status ?? '—'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{Object.entries(checks).map(([key, value]) => {
|
||||
const ok = typeof value === 'boolean' ? value : value?.ok !== false
|
||||
const Icon = iconByKey[key] ?? ListTodo
|
||||
return (
|
||||
<TableRow key={key}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{key}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ok ? 'default' : 'destructive'}>{ok ? 'OK' : 'Ошибка'}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
|
||||
if (!bird.birdc_configured) {
|
||||
return (
|
||||
@@ -435,7 +304,7 @@ function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Established / total</span>
|
||||
<span className="text-muted-foreground">Установлено / всего</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{bird.bgp_established} / {bird.bgp_sessions_total}
|
||||
{ratio !== null ? <span className="text-muted-foreground"> ({ratio}%)</span> : null}
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { Info, RefreshCw } from 'lucide-react'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
|
||||
import {
|
||||
DashboardNetworkCapacityCard,
|
||||
NetworkOverviewAnalyticsCard,
|
||||
} from '@/components/analytics'
|
||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||
import { NetworkPeersCard } from '@/components/network/network-peers-card'
|
||||
import { NetworkSpeakersCard } from '@/components/network/network-speakers-card'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { networkBirdQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions } from '@/queries/network'
|
||||
import { aggregateNetworkMetrics } from '@/queries/overview'
|
||||
import { overviewJobsQueryOptions } from '@/queries/overview'
|
||||
|
||||
export const Route = createFileRoute('/_auth/network')({
|
||||
component: NetworkComponent,
|
||||
@@ -33,14 +28,17 @@ export const Route = createFileRoute('/_auth/network')({
|
||||
|
||||
function NetworkComponent() {
|
||||
const search = useSearch({ from: '/_auth/network' })
|
||||
const navigate = Route.useNavigate()
|
||||
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
|
||||
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
|
||||
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
|
||||
const jobsQ = useQuery(overviewJobsQueryOptions())
|
||||
|
||||
const refreshing = peersQ.isFetching || speakersQ.isFetching
|
||||
const peers = peersQ.data?.items ?? []
|
||||
const speakers = speakersQ.data?.items ?? []
|
||||
const net = aggregateNetworkMetrics(peers, speakers)
|
||||
const jobs = jobsQ.data?.items ?? []
|
||||
const overviewLoading = peersQ.isLoading || speakersQ.isLoading
|
||||
|
||||
function refetchAll() {
|
||||
void peersQ.refetch()
|
||||
@@ -61,115 +59,86 @@ function NetworkComponent() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert className="border-info/30 bg-info/5">
|
||||
<Info className="text-info" />
|
||||
<AlertTitle>О сетевой конфигурации</AlertTitle>
|
||||
<AlertDescription>
|
||||
Вкладка «Обзор» — live-статус agent и BGP на CP и репликах. Apply и ревизии — на странице «Операции».
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Tabs defaultValue={search.tab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Обзор</TabsTrigger>
|
||||
<TabsTrigger value="peers">Пиры ({peers.length})</TabsTrigger>
|
||||
<TabsTrigger value="speakers">Спикеры ({speakers.length})</TabsTrigger>
|
||||
<TabsTrigger value="control-plane">Control plane</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Сводка сети</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-3 p-4 text-sm">
|
||||
<Field label="Пиры всего" value={String(net.peersTotal)} />
|
||||
<Field label="Established" value={`${net.peersEstablished} / ${net.peersEnabled}`} />
|
||||
<Field label="Спикеры всего" value={String(net.speakersTotal)} />
|
||||
<Field label="Online" value={`${net.speakersOnline} / ${net.speakersTotal}`} />
|
||||
{net.peersMismatch > 0 ? (
|
||||
<Field label="Mismatches" value={String(net.peersMismatch)} />
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">BIRD (control plane)</CardTitle>
|
||||
<CardDescription>Статус birdc на хосте API</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<QueryState
|
||||
data={birdQ.data}
|
||||
isLoading={birdQ.isLoading}
|
||||
isError={birdQ.isError}
|
||||
error={birdQ.error}
|
||||
skeleton={<TableSkeleton rows={3} cols={2} />}
|
||||
onRetry={() => birdQ.refetch()}
|
||||
>
|
||||
{(bird) => <BirdSummary bird={bird} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<BadgeTabs
|
||||
value={search.tab}
|
||||
onValueChange={(tab) =>
|
||||
navigate({
|
||||
search: {
|
||||
tab: tab as 'overview' | 'peers' | 'speakers' | 'control-plane',
|
||||
},
|
||||
})
|
||||
}
|
||||
items={[
|
||||
{ value: 'overview', label: 'Обзор' },
|
||||
{ value: 'peers', label: 'Пиры', count: peers.length },
|
||||
{ value: 'speakers', label: 'Спикеры', count: speakers.length, badgeVariant: 'info-light' },
|
||||
{ value: 'control-plane', label: 'Плоскость управления' },
|
||||
]}
|
||||
>
|
||||
<TabsContent value="overview" className="mt-0">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<NetworkOverviewAnalyticsCard
|
||||
peers={peers}
|
||||
speakers={speakers}
|
||||
loading={overviewLoading}
|
||||
/>
|
||||
<DashboardNetworkCapacityCard
|
||||
peers={peers}
|
||||
speakers={speakers}
|
||||
jobs={jobs}
|
||||
loading={overviewLoading}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="peers" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Пиры</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<PanelCard
|
||||
className="mt-4"
|
||||
title="BIRD (control plane)"
|
||||
description="Статус birdc на хосте API"
|
||||
contentClassName="py-4"
|
||||
>
|
||||
<QueryState
|
||||
data={peers}
|
||||
isLoading={peersQ.isLoading}
|
||||
isError={peersQ.isError}
|
||||
error={peersQ.error}
|
||||
empty={peers.length === 0}
|
||||
emptyTitle="Нет пиров"
|
||||
skeleton={<TableSkeleton rows={6} cols={4} />}
|
||||
onRetry={() => peersQ.refetch()}
|
||||
data={birdQ.data}
|
||||
isLoading={birdQ.isLoading}
|
||||
isError={birdQ.isError}
|
||||
error={birdQ.error}
|
||||
skeleton={<TableSkeleton rows={3} cols={2} />}
|
||||
onRetry={() => birdQ.refetch()}
|
||||
>
|
||||
{(items) => <PeersTable items={items} />}
|
||||
{(bird) => <BirdSummary bird={bird} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PanelCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="speakers" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Спикеры</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={speakers}
|
||||
isLoading={speakersQ.isLoading}
|
||||
isError={speakersQ.isError}
|
||||
error={speakersQ.error}
|
||||
empty={speakers.length === 0}
|
||||
emptyTitle="Нет спикеров"
|
||||
skeleton={<TableSkeleton rows={6} cols={4} />}
|
||||
onRetry={() => speakersQ.refetch()}
|
||||
>
|
||||
{(items) => <SpeakersTable items={items} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TabsContent value="peers" className="mt-0">
|
||||
<NetworkPeersCard
|
||||
items={peers}
|
||||
speakers={speakers}
|
||||
isLoading={peersQ.isLoading}
|
||||
isError={peersQ.isError}
|
||||
error={peersQ.error}
|
||||
onRetry={() => peersQ.refetch()}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="control-plane" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Настройки Control Plane (BIRD)</CardTitle>
|
||||
<CardDescription>Конфигурация tenant-level — в разделе «Настройки BIRD»</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 text-sm text-muted-foreground">
|
||||
См. раздел «Настройки BIRD».
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TabsContent value="speakers" className="mt-0">
|
||||
<NetworkSpeakersCard
|
||||
items={speakers}
|
||||
isLoading={speakersQ.isLoading}
|
||||
isError={speakersQ.isError}
|
||||
error={speakersQ.error}
|
||||
onRetry={() => speakersQ.refetch()}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<TabsContent value="control-plane" className="mt-0">
|
||||
<PanelCard
|
||||
title="Настройки Control Plane (BIRD)"
|
||||
description="Конфигурация tenant-level — в разделе «Настройки BIRD»"
|
||||
contentClassName="py-4 text-sm text-muted-foreground"
|
||||
>
|
||||
См. раздел «Настройки BIRD».
|
||||
</PanelCard>
|
||||
</TabsContent>
|
||||
</BadgeTabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -196,78 +165,3 @@ function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PeersTable({ items }: { items: import('@/types/api').PeerRow[] }) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Имя</TableHead>
|
||||
<TableHead>Neighbor</TableHead>
|
||||
<TableHead>ASN</TableHead>
|
||||
<TableHead>Состояние</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium">{p.name ?? p.neighbor}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{p.neighbor}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{p.remote_asn ?? '—'}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={p.session_state} />
|
||||
{p.session_mismatch ? (
|
||||
<Badge variant="warning" className="ml-1">
|
||||
mismatch
|
||||
</Badge>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
function SpeakersTable({ items }: { items: import('@/types/api').SpeakerRow[] }) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Endpoint</TableHead>
|
||||
<TableHead>Роль</TableHead>
|
||||
<TableHead>Agent</TableHead>
|
||||
<TableHead>BGP</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="font-mono text-xs">{s.endpoint}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{s.role}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{s.live?.agent_ok === true ? (
|
||||
<StatusBadge status="ok" label="online" />
|
||||
) : s.live?.agent_ok === false ? (
|
||||
<StatusBadge status="error" label="offline" />
|
||||
) : (
|
||||
<Badge variant="outline">—</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{s.live ? (
|
||||
<span className="text-xs">
|
||||
{s.live.bgp_established ?? 0} / {s.live.bgp_sessions_total ?? 0}
|
||||
</span>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,39 +1,24 @@
|
||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { AlertTriangle, Clock, Activity, Info, RefreshCw } from 'lucide-react'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useState, useMemo } from 'react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evobgp/ui/components/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { OperationsAnalyticsCard } from '@/components/analytics'
|
||||
import { SelectMenu } from '@/components/select-field'
|
||||
import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid'
|
||||
import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
|
||||
import { operationsJobsQueryOptions, operationsRevisionsQueryOptions, operationsDiffQueryOptions } from '@/queries/operations'
|
||||
import { moduleNameById, overviewModulesQueryOptions } from '@/queries/overview'
|
||||
import { apiMutate, waitForJob } from '@/lib/api-client'
|
||||
import type { JobRow } from '@/types/api'
|
||||
|
||||
export const Route = createFileRoute('/_auth/operations')({
|
||||
component: OperationsComponent,
|
||||
@@ -47,6 +32,7 @@ export const Route = createFileRoute('/_auth/operations')({
|
||||
|
||||
function OperationsComponent() {
|
||||
const search = useSearch({ from: '/_auth/operations' })
|
||||
const navigate = Route.useNavigate()
|
||||
const qc = useQueryClient()
|
||||
|
||||
const revisionsQ = useQuery(operationsRevisionsQueryOptions())
|
||||
@@ -58,32 +44,6 @@ function OperationsComponent() {
|
||||
const nameById = moduleNameById(modulesQ.data?.items ?? [])
|
||||
|
||||
const refreshing = revisionsQ.isFetching || jobsQ.isFetching
|
||||
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
|
||||
const failed = jobs.filter(
|
||||
(j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
|
||||
const items: SectionCardItem[] = [
|
||||
{
|
||||
label: 'Ревизий',
|
||||
value: revisions.length,
|
||||
icon: <Activity className="size-4" />,
|
||||
hint: 'история конфигов',
|
||||
},
|
||||
{
|
||||
label: 'Активных задач',
|
||||
value: running,
|
||||
icon: <Clock className="size-4" />,
|
||||
hint: 'queued и running',
|
||||
},
|
||||
{
|
||||
label: 'Задач с ошибкой',
|
||||
value: failed,
|
||||
icon: <AlertTriangle className="size-4" />,
|
||||
hint: failed > 0 ? 'требуют внимания' : 'критичных сбоев нет',
|
||||
variant: failed > 0 ? 'warning' : 'default',
|
||||
},
|
||||
]
|
||||
|
||||
function refetchAll() {
|
||||
void revisionsQ.refetch()
|
||||
@@ -133,237 +93,101 @@ function OperationsComponent() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert className="border-info/30 bg-info/5">
|
||||
<Info className="text-info" />
|
||||
<AlertTitle>Три раздела на одной странице</AlertTitle>
|
||||
<AlertDescription>
|
||||
<strong>Ревизии</strong> — история конфигов и откат; <strong>Сравнение</strong> — diff
|
||||
префиксов; <strong>Задачи</strong> — ingest, apply, rollback.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="default" size="sm" disabled={applyMutation.isPending}>
|
||||
Apply
|
||||
Применить
|
||||
</Button>
|
||||
}
|
||||
title="Применить конфигурацию на всех спикерах?"
|
||||
description="Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль operator."
|
||||
description="Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль оператора."
|
||||
confirmLabel="Применить"
|
||||
onConfirm={() => applyMutation.mutate()}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="outline" size="sm" disabled={birdReloadMutation.isPending}>
|
||||
BIRD reload
|
||||
Перезагрузка BIRD
|
||||
</Button>
|
||||
}
|
||||
title="Перезагрузить BIRD?"
|
||||
description="BIRD перезагрузит конфигурацию. Требуется роль operator."
|
||||
description="BIRD перезагрузит конфигурацию. Требуется роль оператора."
|
||||
confirmLabel="Перезагрузить"
|
||||
onConfirm={() => birdReloadMutation.mutate()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{revisionsQ.isLoading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
|
||||
{revisionsQ.isLoading ? (
|
||||
<OperationsAnalyticsCard jobs={[]} revisions={[]} loading />
|
||||
) : (
|
||||
<OperationsAnalyticsCard jobs={jobs} revisions={revisions} />
|
||||
)}
|
||||
|
||||
<Tabs defaultValue={search.tab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="revisions">Ревизии ({revisions.length})</TabsTrigger>
|
||||
<TabsTrigger value="diff">Сравнение</TabsTrigger>
|
||||
<TabsTrigger value="jobs">Задачи ({jobs.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="revisions" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">История ревизий</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={revisions}
|
||||
isLoading={revisionsQ.isLoading}
|
||||
isError={revisionsQ.isError}
|
||||
error={revisionsQ.error}
|
||||
empty={revisions.length === 0}
|
||||
emptyTitle="Нет ревизий"
|
||||
onRetry={() => revisionsQ.refetch()}
|
||||
>
|
||||
{(items) => <RevisionsTable items={items} qc={qc} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<BadgeTabs
|
||||
value={search.tab}
|
||||
onValueChange={(tab) =>
|
||||
navigate({ search: { tab: tab as 'revisions' | 'diff' | 'jobs' } })
|
||||
}
|
||||
items={[
|
||||
{ value: 'revisions', label: 'Ревизии', count: revisions.length },
|
||||
{ value: 'diff', label: 'Сравнение' },
|
||||
{ value: 'jobs', label: 'Задачи', count: jobs.length, badgeVariant: 'info-light' },
|
||||
]}
|
||||
>
|
||||
<TabsContent value="revisions" className="mt-0">
|
||||
<DataGridCard title="История ревизий">
|
||||
<QueryState
|
||||
data={revisions}
|
||||
isLoading={revisionsQ.isLoading}
|
||||
isError={revisionsQ.isError}
|
||||
error={revisionsQ.error}
|
||||
empty={revisions.length === 0}
|
||||
emptyTitle="Нет ревизий"
|
||||
onRetry={() => revisionsQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<OperationsRevisionsGrid
|
||||
items={items}
|
||||
qc={qc}
|
||||
isLoading={revisionsQ.isFetching && !revisionsQ.isLoading}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="diff" className="mt-4">
|
||||
<TabsContent value="diff" className="mt-0">
|
||||
<DiffTab revisions={revisions} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="jobs" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Задачи</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={jobs}
|
||||
isLoading={jobsQ.isLoading}
|
||||
isError={jobsQ.isError}
|
||||
error={jobsQ.error}
|
||||
empty={jobs.length === 0}
|
||||
emptyTitle="Нет задач"
|
||||
onRetry={() => jobsQ.refetch()}
|
||||
>
|
||||
{(items) => <JobsTable items={items} nameById={nameById} qc={qc} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TabsContent value="jobs" className="mt-0">
|
||||
<DataGridCard title="Задачи">
|
||||
<QueryState
|
||||
data={jobs}
|
||||
isLoading={jobsQ.isLoading}
|
||||
isError={jobsQ.isError}
|
||||
error={jobsQ.error}
|
||||
empty={jobs.length === 0}
|
||||
emptyTitle="Нет задач"
|
||||
onRetry={() => jobsQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<OperationsJobsGrid
|
||||
items={items}
|
||||
nameById={nameById}
|
||||
qc={qc}
|
||||
isLoading={jobsQ.isFetching && !jobsQ.isLoading}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</BadgeTabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RevisionsTable({
|
||||
items,
|
||||
qc,
|
||||
}: {
|
||||
items: import('@/types/api').RevisionRow[]
|
||||
qc: import('@tanstack/react-query').QueryClient
|
||||
}) {
|
||||
const rollbackMutation = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiMutate(`/v1/revisions/${id}/rollback`, 'POST', {}).then(() => id),
|
||||
onSuccess: () => {
|
||||
toast.success('Откат выполнен')
|
||||
void qc.invalidateQueries({ queryKey: ['operations'] })
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось откатить'),
|
||||
})
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Создана</TableHead>
|
||||
<TableHead>Префиксов</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-mono text-xs">{r.id.slice(0, 12)}…</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{new Date(r.created_at).toLocaleString('ru-RU')}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm tabular-nums">
|
||||
{r.materialized_prefix_count}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" className="text-destructive">
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
title={`Откатиться к ревизии ${r.id.slice(0, 8)}…?`}
|
||||
description="Будет создана новая ревизия на основе выбранной. Требуется роль operator."
|
||||
confirmLabel="Откатить"
|
||||
destructive
|
||||
onConfirm={() => rollbackMutation.mutate(r.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
function JobsTable({
|
||||
items,
|
||||
nameById,
|
||||
qc,
|
||||
}: {
|
||||
items: JobRow[]
|
||||
nameById: Map<string, string>
|
||||
qc: import('@tanstack/react-query').QueryClient
|
||||
}) {
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: (jobId: string) => apiMutate(`/v1/jobs/${jobId}/cancel`, 'POST', {}),
|
||||
onSuccess: () => {
|
||||
toast.success('Задача отменена')
|
||||
void qc.invalidateQueries({ queryKey: ['operations'] })
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отменить'),
|
||||
})
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Вид</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Создана</TableHead>
|
||||
<TableHead>Завершена</TableHead>
|
||||
<TableHead className="w-20" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((j) => (
|
||||
<TableRow key={j.job_id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span>{j.kind}</span>
|
||||
{j.meta?.module_id ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{nameById.get(String(j.meta.module_id)) ?? String(j.meta.module_id)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadgeColored status={j.status} />
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{j.created_at ? new Date(j.created_at).toLocaleString('ru-RU') : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{j.finished_at ? new Date(j.finished_at).toLocaleString('ru-RU') : '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{j.status === 'running' || j.status === 'queued' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive"
|
||||
onClick={() => cancelMutation.mutate(j.job_id)}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[] }) {
|
||||
const [a, setA] = useState('')
|
||||
const [b, setB] = useState('')
|
||||
@@ -378,41 +202,25 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
|
||||
)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Сравнение ревизий</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 p-4">
|
||||
<PanelCard title="Сравнение ревизий" contentClassName="flex flex-col gap-4 py-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex w-full max-w-xs flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Ревизия A</span>
|
||||
<Select items={revisionItems} value={a} onValueChange={(v) => v && setA(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{revisions.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.id.slice(0, 12)}…
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SelectMenu
|
||||
items={revisionItems}
|
||||
value={a}
|
||||
placeholder="Выберите"
|
||||
onValueChange={(v) => v && setA(v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full max-w-xs flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Ревизия B</span>
|
||||
<Select items={revisionItems} value={b} onValueChange={(v) => v && setB(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{revisions.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.id.slice(0, 12)}…
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SelectMenu
|
||||
items={revisionItems}
|
||||
value={b}
|
||||
placeholder="Выберите"
|
||||
onValueChange={(v) => v && setB(v)}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => diffQ.refetch()} disabled={!a || !b || diffQ.isFetching}>
|
||||
Сравнить
|
||||
@@ -430,8 +238,7 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
|
||||
>
|
||||
{(diff) => <DiffView diff={diff} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PanelCard>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,18 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { AlertTriangle, Clock, Info, ListTodo, RefreshCw } from 'lucide-react'
|
||||
import { AlertTriangle, Clock, ListTodo, RefreshCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Badge } from '@evobgp/ui/components/badge'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
|
||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { ScheduleJobsGrid } from '@/components/schedule/schedule-jobs-grid'
|
||||
import { ScheduleModulesGrid } from '@/components/schedule/schedule-modules-grid'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
|
||||
import { operationsJobsQueryOptions } from '@/queries/operations'
|
||||
import { modulesListQueryOptions } from '@/queries/modules'
|
||||
@@ -49,7 +39,7 @@ function ScheduleComponent() {
|
||||
|
||||
const items: SectionCardItem[] = [
|
||||
{ label: 'Всего задач', value: jobs.length, icon: <ListTodo className="size-4" />, hint: 'в выборке' },
|
||||
{ label: 'В работе', value: running, icon: <Clock className="size-4" />, hint: 'queued и running' },
|
||||
{ label: 'В работе', value: running, icon: <Clock className="size-4" />, hint: 'в очереди и выполняются' },
|
||||
{
|
||||
label: 'С ошибкой',
|
||||
value: failed,
|
||||
@@ -96,94 +86,35 @@ function ScheduleComponent() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert className="border-info/30 bg-info/5">
|
||||
<Info className="text-info" />
|
||||
<AlertTitle>Как работает расписание</AlertTitle>
|
||||
<AlertDescription>
|
||||
Планировщик использует <code className="text-xs">refresh_interval_sec</code> и опционально{' '}
|
||||
<code className="text-xs">cron_expr</code>. Ручной запуск —{' '}
|
||||
<code className="text-xs">POST /v1/modules/{id}/refresh</code>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Модули</CardTitle>
|
||||
<CardDescription>Расписание обновления и ручной запуск ingest</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={modules}
|
||||
isLoading={modulesQ.isLoading}
|
||||
isError={modulesQ.isError}
|
||||
error={modulesQ.error}
|
||||
empty={modules.length === 0}
|
||||
emptyTitle="Нет модулей"
|
||||
onRetry={() => modulesQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Модуль</TableHead>
|
||||
<TableHead>Тип</TableHead>
|
||||
<TableHead>Расписание</TableHead>
|
||||
<TableHead>Обновлено</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead className="w-32 text-right" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((m) => (
|
||||
<TableRow key={m.id}>
|
||||
<TableCell className="font-medium">{m.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{m.type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{m.cron_expr ?? (m.refresh_interval_sec ? `${m.refresh_interval_sec}s` : '—')}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{m.last_refreshed_at ? new Date(m.last_refreshed_at).toLocaleString('ru-RU') : '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{m.enabled ? (
|
||||
<Badge variant="default">Вкл</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Выкл</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
loading={!!refreshing[m.id]}
|
||||
onClick={() => refreshMutation.mutate(m.id)}
|
||||
>
|
||||
<RefreshCw />
|
||||
Обновить
|
||||
</LoadingButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DataGridCard
|
||||
title="Модули"
|
||||
description="Расписание обновления и ручной запуск обновления"
|
||||
>
|
||||
<QueryState
|
||||
data={modules}
|
||||
isLoading={modulesQ.isLoading}
|
||||
isError={modulesQ.isError}
|
||||
error={modulesQ.error}
|
||||
empty={modules.length === 0}
|
||||
emptyTitle="Нет модулей"
|
||||
onRetry={() => modulesQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<ScheduleModulesGrid
|
||||
items={items}
|
||||
refreshing={refreshing}
|
||||
onRefresh={(id) => refreshMutation.mutate(id)}
|
||||
isLoading={modulesQ.isFetching && !modulesQ.isLoading}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Задачи</CardTitle>
|
||||
<CardDescription>Последние задачи из API</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<JobsTabs jobs={jobs} loading={jobsQ.isLoading} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DataGridCard title="Задачи" description="Последние задачи из API">
|
||||
<JobsTabs jobs={jobs} loading={jobsQ.isLoading} />
|
||||
</DataGridCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -195,69 +126,29 @@ function JobsTabs({ jobs, loading }: { jobs: JobRow[]; loading: boolean }) {
|
||||
)
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="all">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">Все ({jobs.length})</TabsTrigger>
|
||||
<TabsTrigger value="refresh">Обновление ({refresh.length})</TabsTrigger>
|
||||
<TabsTrigger value="failed">С ошибкой ({failed.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
<BadgeTabs
|
||||
defaultValue="all"
|
||||
listClassName="mx-3 mb-0 w-auto"
|
||||
items={[
|
||||
{ value: 'all', label: 'Все', count: jobs.length },
|
||||
{ value: 'refresh', label: 'Обновление', count: refresh.length, badgeVariant: 'info-light' },
|
||||
{
|
||||
value: 'failed',
|
||||
label: 'С ошибкой',
|
||||
count: failed.length,
|
||||
badgeVariant: failed.length > 0 ? 'destructive-light' : 'primary-light',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TabsContent value="all" className="mt-0">
|
||||
<JobsTable items={jobs} loading={loading} />
|
||||
<ScheduleJobsGrid items={jobs} isLoading={loading} />
|
||||
</TabsContent>
|
||||
<TabsContent value="refresh" className="mt-0">
|
||||
<JobsTable items={refresh} loading={loading} />
|
||||
<ScheduleJobsGrid items={refresh} isLoading={loading} />
|
||||
</TabsContent>
|
||||
<TabsContent value="failed" className="mt-0">
|
||||
<JobsTable items={failed} loading={loading} />
|
||||
<ScheduleJobsGrid items={failed} isLoading={loading} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
function JobsTable({ items, loading }: { items: JobRow[]; loading: boolean }) {
|
||||
if (loading) return <div className="p-6 text-center text-sm text-muted-foreground">Загрузка…</div>
|
||||
if (items.length === 0)
|
||||
return <div className="p-6 text-center text-sm text-muted-foreground">Нет задач</div>
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Вид</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Создана</TableHead>
|
||||
<TableHead>Завершена</TableHead>
|
||||
<TableHead>Ошибка</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((j) => (
|
||||
<TableRow key={j.job_id}>
|
||||
<TableCell className="font-medium">{j.kind}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
j.status === 'succeeded'
|
||||
? 'default'
|
||||
: j.status === 'failed'
|
||||
? 'destructive'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{j.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{j.created_at ? new Date(j.created_at).toLocaleString('ru-RU') : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{j.finished_at ? new Date(j.finished_at).toLocaleString('ru-RU') : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate text-xs text-destructive">
|
||||
{j.error ?? ''}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</BadgeTabs>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evobgp/ui/components/select'
|
||||
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||
import { authKeys, authSessionQueryOptions } from '@/queries/auth'
|
||||
import { toast } from 'sonner'
|
||||
import { Info, Save } from 'lucide-react'
|
||||
import { Save } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
@@ -76,25 +69,11 @@ function SettingsComponent() {
|
||||
description="Параметры интерфейса и подключения браузера к API."
|
||||
/>
|
||||
|
||||
<Alert className="border-info/30 bg-info/5">
|
||||
<Info className="text-info" />
|
||||
<AlertTitle>Локальная разработка</AlertTitle>
|
||||
<AlertDescription>
|
||||
При включённом demo-seed API принимает токен <code className="text-xs">dev</code> (роль{' '}
|
||||
<code className="text-xs">operator</code>). Вводите только значение токена, без префикса{' '}
|
||||
<code className="text-xs">Bearer</code> — он добавляется автоматически.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Подключение к API</CardTitle>
|
||||
<CardDescription>
|
||||
Токен хранится только в этом браузере (localStorage). Управление ключами tenant — в
|
||||
разделе «Права доступа».
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<PanelCard
|
||||
title="Подключение к API"
|
||||
description="Токен хранится только в этом браузере (localStorage). Управление ключами tenant — в разделе «Права доступа»."
|
||||
contentClassName="flex flex-col gap-4 py-4"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="token">Токен для запросов</Label>
|
||||
<Input
|
||||
@@ -130,34 +109,23 @@ function SettingsComponent() {
|
||||
<code className="text-xs">EVOBGP_SEED_DEMO</code> ≠ 0) и запущенный API.
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PanelCard>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Оформление</CardTitle>
|
||||
<CardDescription>
|
||||
Тема интерфейса. Быстрый переключатель также доступен в боковой панели.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<Label htmlFor="theme-select">Тема</Label>
|
||||
<Select
|
||||
<PanelCard
|
||||
title="Оформление"
|
||||
description="Тема интерфейса. Быстрый переключатель также доступен в боковой панели."
|
||||
contentClassName="flex flex-col gap-2 py-4"
|
||||
>
|
||||
<SelectField
|
||||
id="theme-select"
|
||||
label="Тема"
|
||||
items={[...THEME_SELECT_ITEMS]}
|
||||
value={theme ?? 'system'}
|
||||
placeholder="Выберите тему"
|
||||
triggerClassName="max-w-xs"
|
||||
onValueChange={(v) => v && setTheme(v)}
|
||||
>
|
||||
<SelectTrigger id="theme-select" className="w-full max-w-xs">
|
||||
<SelectValue placeholder="Выберите тему" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Светлая</SelectItem>
|
||||
<SelectItem value="dark">Тёмная</SelectItem>
|
||||
<SelectItem value="system">Как в системе</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
/>
|
||||
</PanelCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,30 +1,16 @@
|
||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Info, Save } from 'lucide-react'
|
||||
import { Save } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evobgp/ui/components/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
|
||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
@@ -56,8 +42,8 @@ const RUNTIME_LOGS_ENABLED_ITEMS = [
|
||||
] as const
|
||||
|
||||
const RUNTIME_LOGS_MODE_ITEMS = [
|
||||
{ value: 'truncate', label: 'truncate — обнулить' },
|
||||
{ value: 'delete', label: 'delete — удалить файл' },
|
||||
{ value: 'truncate', label: 'обнулить (truncate)' },
|
||||
{ value: 'delete', label: 'удалить файл (delete)' },
|
||||
] as const
|
||||
|
||||
const BIRD_LABELS: Record<BirdSettingKey, string> = {
|
||||
@@ -71,6 +57,7 @@ const BIRD_LABELS: Record<BirdSettingKey, string> = {
|
||||
|
||||
function TenantSettingsComponent() {
|
||||
const search = useSearch({ from: '/_auth/tenant-settings' })
|
||||
const navigate = Route.useNavigate()
|
||||
const settingsQ = useQuery(settingsQueryOptions())
|
||||
const qc = useQueryClient()
|
||||
|
||||
@@ -112,45 +99,35 @@ function TenantSettingsComponent() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Параметры tenant"
|
||||
description="Параметры control plane для текущего tenant (API /v1/settings)"
|
||||
title="Параметры арендатора"
|
||||
description="Параметры плоскости управления для текущего арендатора (API /v1/settings)"
|
||||
/>
|
||||
|
||||
<Alert className="border-info/30 bg-info/5">
|
||||
<Info className="text-info" />
|
||||
<AlertTitle>Operator-only</AlertTitle>
|
||||
<AlertDescription>
|
||||
Изменение значений через <code className="text-xs">PATCH /v1/settings</code> требует роли
|
||||
operator. При отсутствии прав API вернёт 403.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Tabs defaultValue={search.tab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="bird">BIRD</TabsTrigger>
|
||||
<TabsTrigger value="revision">Ревизии</TabsTrigger>
|
||||
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger>
|
||||
<TabsTrigger value="additional">Дополнительно</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="bird" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>BIRD control plane</CardTitle>
|
||||
<CardDescription>
|
||||
<BadgeTabs
|
||||
value={search.tab}
|
||||
onValueChange={(tab) =>
|
||||
navigate({
|
||||
search: { tab: tab as 'bird' | 'revision' | 'runtime-logs' | 'additional' },
|
||||
})
|
||||
}
|
||||
items={[
|
||||
{ value: 'bird', label: 'BIRD' },
|
||||
{ value: 'revision', label: 'Ревизии' },
|
||||
{ value: 'runtime-logs', label: 'Файловые логи' },
|
||||
{ value: 'additional', label: 'Дополнительно' },
|
||||
]}
|
||||
>
|
||||
<TabsContent value="bird" className="mt-0">
|
||||
<PanelCard
|
||||
title="BIRD control plane"
|
||||
description={
|
||||
<>
|
||||
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через{' '}
|
||||
<code className="text-xs">PATCH /v1/settings</code> (роль operator).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert className="border-info/30 bg-info/5">
|
||||
<Info className="text-info" />
|
||||
<AlertTitle>Подстановка в конфиг</AlertTitle>
|
||||
<AlertDescription>
|
||||
Значения используются при генерации BIRD-конфигурации (router id, local AS, адреса).
|
||||
Пиры и спикеры настраиваются в разделе «Сеть».
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
}
|
||||
contentClassName="space-y-4 py-4"
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
@@ -182,17 +159,15 @@ function TenantSettingsComponent() {
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PanelCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="revision" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ревизии</CardTitle>
|
||||
<CardDescription>Время хранения ревизий в БД</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<TabsContent value="revision" className="mt-0">
|
||||
<PanelCard
|
||||
title="Ревизии"
|
||||
description="Время хранения ревизий в БД"
|
||||
contentClassName="space-y-4 py-4"
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
@@ -229,17 +204,15 @@ function TenantSettingsComponent() {
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PanelCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="runtime-logs" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Файловые логи</CardTitle>
|
||||
<CardDescription>Автоматическая очистка логов</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<TabsContent value="runtime-logs" className="mt-0">
|
||||
<PanelCard
|
||||
title="Файловые логи"
|
||||
description="Автоматическая очистка логов"
|
||||
contentClassName="space-y-4 py-4"
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
@@ -250,31 +223,20 @@ function TenantSettingsComponent() {
|
||||
>
|
||||
{() => (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Авто-очистка включена</Label>
|
||||
<Select
|
||||
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
|
||||
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_auto_enabled: v,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Вкл</SelectItem>
|
||||
<SelectItem value="false">Выкл</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
runtime_logs_auto_enabled
|
||||
</p>
|
||||
</div>
|
||||
<SelectField
|
||||
label="Авто-очистка включена"
|
||||
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
|
||||
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
|
||||
placeholder="Выберите"
|
||||
description="runtime_logs_auto_enabled"
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_auto_enabled: v,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="runtime_logs_max_file_mb">Макс. размер файла (MB)</Label>
|
||||
<Input
|
||||
@@ -308,31 +270,20 @@ function TenantSettingsComponent() {
|
||||
runtime_logs_auto_schedule
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Режим очистки</Label>
|
||||
<Select
|
||||
items={[...RUNTIME_LOGS_MODE_ITEMS]}
|
||||
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_auto_mode: v,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="truncate">truncate — обнулить</SelectItem>
|
||||
<SelectItem value="delete">delete — удалить файл</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
runtime_logs_auto_mode
|
||||
</p>
|
||||
</div>
|
||||
<SelectField
|
||||
label="Режим очистки"
|
||||
items={[...RUNTIME_LOGS_MODE_ITEMS]}
|
||||
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
|
||||
placeholder="Выберите"
|
||||
description="runtime_logs_auto_mode"
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_auto_mode: v,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div className="md:col-span-2">
|
||||
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
|
||||
<Save />
|
||||
@@ -342,52 +293,34 @@ function TenantSettingsComponent() {
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PanelCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="additional" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Дополнительные параметры</CardTitle>
|
||||
<CardDescription>
|
||||
Параметры вне стандартных групп (readonly — изменяются только через API)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={partitioned?.additional ?? []}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
empty={(partitioned?.additional ?? []).length === 0}
|
||||
emptyTitle="Нет дополнительных параметров"
|
||||
skeleton={<div className="h-32" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Ключ</TableHead>
|
||||
<TableHead>Значение</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono text-xs">{row.key}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.value}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TabsContent value="additional" className="mt-0">
|
||||
<DataGridCard
|
||||
title="Дополнительные параметры"
|
||||
description="Параметры вне стандартных групп (только чтение — изменяются через API)"
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned?.additional ?? []}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
empty={(partitioned?.additional ?? []).length === 0}
|
||||
emptyTitle="Нет дополнительных параметров"
|
||||
skeleton={<div className="h-32" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<SettingsKvGrid
|
||||
items={items}
|
||||
isLoading={settingsQ.isFetching && !settingsQ.isLoading}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</BadgeTabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -356,6 +356,8 @@ export type FirewallClient = {
|
||||
last_apply_at?: string | null
|
||||
last_apply_status?: string
|
||||
last_apply_prefix_count?: number
|
||||
last_apply_packets_dropped?: number
|
||||
last_apply_packets_accepted?: number
|
||||
last_apply_source?: string
|
||||
client_version?: string
|
||||
created_at: string
|
||||
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user