Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c265c06f93 | ||
|
|
d434eb0d94 | ||
|
|
d0bd4d661d | ||
|
|
642db1a83a | ||
|
|
f66d68d1c7 | ||
|
|
e04fea657c | ||
|
|
452f6b2db0 | ||
|
|
b321aa5321 | ||
|
|
5d1102b497 | ||
|
|
5edbd656ba | ||
|
|
4a4c11c6bf | ||
|
|
e51999c908 | ||
|
|
b7f7669685 | ||
|
|
947d1f0cc4 | ||
|
|
68f9d4b832 | ||
|
|
72045afcde | ||
|
|
e15768b25b | ||
|
|
7b3f002e5f | ||
|
|
fa2abc81f3 | ||
|
|
7a3eae98b1 | ||
|
|
276194a9d0 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"pid": 59836,
|
||||
"pid": 43636,
|
||||
"version": "0.9.9",
|
||||
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
||||
"startedAt": 1783332305171
|
||||
"startedAt": 1783570299043
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,64 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Info } from 'lucide-react'
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@evobgp/ui/components/card'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@evobgp/ui/components/tooltip'
|
||||
|
||||
export function AnalyticsCardShell({
|
||||
title,
|
||||
description,
|
||||
info,
|
||||
actions,
|
||||
footer,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
info?: string
|
||||
actions?: ReactNode
|
||||
footer?: ReactNode
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Card className={cn('flex h-full flex-col gap-0 overflow-hidden', className)}>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 border-b py-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
{title}
|
||||
{info ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
className="inline-flex text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label="Подробнее"
|
||||
>
|
||||
<Info className="size-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs text-xs">
|
||||
{info}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</CardTitle>
|
||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||
</div>
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col gap-5 p-5">{children}</CardContent>
|
||||
{footer ? <CardFooter className="gap-2 border-t p-4">{footer}</CardFooter> : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Minus, TrendingDown, TrendingUp } from 'lucide-react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export type AnalyticsKpiItem = {
|
||||
label: string
|
||||
value: string
|
||||
delta?: {
|
||||
direction: 'up' | 'down' | 'neutral'
|
||||
label: string
|
||||
tone?: 'success' | 'warning' | 'destructive' | 'muted'
|
||||
}
|
||||
}
|
||||
|
||||
const TONE_CLASS = {
|
||||
success: 'text-success',
|
||||
warning: 'text-warning',
|
||||
destructive: 'text-destructive',
|
||||
muted: 'text-muted-foreground',
|
||||
} as const
|
||||
|
||||
function DeltaIcon({ direction }: { direction: AnalyticsKpiItem['delta'] extends infer D ? D extends { direction: infer Dir } ? Dir : never : never }) {
|
||||
if (direction === 'up') return <TrendingUp className="size-3" />
|
||||
if (direction === 'down') return <TrendingDown className="size-3" />
|
||||
return <Minus className="size-3" />
|
||||
}
|
||||
|
||||
export function AnalyticsKpiRow({ items, className }: { items: AnalyticsKpiItem[]; className?: string }) {
|
||||
return (
|
||||
<div className={cn('grid gap-4 sm:grid-cols-3', className)}>
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="min-w-0 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{item.label}</p>
|
||||
<p className="text-2xl font-semibold tracking-tight tabular-nums">{item.value}</p>
|
||||
{item.delta ? (
|
||||
<p
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-xs',
|
||||
TONE_CLASS[item.delta.tone ?? 'muted'],
|
||||
)}
|
||||
>
|
||||
<DeltaIcon direction={item.delta.direction} />
|
||||
{item.delta.label}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
Progress,
|
||||
ProgressIndicator,
|
||||
ProgressTrack,
|
||||
} from '@evobgp/ui/components/progress'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export function AnalyticsProgress({
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
className?: string
|
||||
}) {
|
||||
const clamped = Math.max(0, Math.min(100, value))
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-medium tabular-nums">{clamped}%</span>
|
||||
</div>
|
||||
<Progress value={clamped} className="gap-0">
|
||||
<ProgressTrack className="h-2">
|
||||
<ProgressIndicator className="bg-foreground" />
|
||||
</ProgressTrack>
|
||||
</Progress>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { ButtonGroup } from '@evobgp/ui/components/button-group'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export function AnalyticsSegmentControl<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
className,
|
||||
}: {
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
options: { value: T; label: string }[]
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<ButtonGroup className={cn('rounded-lg bg-muted/50 p-0.5', className)}>
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={value === option.value ? 'secondary' : 'ghost'}
|
||||
className={cn(
|
||||
'h-7 rounded-md px-2.5 text-xs',
|
||||
value === option.value && 'bg-background shadow-sm',
|
||||
)}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Bar, BarChart, XAxis } from 'recharts'
|
||||
|
||||
import { ChartContainer, type ChartConfig } from '@evobgp/ui/components/chart'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import type { CapacityBar } from '@/lib/metrics'
|
||||
|
||||
const chartConfig = {
|
||||
value: { label: 'Загрузка', color: 'var(--color-chart-2)' },
|
||||
} satisfies ChartConfig
|
||||
|
||||
export function ChartBarStrip({
|
||||
bars,
|
||||
className,
|
||||
}: {
|
||||
bars: CapacityBar[]
|
||||
className?: string
|
||||
}) {
|
||||
if (bars.length === 0) {
|
||||
return (
|
||||
<div className={cn('flex h-36 items-center justify-center text-sm text-muted-foreground', className)}>
|
||||
Нет данных для графика
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const data = bars.map((bar, index) => ({
|
||||
...bar,
|
||||
slot: index + 1,
|
||||
fill: bar.value >= 80 ? 'var(--color-chart-2)' : 'var(--color-chart-3)',
|
||||
}))
|
||||
|
||||
return (
|
||||
<ChartContainer config={chartConfig} className={cn('aspect-auto h-36 w-full', className)}>
|
||||
<BarChart data={data} margin={{ top: 4, right: 0, left: 0, bottom: 0 }}>
|
||||
<XAxis dataKey="slot" hide />
|
||||
<Bar dataKey="value" radius={[3, 3, 0, 0]} maxBarSize={10} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Cell, Label, Pie, PieChart } from 'recharts'
|
||||
|
||||
import {
|
||||
ChartContainer,
|
||||
type ChartConfig,
|
||||
} from '@evobgp/ui/components/chart'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import type { BreakdownSlice } from '@/lib/metrics'
|
||||
|
||||
export function ChartDonutMetric({
|
||||
slices,
|
||||
centerLabel,
|
||||
centerValue,
|
||||
className,
|
||||
}: {
|
||||
slices: BreakdownSlice[]
|
||||
centerLabel: string
|
||||
centerValue: string | number
|
||||
className?: string
|
||||
}) {
|
||||
const chartConfig = slices.reduce<ChartConfig>((acc, slice) => {
|
||||
acc[slice.key] = { label: slice.label, color: slice.color }
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const data = slices.map((slice) => ({
|
||||
...slice,
|
||||
fill: slice.color,
|
||||
}))
|
||||
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className={cn('flex h-48 items-center justify-center text-sm text-muted-foreground', className)}>
|
||||
Нет данных
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-6', className)}>
|
||||
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="count"
|
||||
nameKey="label"
|
||||
innerRadius={52}
|
||||
outerRadius={72}
|
||||
strokeWidth={2}
|
||||
stroke="var(--color-card)"
|
||||
>
|
||||
{data.map((entry) => (
|
||||
<Cell key={entry.key} fill={entry.fill} />
|
||||
))}
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (!viewBox || !('cx' in viewBox) || !('cy' in viewBox)) return null
|
||||
const { cx, cy } = viewBox
|
||||
return (
|
||||
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle">
|
||||
<tspan x={cx} y={(cy ?? 0) - 6} className="fill-muted-foreground text-xs">
|
||||
{centerLabel}
|
||||
</tspan>
|
||||
<tspan x={cx} y={(cy ?? 0) + 14} className="fill-foreground text-xl font-semibold">
|
||||
{centerValue}
|
||||
</tspan>
|
||||
</text>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
|
||||
<ul className="min-w-0 flex-1 space-y-3">
|
||||
{slices.map((slice) => {
|
||||
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
|
||||
return (
|
||||
<li key={slice.key} className="flex items-center justify-between gap-3 text-sm">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: slice.color }}
|
||||
/>
|
||||
<span className="truncate text-muted-foreground">{slice.label}</span>
|
||||
</div>
|
||||
<div className="shrink-0 text-right tabular-nums">
|
||||
<span className="font-semibold">{slice.count}</span>
|
||||
<span className="ml-2 text-muted-foreground">{pct}%</span>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
} from '@evobgp/ui/components/avatar'
|
||||
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
|
||||
import { ChartBarStrip } from '@/components/analytics/chart-bar-strip'
|
||||
import {
|
||||
capacityUtilization,
|
||||
peerCapacityBars,
|
||||
speakerCapacityBars,
|
||||
} from '@/lib/metrics'
|
||||
import { runningJobCount } from '@/queries/overview'
|
||||
import type { JobRow, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
type CapacityMode = 'peers' | 'speakers'
|
||||
|
||||
function speakerInitials(speaker: SpeakerRow): string {
|
||||
const label = speaker.live?.label ?? speaker.agent_domain ?? speaker.endpoint ?? speaker.id
|
||||
const parts = label.split(/[.\-_@/]/).filter(Boolean)
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
|
||||
return label.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
export function DashboardNetworkCapacityCard({
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const [mode, setMode] = useState<CapacityMode>('peers')
|
||||
|
||||
const bars = useMemo(
|
||||
() => (mode === 'peers' ? peerCapacityBars(peers) : speakerCapacityBars(speakers)),
|
||||
[mode, peers, speakers],
|
||||
)
|
||||
const utilization = capacityUtilization(bars)
|
||||
const queued = runningJobCount(jobs)
|
||||
const previewSpeakers = speakers.slice(0, 3)
|
||||
|
||||
const deltaLabel =
|
||||
mode === 'peers'
|
||||
? `${peers.filter((p) => p.enabled !== false && p.session_state === 'Established').length} Established`
|
||||
: `${speakers.filter((s) => s.live?.agent_ok).length} online`
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
title="Загрузка BGP"
|
||||
description="Текущая утилизация сессий по пирам и спикерам"
|
||||
info="Каждый столбец — enabled peer или speaker. Высота отражает Established/online."
|
||||
actions={
|
||||
<AnalyticsSegmentControl
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ value: 'peers', label: 'Пиры' },
|
||||
{ value: 'speakers', label: 'Спикеры' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="text-3xl font-semibold tracking-tight tabular-nums">
|
||||
{loading ? '—' : `${utilization}%`}
|
||||
</p>
|
||||
<p className="text-sm text-success">{loading ? '…' : `${deltaLabel} · снимок live`}</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-36 items-center justify-center text-sm text-muted-foreground">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartBarStrip bars={bars} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
Активных задач: <span className="font-medium text-foreground">{loading ? '—' : queued}</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<AvatarGroup>
|
||||
{previewSpeakers.map((speaker) => (
|
||||
<Avatar key={speaker.id} size="sm">
|
||||
<AvatarFallback>{speakerInitials(speaker)}</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
{speakers.length > 3 ? (
|
||||
<AvatarGroupCount>+{speakers.length - 3}</AvatarGroupCount>
|
||||
) : null}
|
||||
</AvatarGroup>
|
||||
<span className="text-muted-foreground">{speakers.length} спикеров</span>
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsCardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
|
||||
import type { JobRow, ModuleRow } from '@/types/api'
|
||||
|
||||
type FlowMode = 'jobs' | 'modules'
|
||||
|
||||
export function DashboardOperationsFlowCard({
|
||||
jobs,
|
||||
modules,
|
||||
loading,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
modules: ModuleRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const [mode, setMode] = useState<FlowMode>('jobs')
|
||||
|
||||
const slices = useMemo(
|
||||
() => (mode === 'jobs' ? jobStatusBreakdown(jobs) : moduleTypeBreakdown(modules)),
|
||||
[mode, jobs, modules],
|
||||
)
|
||||
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
const centerLabel = mode === 'jobs' ? 'Задачи' : 'Модули'
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
title="Поток операций"
|
||||
description="Распределение фоновых задач и типов модулей"
|
||||
info="Donut строится по текущей выборке API (до 100 последних задач)."
|
||||
actions={
|
||||
<AnalyticsSegmentControl
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ value: 'jobs', label: 'Задачи' },
|
||||
{ value: 'modules', label: 'Модули' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric
|
||||
slices={slices}
|
||||
centerLabel={centerLabel}
|
||||
centerValue={total}
|
||||
/>
|
||||
)}
|
||||
</AnalyticsCardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { AnalyticsActivityList } from '@/components/analytics/analytics-activity-list'
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
|
||||
import { AnalyticsProgress } from '@/components/analytics/analytics-progress'
|
||||
import {
|
||||
deploymentProgress,
|
||||
recentPlatformActivity,
|
||||
} from '@/lib/metrics'
|
||||
import { runningJobCount } from '@/queries/overview'
|
||||
import type { JobRow, ModuleRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
export function DashboardPlatformCard({
|
||||
modules,
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
revisions,
|
||||
loading,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
revisions: RevisionRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const enabledModules = modules.filter((m) => m.enabled !== false).length
|
||||
const peersEnabled = peers.filter((p) => p.enabled !== false).length
|
||||
const peersEstablished = peers.filter(
|
||||
(p) => p.enabled !== false && p.session_state === 'Established',
|
||||
).length
|
||||
const peersMismatch = peers.filter((p) => p.session_mismatch).length
|
||||
const speakersOnline = speakers.filter((s) => s.live?.agent_ok).length
|
||||
const failedJobs = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
const running = runningJobCount(jobs)
|
||||
const riskCount = peersMismatch + failedJobs + Math.max(0, speakers.length - speakersOnline)
|
||||
|
||||
const bgpPct =
|
||||
peersEnabled > 0 ? Math.round((peersEstablished / peersEnabled) * 100) : null
|
||||
|
||||
const deploy = useMemo(() => deploymentProgress(speakers), [speakers])
|
||||
const activity = useMemo(
|
||||
() => recentPlatformActivity(jobs, revisions, peers, speakers),
|
||||
[jobs, revisions, peers, speakers],
|
||||
)
|
||||
|
||||
const kpis = [
|
||||
{
|
||||
label: 'Модули активны',
|
||||
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
|
||||
delta: {
|
||||
direction: 'neutral' as const,
|
||||
label: `${modules.length} всего`,
|
||||
tone: 'muted' as const,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'BGP готовность',
|
||||
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
|
||||
delta: {
|
||||
direction: (bgpPct !== null && bgpPct >= 90 ? 'up' : bgpPct !== null && bgpPct < 70 ? 'down' : 'neutral') as
|
||||
| 'up'
|
||||
| 'down'
|
||||
| 'neutral',
|
||||
label:
|
||||
bgpPct === null
|
||||
? 'нет включённых пиров'
|
||||
: `${peersEstablished} Established`,
|
||||
tone: (bgpPct !== null && bgpPct >= 90
|
||||
? 'success'
|
||||
: bgpPct !== null && bgpPct < 70
|
||||
? 'warning'
|
||||
: 'muted') as 'success' | 'warning' | 'muted',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Риски',
|
||||
value: loading ? '—' : String(riskCount),
|
||||
delta: {
|
||||
direction: (riskCount > 0 ? 'down' : 'up') as 'up' | 'down',
|
||||
label: riskCount > 0 ? `${failedJobs} задач, ${peersMismatch} mismatch` : 'в норме',
|
||||
tone: (riskCount > 0 ? 'destructive' : 'success') as 'destructive' | 'success',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const progressLabel =
|
||||
deploy.mode === 'revision'
|
||||
? `Синхронизация ревизий (${deploy.synced}/${deploy.total})`
|
||||
: `Спикеры online (${deploy.synced}/${deploy.total})`
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
title="Состояние платформы"
|
||||
description="Сводка модулей, BGP и фоновых задач"
|
||||
info="Актуальный снимок без исторических трендов. Обновите данные кнопкой «Обновить» на странице."
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => navigate({ to: '/schedule' })}
|
||||
>
|
||||
Расписание
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => navigate({ to: '/monitoring', search: { tab: 'system' } })}
|
||||
>
|
||||
Мониторинг
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AnalyticsKpiRow items={kpis} />
|
||||
<AnalyticsProgress label={progressLabel} value={loading ? 0 : deploy.percent} />
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Недавняя активность</span>
|
||||
{!loading ? (
|
||||
<span className="text-xs text-muted-foreground">{running} активных задач</span>
|
||||
) : null}
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Загрузка…</p>
|
||||
) : (
|
||||
<AnalyticsActivityList items={activity} />
|
||||
)}
|
||||
</div>
|
||||
</AnalyticsCardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export { AnalyticsActivityList } from './analytics-activity-list'
|
||||
export { AnalyticsCardShell } from './analytics-card-shell'
|
||||
export { AnalyticsKpiRow, type AnalyticsKpiItem } from './analytics-kpi-row'
|
||||
export { AnalyticsProgress } from './analytics-progress'
|
||||
export { AnalyticsSegmentControl } from './analytics-segment-control'
|
||||
export { ChartBarStrip } from './chart-bar-strip'
|
||||
export { ChartDonutMetric } from './chart-donut-metric'
|
||||
export { DashboardNetworkCapacityCard } from './dashboard-network-capacity-card'
|
||||
export { DashboardOperationsFlowCard } from './dashboard-operations-flow-card'
|
||||
export { DashboardPlatformCard } from './dashboard-platform-card'
|
||||
export { MonitoringHealthCard } from './monitoring-health-card'
|
||||
export { NetworkOverviewAnalyticsCard } from './network-overview-analytics-card'
|
||||
export { OperationsAnalyticsCard } from './operations-analytics-card'
|
||||
@@ -0,0 +1,33 @@
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { readinessBreakdown } from '@/lib/metrics'
|
||||
import type { ReadyStatus } from '@/queries/monitoring'
|
||||
|
||||
export function MonitoringHealthCard({
|
||||
healthOk,
|
||||
ready,
|
||||
loading,
|
||||
}: {
|
||||
healthOk: boolean
|
||||
ready: ReadyStatus | null | undefined
|
||||
loading?: boolean
|
||||
}) {
|
||||
const slices = readinessBreakdown(ready, healthOk)
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
title="Доступность системы"
|
||||
description="Health и readiness checks"
|
||||
info="Donut отражает результат GET /v1/health и checks из GET /v1/ready."
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric slices={slices} centerLabel="Checks" centerValue={total} />
|
||||
)}
|
||||
</AnalyticsCardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { peerSessionBreakdown } from '@/lib/metrics'
|
||||
import { aggregateNetworkMetrics } from '@/queries/overview'
|
||||
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
export function NetworkOverviewAnalyticsCard({
|
||||
peers,
|
||||
speakers,
|
||||
loading,
|
||||
}: {
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const net = aggregateNetworkMetrics(peers, speakers)
|
||||
const slices = peerSessionBreakdown(peers)
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
title="Сводка BGP"
|
||||
description="Established, online и mismatch по live-данным"
|
||||
info="Снимок текущего состояния пиров и спикеров."
|
||||
>
|
||||
<AnalyticsKpiRow
|
||||
items={[
|
||||
{
|
||||
label: 'Пиры Established',
|
||||
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
|
||||
delta: {
|
||||
direction: net.peersMismatch > 0 ? 'down' : 'up',
|
||||
label: net.peersMismatch > 0 ? `${net.peersMismatch} mismatch` : 'сессии в норме',
|
||||
tone: net.peersMismatch > 0 ? 'warning' : 'success',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Спикеры online',
|
||||
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
|
||||
delta: {
|
||||
direction: net.speakersOnline < net.speakersTotal ? 'down' : 'up',
|
||||
label:
|
||||
net.speakersOnline < net.speakersTotal
|
||||
? `${net.speakersTotal - net.speakersOnline} offline`
|
||||
: 'все online',
|
||||
tone: net.speakersOnline < net.speakersTotal ? 'warning' : 'success',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Пиры всего',
|
||||
value: loading ? '—' : String(net.peersTotal),
|
||||
delta: { direction: 'neutral', label: 'в каталоге', tone: 'muted' },
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric slices={slices} centerLabel="Пиры" centerValue={total} />
|
||||
)}
|
||||
</AnalyticsCardShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
||||
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
|
||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
||||
import { jobStatusBreakdown } from '@/lib/metrics'
|
||||
import type { JobRow, RevisionRow } from '@/types/api'
|
||||
|
||||
export function OperationsAnalyticsCard({
|
||||
jobs,
|
||||
revisions,
|
||||
loading,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
revisions: RevisionRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const running = jobs.filter((j) => ['running', 'queued'].includes(j.status.toLowerCase())).length
|
||||
const failed = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
const slices = jobStatusBreakdown(jobs)
|
||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
||||
|
||||
return (
|
||||
<AnalyticsCardShell
|
||||
title="Операции и задачи"
|
||||
description="Статистика ревизий и фоновых jobs"
|
||||
info="Данные из GET /v1/jobs и /v1/revisions."
|
||||
>
|
||||
<AnalyticsKpiRow
|
||||
items={[
|
||||
{
|
||||
label: 'Ревизий',
|
||||
value: loading ? '—' : String(revisions.length),
|
||||
delta: { direction: 'neutral', label: 'в выборке', tone: 'muted' },
|
||||
},
|
||||
{
|
||||
label: 'Активных задач',
|
||||
value: loading ? '—' : String(running),
|
||||
delta: {
|
||||
direction: running > 0 ? 'up' : 'neutral',
|
||||
label: running > 0 ? 'выполняются' : 'очередь пуста',
|
||||
tone: (running > 0 ? 'warning' : 'muted') as 'warning' | 'muted',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'С ошибкой',
|
||||
value: loading ? '—' : String(failed),
|
||||
delta: {
|
||||
direction: failed > 0 ? 'down' : 'up',
|
||||
label: failed > 0 ? 'требуют внимания' : 'в норме',
|
||||
tone: failed > 0 ? 'destructive' : 'success',
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
Загрузка…
|
||||
</div>
|
||||
) : (
|
||||
<ChartDonutMetric slices={slices} centerLabel="Задачи" centerValue={total} />
|
||||
)}
|
||||
</AnalyticsCardShell>
|
||||
)
|
||||
}
|
||||
@@ -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,126 @@
|
||||
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,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from '@evobgp/ui/components/drawer'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
trigger: ReactElement
|
||||
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>
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
|
||||
</DrawerHeader>
|
||||
<DrawerFooter className="border-t bg-muted/50 sm:flex-row sm:justify-end">
|
||||
<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>
|
||||
)}
|
||||
</DrawerFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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="h-full max-h-none sm:max-w-sm">
|
||||
<ConfirmDrawerBody {...bodyProps} />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer open={props.open} onOpenChange={props.onOpenChange} swipeDirection="right">
|
||||
<DrawerContent className="h-full max-h-none sm:max-w-sm">
|
||||
<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="Пиры 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { CardFooter } from '@evobgp/ui/components/card'
|
||||
|
||||
export function DashboardQuickActions() {
|
||||
return (
|
||||
<CardFooter className="flex flex-wrap gap-2 border-t-0 bg-transparent">
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/modules" />}>
|
||||
<Plus className="size-4" />
|
||||
Создать модуль
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/directories" />}>
|
||||
<Tags className="size-4" />
|
||||
Добавить community
|
||||
</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" />
|
||||
Деплой (Apply)
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/monitoring" search={{ tab: 'system' }} />}>
|
||||
<Gauge className="size-4" />
|
||||
Мониторинг
|
||||
</Button>
|
||||
</CardFooter>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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 { 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={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 `${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,115 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Table } from '@tanstack/react-table'
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
|
||||
import { DataGridToolbar } from '@/components/data-grid-toolbar'
|
||||
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_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}
|
||||
tableLayout={tableLayout}
|
||||
className={className}
|
||||
onRowClick={onRowClick}
|
||||
>
|
||||
<DataGridContainer>
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
{showPagination ? (
|
||||
<div className="border-t px-4 py-3">
|
||||
<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) {
|
||||
const hasHeader = Boolean(title || description || actions)
|
||||
return (
|
||||
<Card className={className ?? 'gap-0'}>
|
||||
{hasHeader ? (
|
||||
<CardHeader className="flex flex-row items-center justify-between border-b py-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{title ? <CardTitle className="text-base">{title}</CardTitle> : null}
|
||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
|
||||
</CardHeader>
|
||||
) : null}
|
||||
<CardContent className="p-0">{children}</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
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-4 py-3 ${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,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="Last seen" />,
|
||||
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: 'Last seen' },
|
||||
},
|
||||
{
|
||||
id: 'apply',
|
||||
enableSorting: false,
|
||||
header: 'Apply',
|
||||
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: 'Apply' },
|
||||
},
|
||||
{
|
||||
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,47 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from '@evobgp/ui/components/drawer'
|
||||
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
|
||||
|
||||
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('h-full max-h-none sm:max-w-lg', className)}>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
|
||||
</DrawerHeader>
|
||||
<ScrollArea className="min-h-0 flex-1 px-4">
|
||||
<div className="space-y-4 pb-4">{children}</div>
|
||||
</ScrollArea>
|
||||
<DrawerFooter className="border-t bg-muted/50 sm:flex-row sm:justify-end">{footer}</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
BookText,
|
||||
KeyRound,
|
||||
ServerCog,
|
||||
Shield,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
@@ -69,6 +70,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
label: 'Операции',
|
||||
items: [
|
||||
{ to: '/operations', label: 'Операции', icon: Cog },
|
||||
{ to: '/firewall', label: 'Firewall', icon: Shield },
|
||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks },
|
||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity },
|
||||
],
|
||||
|
||||
@@ -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,97 @@
|
||||
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 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>{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,119 @@
|
||||
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 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: 'Liveness',
|
||||
subtitle: '/v1/health',
|
||||
icon: HeartPulse,
|
||||
status: health?.ok ? 'ok' : 'error',
|
||||
statusLabel: health?.ok ? 'OK' : 'Ошибка',
|
||||
},
|
||||
{
|
||||
id: 'readiness',
|
||||
label: 'Readiness',
|
||||
subtitle: '/v1/ready',
|
||||
icon: ShieldCheck,
|
||||
status: ready.status === 'ok' ? 'ok' : 'warning',
|
||||
statusLabel: ready.status ?? '—',
|
||||
},
|
||||
]
|
||||
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: key,
|
||||
icon: READY_CHECK_ICONS[key] ?? ListTodo,
|
||||
status: ok ? 'ok' : 'error',
|
||||
statusLabel: ok ? '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,86 @@
|
||||
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 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="Neighbor" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
|
||||
),
|
||||
meta: { headerTitle: 'Neighbor' },
|
||||
},
|
||||
{
|
||||
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} />
|
||||
{row.original.session_mismatch ? (
|
||||
<CategoryBadge tone="warning">mismatch</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,86 @@
|
||||
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 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="Endpoint" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
|
||||
),
|
||||
meta: { headerTitle: 'Endpoint' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
||||
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
|
||||
meta: { headerTitle: 'Роль' },
|
||||
},
|
||||
{
|
||||
id: 'agent',
|
||||
enableSorting: false,
|
||||
header: 'Agent',
|
||||
cell: ({ row }) => {
|
||||
const live = row.original.live
|
||||
if (live?.agent_ok === true) return <StatusBadge status="ok" label="online" />
|
||||
if (live?.agent_ok === false) return <StatusBadge status="error" label="offline" />
|
||||
return <Badge variant="outline" size="sm" radius="full">—</Badge>
|
||||
},
|
||||
meta: { headerTitle: 'Agent' },
|
||||
},
|
||||
{
|
||||
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">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: 'replica' },
|
||||
{ value: 'master', label: 'master (CP)' },
|
||||
]}
|
||||
value={role}
|
||||
onValueChange={(v) => setRole(v ?? 'replica')}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="speaker-agent-domain">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">Node 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,138 @@
|
||||
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 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={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 `${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="Поиск ревизий…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -152,28 +148,23 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{mergedProps.rowsPerPageLabel}
|
||||
</div>
|
||||
<Select
|
||||
items={mergedProps?.sizes?.map((size: number) => ({
|
||||
value: `${size}`,
|
||||
label: `${size}`,
|
||||
}))}
|
||||
<SelectMenu
|
||||
items={
|
||||
mergedProps?.sizes?.map((size: number) => ({
|
||||
value: `${size}`,
|
||||
label: `${size}`,
|
||||
})) ?? []
|
||||
}
|
||||
value={`${pageSize}`}
|
||||
triggerClassName="w-14"
|
||||
size="sm"
|
||||
side="top"
|
||||
contentClassName="min-w-18"
|
||||
onValueChange={(value) => {
|
||||
const newPageSize = Number(value)
|
||||
table.setPageSize(newPageSize)
|
||||
if (!value) return
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-14" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top" className="min-w-18">
|
||||
{mergedProps?.sizes?.map((size: number) => (
|
||||
<SelectItem key={size} value={`${size}`}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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,97 @@
|
||||
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 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={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) => `${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,113 @@
|
||||
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 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>{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="Поиск модулей…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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,6 +15,38 @@ export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function AnalyticsDashboardSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-3 lg:grid-rows-2">
|
||||
<Card className="gap-0 lg:row-span-2">
|
||||
<CardContent className="space-y-4 p-5">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-2 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="gap-0">
|
||||
<CardContent className="space-y-4 p-5">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
<Skeleton className="h-36 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="gap-0">
|
||||
<CardContent className="space-y-4 p-5">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-44 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
|
||||
return (
|
||||
<Card className="gap-0">
|
||||
|
||||
@@ -1,30 +1,71 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
|
||||
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 ?? 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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_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,32 @@
|
||||
import type { SpeakerRow } from '@/types/api'
|
||||
|
||||
import type { DeploymentProgress } from './types'
|
||||
|
||||
export function deploymentProgress(speakers: SpeakerRow[]): DeploymentProgress {
|
||||
if (speakers.length === 0) {
|
||||
return { percent: 0, synced: 0, total: 0, mode: 'online' }
|
||||
}
|
||||
|
||||
const withRevision = speakers.filter(
|
||||
(s) => s.published_revision_id && s.last_applied_revision_id,
|
||||
)
|
||||
if (withRevision.length > 0) {
|
||||
const synced = withRevision.filter(
|
||||
(s) => s.published_revision_id === s.last_applied_revision_id,
|
||||
).length
|
||||
return {
|
||||
percent: Math.round((synced / withRevision.length) * 100),
|
||||
synced,
|
||||
total: withRevision.length,
|
||||
mode: 'revision',
|
||||
}
|
||||
}
|
||||
|
||||
const online = speakers.filter((s) => s.live?.agent_ok).length
|
||||
return {
|
||||
percent: Math.round((online / speakers.length) * 100),
|
||||
synced: online,
|
||||
total: speakers.length,
|
||||
mode: 'online',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './types'
|
||||
export * from './job-status-breakdown'
|
||||
export * from './module-type-breakdown'
|
||||
export * from './peer-capacity-bars'
|
||||
export * from './deployment-progress'
|
||||
export * from './readiness-breakdown'
|
||||
export * from './peer-session-breakdown'
|
||||
export * from './recent-platform-activity'
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { JobRow } from '@/types/api'
|
||||
|
||||
import type { BreakdownSlice } from './types'
|
||||
|
||||
const STATUS_BUCKETS: { keys: string[]; label: string; color: string }[] = [
|
||||
{ keys: ['succeeded', 'success'], label: 'Успешно', color: 'var(--color-chart-2)' },
|
||||
{ keys: ['running', 'queued'], label: 'Активные', color: 'var(--color-chart-1)' },
|
||||
{ keys: ['failed', 'error', 'cancelled'], label: 'Ошибки', color: 'var(--color-destructive)' },
|
||||
]
|
||||
|
||||
function bucketForStatus(status: string): string {
|
||||
const s = status.toLowerCase()
|
||||
for (const bucket of STATUS_BUCKETS) {
|
||||
if (bucket.keys.includes(s)) return bucket.label
|
||||
}
|
||||
return 'Прочее'
|
||||
}
|
||||
|
||||
export function jobStatusBreakdown(jobs: JobRow[]): BreakdownSlice[] {
|
||||
const counts = new Map<string, number>()
|
||||
for (const job of jobs) {
|
||||
const label = bucketForStatus(job.status)
|
||||
counts.set(label, (counts.get(label) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const slices: BreakdownSlice[] = []
|
||||
for (const bucket of STATUS_BUCKETS) {
|
||||
const count = counts.get(bucket.label) ?? 0
|
||||
if (count > 0) {
|
||||
slices.push({
|
||||
key: bucket.label,
|
||||
label: bucket.label,
|
||||
count,
|
||||
color: bucket.color,
|
||||
})
|
||||
}
|
||||
}
|
||||
const other = counts.get('Прочее') ?? 0
|
||||
if (other > 0) {
|
||||
slices.push({
|
||||
key: 'other',
|
||||
label: 'Прочее',
|
||||
count: other,
|
||||
color: 'var(--color-chart-4)',
|
||||
})
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
export function jobStatusTotal(jobs: JobRow[]): number {
|
||||
return jobs.length
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ModuleRow, ModuleType } from '@/types/api'
|
||||
|
||||
import type { BreakdownSlice } from './types'
|
||||
|
||||
const TYPE_META: Record<ModuleType, { label: string; color: string }> = {
|
||||
AS_PREFIXES: { label: 'AS / префиксы', color: 'var(--color-chart-1)' },
|
||||
DOMAINS: { label: 'Домены', color: 'var(--color-chart-2)' },
|
||||
CDN_CIDRS: { label: 'CDN', color: 'var(--color-chart-3)' },
|
||||
IP_RANGES: { label: 'IP-диапазоны', color: 'var(--color-chart-4)' },
|
||||
}
|
||||
|
||||
export function moduleTypeBreakdown(modules: ModuleRow[]): BreakdownSlice[] {
|
||||
const counts = new Map<ModuleType, number>()
|
||||
for (const mod of modules) {
|
||||
counts.set(mod.type, (counts.get(mod.type) ?? 0) + 1)
|
||||
}
|
||||
|
||||
return (Object.keys(TYPE_META) as ModuleType[])
|
||||
.map((type) => ({
|
||||
key: type,
|
||||
label: TYPE_META[type].label,
|
||||
count: counts.get(type) ?? 0,
|
||||
color: TYPE_META[type].color,
|
||||
}))
|
||||
.filter((slice) => slice.count > 0)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
import type { CapacityBar } from './types'
|
||||
|
||||
function peerLabel(peer: PeerRow): string {
|
||||
return peer.name?.trim() || peer.neighbor || peer.id.slice(0, 8)
|
||||
}
|
||||
|
||||
function speakerLabel(speaker: SpeakerRow): string {
|
||||
return speaker.live?.label?.trim() || speaker.agent_domain || speaker.endpoint || speaker.id.slice(0, 8)
|
||||
}
|
||||
|
||||
export function peerCapacityBars(peers: PeerRow[], max = 24): CapacityBar[] {
|
||||
return peers
|
||||
.filter((p) => p.enabled !== false)
|
||||
.slice(0, max)
|
||||
.map((peer) => ({
|
||||
id: peer.id,
|
||||
name: peerLabel(peer),
|
||||
value: peer.session_state === 'Established' ? 100 : peer.session_state ? 40 : 10,
|
||||
}))
|
||||
}
|
||||
|
||||
export function speakerCapacityBars(speakers: SpeakerRow[], max = 24): CapacityBar[] {
|
||||
return speakers.slice(0, max).map((speaker) => {
|
||||
const online = speaker.live?.agent_ok === true
|
||||
const established = speaker.live?.bgp_established ?? 0
|
||||
const total = speaker.live?.bgp_sessions_total ?? 0
|
||||
const ratio = total > 0 ? Math.round((established / total) * 100) : online ? 100 : 15
|
||||
return {
|
||||
id: speaker.id,
|
||||
name: speakerLabel(speaker),
|
||||
value: online ? ratio : 10,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function capacityUtilization(bars: CapacityBar[]): number {
|
||||
if (bars.length === 0) return 0
|
||||
const sum = bars.reduce((acc, bar) => acc + bar.value, 0)
|
||||
return Math.round(sum / bars.length)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { PeerRow } from '@/types/api'
|
||||
|
||||
import type { BreakdownSlice } from './types'
|
||||
|
||||
export function peerSessionBreakdown(peers: PeerRow[]): BreakdownSlice[] {
|
||||
const enabled = peers.filter((p) => p.enabled !== false)
|
||||
const established = enabled.filter((p) => p.session_state === 'Established').length
|
||||
const pending = enabled.filter(
|
||||
(p) => p.session_state && p.session_state !== 'Established',
|
||||
).length
|
||||
const disabled = peers.length - enabled.length
|
||||
|
||||
const slices: BreakdownSlice[] = []
|
||||
if (established > 0) {
|
||||
slices.push({
|
||||
key: 'established',
|
||||
label: 'Established',
|
||||
count: established,
|
||||
color: 'var(--color-chart-2)',
|
||||
})
|
||||
}
|
||||
if (pending > 0) {
|
||||
slices.push({
|
||||
key: 'pending',
|
||||
label: 'Не Established',
|
||||
count: pending,
|
||||
color: 'var(--color-warning)',
|
||||
})
|
||||
}
|
||||
if (disabled > 0) {
|
||||
slices.push({
|
||||
key: 'disabled',
|
||||
label: 'Выключены',
|
||||
count: disabled,
|
||||
color: 'var(--color-chart-4)',
|
||||
})
|
||||
}
|
||||
return slices.length > 0
|
||||
? slices
|
||||
: [{ key: 'empty', label: 'Нет пиров', count: 1, color: 'var(--color-muted-foreground)' }]
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { ReadyStatus } from '@/queries/monitoring'
|
||||
|
||||
import type { BreakdownSlice } from './types'
|
||||
|
||||
function checkOk(value: boolean | { ok?: boolean; error?: string } | undefined): boolean {
|
||||
if (typeof value === 'boolean') return value
|
||||
if (value && typeof value === 'object') return value.ok === true
|
||||
return false
|
||||
}
|
||||
|
||||
export function readinessBreakdown(
|
||||
ready: ReadyStatus | null | undefined,
|
||||
healthOk: boolean,
|
||||
): BreakdownSlice[] {
|
||||
if (!healthOk) {
|
||||
return [
|
||||
{
|
||||
key: 'health-fail',
|
||||
label: 'API недоступен',
|
||||
count: 1,
|
||||
color: 'var(--color-destructive)',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const checks = ready?.checks ?? {}
|
||||
let okCount = 0
|
||||
let failCount = 0
|
||||
|
||||
for (const value of Object.values(checks)) {
|
||||
if (checkOk(value)) okCount += 1
|
||||
else failCount += 1
|
||||
}
|
||||
|
||||
const slices: BreakdownSlice[] = [
|
||||
{
|
||||
key: 'health',
|
||||
label: 'Health OK',
|
||||
count: 1,
|
||||
color: 'var(--color-chart-2)',
|
||||
},
|
||||
]
|
||||
|
||||
if (okCount > 0) {
|
||||
slices.push({
|
||||
key: 'checks-ok',
|
||||
label: 'Checks OK',
|
||||
count: okCount,
|
||||
color: 'var(--color-chart-1)',
|
||||
})
|
||||
}
|
||||
if (failCount > 0) {
|
||||
slices.push({
|
||||
key: 'checks-fail',
|
||||
label: 'Checks fail',
|
||||
count: failCount,
|
||||
color: 'var(--color-warning)',
|
||||
})
|
||||
}
|
||||
|
||||
if (slices.length === 1 && okCount === 0 && failCount === 0) {
|
||||
slices.push({
|
||||
key: 'ready',
|
||||
label: ready?.status === 'ok' ? 'Ready' : 'Ready pending',
|
||||
count: 1,
|
||||
color: 'var(--color-chart-4)',
|
||||
})
|
||||
}
|
||||
|
||||
return slices
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
import type { PlatformActivityItem } from './types'
|
||||
|
||||
const JOB_KIND_RU: Record<string, string> = {
|
||||
module_refresh: 'Обновление модуля',
|
||||
apply: 'Применение конфигурации',
|
||||
rollback: 'Откат ревизии',
|
||||
bird_reload: 'Перезагрузка BIRD',
|
||||
}
|
||||
|
||||
function jobMessage(job: JobRow): string {
|
||||
const kind = JOB_KIND_RU[job.kind] ?? job.kind
|
||||
return `${kind} · ${job.status}`
|
||||
}
|
||||
|
||||
export function recentPlatformActivity(
|
||||
jobs: JobRow[],
|
||||
revisions: RevisionRow[],
|
||||
peers: PeerRow[],
|
||||
speakers: SpeakerRow[],
|
||||
limit = 5,
|
||||
): PlatformActivityItem[] {
|
||||
const items: PlatformActivityItem[] = []
|
||||
|
||||
for (const job of jobs.slice(0, 3)) {
|
||||
items.push({
|
||||
id: `job-${job.job_id}`,
|
||||
message: jobMessage(job),
|
||||
status: job.status,
|
||||
kind: 'job',
|
||||
})
|
||||
}
|
||||
|
||||
for (const rev of revisions.slice(0, 2)) {
|
||||
items.push({
|
||||
id: `rev-${rev.id}`,
|
||||
message: `Ревизия ${rev.id.slice(0, 8)}… · ${rev.materialized_prefix_count} префиксов`,
|
||||
status: 'ok',
|
||||
statusLabel: 'Создана',
|
||||
kind: 'revision',
|
||||
})
|
||||
}
|
||||
|
||||
for (const peer of peers.filter((p) => p.session_mismatch).slice(0, 2)) {
|
||||
items.push({
|
||||
id: `peer-${peer.id}`,
|
||||
message: `Mismatch сессии: ${peer.name ?? peer.neighbor}`,
|
||||
status: 'mismatch',
|
||||
kind: 'network',
|
||||
})
|
||||
}
|
||||
|
||||
for (const speaker of speakers.filter((s) => s.live?.bgp_poll_error || s.live?.agent_ok === false).slice(0, 2)) {
|
||||
items.push({
|
||||
id: `speaker-${speaker.id}`,
|
||||
message: `Нода недоступна: ${speaker.live?.label ?? speaker.endpoint}`,
|
||||
status: speaker.live?.agent_ok === false ? 'error' : 'warning',
|
||||
kind: 'network',
|
||||
})
|
||||
}
|
||||
|
||||
return items.slice(0, limit)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { apiJSON } from '@/lib/api-client'
|
||||
import type {
|
||||
FirewallClient,
|
||||
FirewallClientsResponse,
|
||||
FirewallInstallContext,
|
||||
FirewallRule,
|
||||
FirewallRulesResponse,
|
||||
} from '@/types/api'
|
||||
|
||||
export const firewallKeys = {
|
||||
all: ['firewall'] as const,
|
||||
clients: () => [...firewallKeys.all, 'clients'] as const,
|
||||
installContext: () => [...firewallKeys.all, 'install-context'] as const,
|
||||
rules: (scope: string, clientId?: string) =>
|
||||
[...firewallKeys.all, 'rules', scope, clientId ?? ''] as const,
|
||||
}
|
||||
|
||||
export function firewallInstallContextQueryOptions() {
|
||||
return queryOptions<FirewallInstallContext>({
|
||||
queryKey: firewallKeys.installContext(),
|
||||
queryFn: () => apiJSON<FirewallInstallContext>('/v1/firewall/install-context'),
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function firewallClientsQueryOptions() {
|
||||
return queryOptions<FirewallClientsResponse>({
|
||||
queryKey: firewallKeys.clients(),
|
||||
queryFn: () => apiJSON<FirewallClientsResponse>('/v1/firewall/clients'),
|
||||
staleTime: 15_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function firewallRulesQueryOptions(scope: 'tenant' | 'client', clientId?: string) {
|
||||
const qs =
|
||||
scope === 'client' && clientId
|
||||
? `?scope=client&client_id=${encodeURIComponent(clientId)}`
|
||||
: '?scope=tenant'
|
||||
return queryOptions<FirewallRulesResponse>({
|
||||
queryKey: firewallKeys.rules(scope, clientId),
|
||||
queryFn: () => apiJSON<FirewallRulesResponse>(`/v1/firewall/rules${qs}`),
|
||||
staleTime: 15_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useApproveFirewallClient() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
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 : 'Не удалось удалить'),
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateFirewallRule() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
apiJSON<FirewallRule>('/v1/firewall/rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Правило добавлено')
|
||||
void qc.invalidateQueries({ queryKey: firewallKeys.all })
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось добавить правило'),
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteFirewallRule() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiJSON<void>(`/v1/firewall/rules/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: firewallKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,36 +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 { CheckCircle, Info, RefreshCw, XCircle } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@evobgp/ui/components/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,
|
||||
@@ -38,7 +33,6 @@ import {
|
||||
overviewPeersQueryOptions,
|
||||
overviewRevisionsQueryOptions,
|
||||
overviewSpeakersQueryOptions,
|
||||
runningJobCount,
|
||||
} from '@/queries/overview'
|
||||
|
||||
export const Route = createFileRoute('/_auth/dashboard')({
|
||||
@@ -78,60 +72,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={
|
||||
@@ -162,45 +109,56 @@ function DashboardComponent() {
|
||||
loadError={modulesQ.isError || peersQ.isError ? 'Некоторые данные не загружены' : null}
|
||||
/>
|
||||
|
||||
{initialLoading ? <SectionCardsSkeleton count={5} /> : <SectionCards items={items} />}
|
||||
{initialLoading ? (
|
||||
<AnalyticsDashboardSkeleton />
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-3 lg:grid-rows-2">
|
||||
<div className="lg:row-span-2">
|
||||
<DashboardPlatformCard
|
||||
modules={modules}
|
||||
peers={peers}
|
||||
speakers={speakers}
|
||||
jobs={jobs}
|
||||
revisions={revisions}
|
||||
/>
|
||||
</div>
|
||||
<DashboardNetworkCapacityCard peers={peers} speakers={speakers} jobs={jobs} />
|
||||
<DashboardOperationsFlowCard jobs={jobs} modules={modules} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<RecentJobsCard jobs={jobs} nameById={nameById} loading={refreshing} />
|
||||
<RecentRevisionsCard revisions={revisions} loading={refreshing} />
|
||||
<NetworkStatusCard peers={peers} speakers={speakers} loading={refreshing} />
|
||||
<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>
|
||||
|
||||
<DataGridCard
|
||||
title="Последние ревизии"
|
||||
description="История конфигураций"
|
||||
className="h-full"
|
||||
>
|
||||
{activityLoading ? (
|
||||
<Skeleton className="m-3 h-24 w-auto" />
|
||||
) : (
|
||||
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
|
||||
)}
|
||||
</DataGridCard>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Card className="gap-0">
|
||||
<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>
|
||||
<DashboardQuickActions />
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
@@ -255,146 +213,3 @@ function HealthAlert({
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,19 +3,11 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { BookText, Globe, Info, 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'
|
||||
@@ -87,100 +79,60 @@ function DirectoriesComponent() {
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Copy, Info, 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 { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
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 { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { directoriesCommunitiesQueryOptions } from '@/queries/directories'
|
||||
import {
|
||||
firewallClientsQueryOptions,
|
||||
firewallInstallContextQueryOptions,
|
||||
firewallRulesQueryOptions,
|
||||
useApproveFirewallClient,
|
||||
useDeleteFirewallClient,
|
||||
useDeleteFirewallRule,
|
||||
} from '@/queries/firewall'
|
||||
|
||||
function httpsOrigin(origin: string): string {
|
||||
try {
|
||||
const u = new URL(origin)
|
||||
u.protocol = 'https:'
|
||||
return u.origin
|
||||
} catch {
|
||||
return origin.replace(/^http:/i, 'https:')
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/firewall')({
|
||||
component: FirewallPage,
|
||||
})
|
||||
|
||||
function FirewallPage() {
|
||||
const installCtxQ = useQuery(firewallInstallContextQueryOptions())
|
||||
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
|
||||
const clientsQ = useQuery(firewallClientsQueryOptions())
|
||||
const rulesQ = useQuery(firewallRulesQueryOptions('tenant'))
|
||||
const approve = useApproveFirewallClient()
|
||||
const deleteClient = useDeleteFirewallClient()
|
||||
const deleteRule = useDeleteFirewallRule()
|
||||
|
||||
const installCtx = installCtxQ.data
|
||||
|
||||
const [clientName, setClientName] = useState('web-01')
|
||||
const [cpUrl, setCpUrl] = useState(() =>
|
||||
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) {
|
||||
setCpUrl(httpsOrigin(installCtx.suggested_cp_url))
|
||||
}
|
||||
if (installCtx?.bundle_seed) {
|
||||
setSeed(installCtx.bundle_seed)
|
||||
}
|
||||
}, [installCtx?.bundle_seed, installCtx?.suggested_cp_url])
|
||||
|
||||
const communities = communitiesQ.data?.items ?? []
|
||||
|
||||
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(() => {
|
||||
const s = seed.trim() || '<bundle_seed_hex>'
|
||||
return `curl -fsSL ${cpUrl.replace(/\/$/, '')}/v1/firewall/install.sh | \\
|
||||
EVOBGP_CP_URL=${cpUrl.replace(/\/$/, '')} \\
|
||||
EVOBGP_SEED=${s} \\
|
||||
EVOBGP_CLIENT_NAME="${clientName}" \\
|
||||
bash`
|
||||
}, [clientName, cpUrl, seed])
|
||||
|
||||
async function copyInstall() {
|
||||
if (!seed.trim()) {
|
||||
toast.error(
|
||||
installCtx?.bundle_seed_configured === false
|
||||
? 'На CP не задан EVOBGP_BUNDLE_SEED_HEX'
|
||||
: 'Bundle seed недоступен (нужна роль operator)',
|
||||
)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(installCmd)
|
||||
toast.success('Команда скопирована')
|
||||
} catch {
|
||||
toast.error('Не удалось скопировать')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Firewall blocklist"
|
||||
description="Linux-серверы: синхронизация CIDR по policy block/accept"
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void clientsQ.refetch()
|
||||
void rulesQ.refetch()
|
||||
}}
|
||||
disabled={clientsQ.isFetching}
|
||||
>
|
||||
<RefreshCw className={clientsQ.isFetching ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<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" />
|
||||
Установка на сервер
|
||||
</CardTitle>
|
||||
<CardDescription>One-liner для root на целевом Linux (bash, curl). После enroll — approve в «Запросы».</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fw-name">Имя сервера</Label>
|
||||
<Input id="fw-name" value={clientName} onChange={(e) => setClientName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fw-url">URL API</Label>
|
||||
<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>
|
||||
<Input
|
||||
id="fw-seed"
|
||||
type="password"
|
||||
readOnly
|
||||
placeholder="EVOBGP_BUNDLE_SEED_HEX"
|
||||
value={seed}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<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 невозможен'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 text-xs">{installCmd}</pre>
|
||||
<Button variant="outline" size="sm" className="w-fit" onClick={copyInstall}>
|
||||
<Copy />
|
||||
Копировать команду
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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-серверы с синхронизацией blocklist">
|
||||
<QueryState
|
||||
data={clientsQ.data}
|
||||
isLoading={clientsQ.isLoading}
|
||||
isError={clientsQ.isError}
|
||||
error={clientsQ.error}
|
||||
onRetry={() => void clientsQ.refetch()}
|
||||
skeleton={<TableSkeleton rows={5} cols={6} />}
|
||||
>
|
||||
{() => (
|
||||
<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}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="requests" className="mt-0">
|
||||
<DataGridCard
|
||||
title="Запросы"
|
||||
description="Pending enroll — одобрите или отклоните новые клиенты"
|
||||
>
|
||||
<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="Нет pending-запросов"
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
</TabsContent>
|
||||
</BadgeTabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,26 +1,22 @@
|
||||
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, Database, HeartPulse, Info, ListTodo, 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 { 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 {
|
||||
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,
|
||||
@@ -31,6 +27,7 @@ import {
|
||||
} from '@/queries/monitoring'
|
||||
import { networkBirdQueryOptions } from '@/queries/network'
|
||||
import { operationsJobsQueryOptions } from '@/queries/operations'
|
||||
import { overviewModulesQueryOptions } from '@/queries/overview'
|
||||
|
||||
export const Route = createFileRoute('/_auth/monitoring')({
|
||||
component: MonitoringComponent,
|
||||
@@ -44,58 +41,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 +73,7 @@ function MonitoringComponent() {
|
||||
void versionQ.refetch()
|
||||
void birdQ.refetch()
|
||||
void jobsQ.refetch()
|
||||
void modulesQ.refetch()
|
||||
}
|
||||
|
||||
const failedJobs = jobs
|
||||
@@ -122,35 +93,58 @@ function MonitoringComponent() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue={search.tab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="system">Система</TabsTrigger>
|
||||
<TabsTrigger value="postgres">PostgreSQL</TabsTrigger>
|
||||
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger>
|
||||
</TabsList>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<TabsContent value="system" className="mt-4 flex flex-col gap-6">
|
||||
{refreshing ? <SectionCardsSkeleton count={4} /> : <SectionCards items={items} />}
|
||||
<Alert className="border-muted bg-muted/30">
|
||||
<Info className="size-4" />
|
||||
<AlertTitle className="text-sm">
|
||||
Версия API: {versionText}
|
||||
{versionQ.data?.git_sha ? ` · ${versionQ.data.git_sha.slice(0, 8)}` : ''}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-xs">
|
||||
{overallHint({ health: healthQ.data, jobsFailed: failed })}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<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>
|
||||
@@ -186,7 +180,7 @@ function MonitoringComponent() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<Metric label="Активных" value={running} />
|
||||
<Metric label="Активных" value={jobs.filter((j) => j.status === 'running' || j.status === 'queued').length} />
|
||||
<Metric
|
||||
label="С ошибками"
|
||||
value={failed}
|
||||
@@ -203,7 +197,7 @@ function MonitoringComponent() {
|
||||
<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>
|
||||
<StatusBadge status={job.status} />
|
||||
</div>
|
||||
{job.error ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
@@ -267,7 +261,7 @@ function MonitoringComponent() {
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="postgres" className="mt-4">
|
||||
<TabsContent value="postgres" className="mt-0">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">PostgreSQL</CardTitle>
|
||||
@@ -286,7 +280,7 @@ function MonitoringComponent() {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="runtime-logs" className="mt-4">
|
||||
<TabsContent value="runtime-logs" className="mt-0">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Файловые логи</CardTitle>
|
||||
@@ -304,7 +298,7 @@ function MonitoringComponent() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</BadgeTabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -329,97 +323,12 @@ interface OverallInput {
|
||||
jobsFailed: number
|
||||
}
|
||||
|
||||
function overallStatusLabel(input: OverallInput): string {
|
||||
if (!input.health?.ok) return 'Ошибка'
|
||||
if (input.jobsFailed > 0) return 'Внимание'
|
||||
if (input.ready?.status && input.ready.status !== 'ok') return 'Внимание'
|
||||
return 'В норме'
|
||||
}
|
||||
|
||||
function overallHint(input: OverallInput): string {
|
||||
if (!input.health?.ok) return 'API недоступен или возвращает ошибку'
|
||||
if (input.jobsFailed > 0) return `Есть провальные задачи (${input.jobsFailed})`
|
||||
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 (
|
||||
|
||||
@@ -4,23 +4,19 @@ 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 {
|
||||
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 +29,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()
|
||||
@@ -69,96 +68,78 @@ function NetworkComponent() {
|
||||
</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: 'Control plane' },
|
||||
]}
|
||||
>
|
||||
<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>
|
||||
<Card className="mt-4">
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Пиры</CardTitle>
|
||||
<CardTitle className="text-base">BIRD (control plane)</CardTitle>
|
||||
<CardDescription>Статус birdc на хосте API</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<CardContent className="p-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>
|
||||
</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">
|
||||
<TabsContent value="speakers" className="mt-0">
|
||||
<NetworkSpeakersCard
|
||||
items={speakers}
|
||||
isLoading={speakersQ.isLoading}
|
||||
isError={speakersQ.isError}
|
||||
error={speakersQ.error}
|
||||
onRetry={() => speakersQ.refetch()}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="control-plane" className="mt-0">
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Настройки Control Plane (BIRD)</CardTitle>
|
||||
@@ -169,7 +150,7 @@ function NetworkComponent() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</BadgeTabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -196,78 +177,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,25 @@
|
||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { AlertTriangle, Clock, Activity, Info, RefreshCw } from 'lucide-react'
|
||||
import { Info, RefreshCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useState, useMemo } from 'react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evobgp/ui/components/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
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 { 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 +33,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 +45,6 @@ function OperationsComponent() {
|
||||
const nameById = moduleNameById(modulesQ.data?.items ?? [])
|
||||
|
||||
const refreshing = revisionsQ.isFetching || jobsQ.isFetching
|
||||
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
|
||||
const failed = jobs.filter(
|
||||
(j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
|
||||
const items: SectionCardItem[] = [
|
||||
{
|
||||
label: 'Ревизий',
|
||||
value: revisions.length,
|
||||
icon: <Activity className="size-4" />,
|
||||
hint: 'история конфигов',
|
||||
},
|
||||
{
|
||||
label: 'Активных задач',
|
||||
value: running,
|
||||
icon: <Clock className="size-4" />,
|
||||
hint: 'queued и running',
|
||||
},
|
||||
{
|
||||
label: 'Задач с ошибкой',
|
||||
value: failed,
|
||||
icon: <AlertTriangle className="size-4" />,
|
||||
hint: failed > 0 ? 'требуют внимания' : 'критичных сбоев нет',
|
||||
variant: failed > 0 ? 'warning' : 'default',
|
||||
},
|
||||
]
|
||||
|
||||
function refetchAll() {
|
||||
void revisionsQ.refetch()
|
||||
@@ -167,203 +128,76 @@ function OperationsComponent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{revisionsQ.isLoading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
|
||||
{revisionsQ.isLoading ? (
|
||||
<OperationsAnalyticsCard jobs={[]} revisions={[]} loading />
|
||||
) : (
|
||||
<OperationsAnalyticsCard jobs={jobs} revisions={revisions} />
|
||||
)}
|
||||
|
||||
<Tabs defaultValue={search.tab}>
|
||||
<TabsList>
|
||||
<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('')
|
||||
@@ -386,33 +220,21 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex w-full max-w-xs flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Ревизия A</span>
|
||||
<Select items={revisionItems} value={a} onValueChange={(v) => v && setA(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{revisions.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.id.slice(0, 12)}…
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SelectMenu
|
||||
items={revisionItems}
|
||||
value={a}
|
||||
placeholder="Выберите"
|
||||
onValueChange={(v) => v && setA(v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full max-w-xs flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Ревизия B</span>
|
||||
<Select items={revisionItems} value={b} onValueChange={(v) => v && setB(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{revisions.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.id.slice(0, 12)}…
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SelectMenu
|
||||
items={revisionItems}
|
||||
value={b}
|
||||
placeholder="Выберите"
|
||||
onValueChange={(v) => v && setB(v)}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => diffQ.refetch()} disabled={!a || !b || diffQ.isFetching}>
|
||||
Сравнить
|
||||
|
||||
@@ -5,24 +5,15 @@ 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'
|
||||
@@ -108,82 +99,33 @@ function ScheduleComponent() {
|
||||
|
||||
{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="Расписание обновления и ручной запуск ingest"
|
||||
>
|
||||
<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 +137,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,16 +6,10 @@ import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evobgp/ui/components/select'
|
||||
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||
import { authKeys, authSessionQueryOptions } from '@/queries/auth'
|
||||
import { toast } from 'sonner'
|
||||
@@ -141,21 +135,15 @@ function SettingsComponent() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<Label htmlFor="theme-select">Тема</Label>
|
||||
<Select
|
||||
<SelectField
|
||||
id="theme-select"
|
||||
label="Тема"
|
||||
items={[...THEME_SELECT_ITEMS]}
|
||||
value={theme ?? 'system'}
|
||||
placeholder="Выберите тему"
|
||||
triggerClassName="max-w-xs"
|
||||
onValueChange={(v) => v && setTheme(v)}
|
||||
>
|
||||
<SelectTrigger id="theme-select" className="w-full max-w-xs">
|
||||
<SelectValue placeholder="Выберите тему" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Светлая</SelectItem>
|
||||
<SelectItem value="dark">Тёмная</SelectItem>
|
||||
<SelectItem value="system">Как в системе</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -8,23 +8,10 @@ import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evobgp/ui/components/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
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'
|
||||
@@ -71,6 +58,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()
|
||||
|
||||
@@ -125,15 +113,21 @@ function TenantSettingsComponent() {
|
||||
</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">
|
||||
<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">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>BIRD control plane</CardTitle>
|
||||
@@ -186,7 +180,7 @@ function TenantSettingsComponent() {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="revision" className="mt-4">
|
||||
<TabsContent value="revision" className="mt-0">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ревизии</CardTitle>
|
||||
@@ -233,7 +227,7 @@ function TenantSettingsComponent() {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="runtime-logs" className="mt-4">
|
||||
<TabsContent value="runtime-logs" className="mt-0">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Файловые логи</CardTitle>
|
||||
@@ -250,31 +244,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 +291,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 />
|
||||
@@ -346,48 +318,31 @@ function TenantSettingsComponent() {
|
||||
</Card>
|
||||
</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="Параметры вне стандартных групп (readonly — изменяются только через 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -343,3 +343,42 @@ export type ApiKeyCreated = ApiKey & { token: string }
|
||||
export type AsyncJobAccepted = {
|
||||
job_id: string
|
||||
}
|
||||
|
||||
// ---- Firewall blocklist ----
|
||||
export type FirewallClient = {
|
||||
id: string
|
||||
name: string
|
||||
hostname?: string
|
||||
token_prefix: string
|
||||
status: 'pending' | 'approved' | 'revoked'
|
||||
last_seen_at?: string | null
|
||||
last_seen_at_source?: string
|
||||
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
|
||||
}
|
||||
|
||||
export type FirewallClientsResponse = { items: FirewallClient[] }
|
||||
|
||||
export type FirewallRule = {
|
||||
id: string
|
||||
client_id?: string | null
|
||||
priority: number
|
||||
action: 'block' | 'accept'
|
||||
community_id?: string | null
|
||||
comment?: string
|
||||
}
|
||||
|
||||
export type FirewallRulesResponse = { items: FirewallRule[] }
|
||||
|
||||
export type FirewallInstallContext = {
|
||||
bundle_seed: string
|
||||
bundle_seed_configured: boolean
|
||||
suggested_cp_url: string
|
||||
install_sh_url: string
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -159,10 +159,18 @@ services:
|
||||
condition: service_started
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# Публичные firewall-эндпоинты — без WEBUI_IP_WHITELIST (установка с произвольных серверов).
|
||||
- traefik.http.routers.evobgp-firewall-public.rule=Host(`${WEBUI_DOMAIN}`) && (Path(`/v1/firewall/install.sh`) || Path(`/v1/firewall/sync-script`) || PathPrefix(`/v1/firewall/enroll`))
|
||||
- traefik.http.routers.evobgp-firewall-public.entrypoints=websecure
|
||||
- traefik.http.routers.evobgp-firewall-public.tls=true
|
||||
- traefik.http.routers.evobgp-firewall-public.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.evobgp-firewall-public.priority=100
|
||||
- traefik.http.routers.evobgp-firewall-public.service=evobgp-web
|
||||
- traefik.http.routers.evobgp-web.rule=Host(`${WEBUI_DOMAIN}`)
|
||||
- traefik.http.routers.evobgp-web.entrypoints=websecure
|
||||
- traefik.http.routers.evobgp-web.tls=true
|
||||
- traefik.http.routers.evobgp-web.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.evobgp-web.priority=10
|
||||
- traefik.http.routers.evobgp-web.middlewares=webui-ipwhitelist@docker
|
||||
- traefik.http.middlewares.webui-ipwhitelist.ipallowlist.sourcerange=${WEBUI_IP_WHITELIST}
|
||||
- traefik.http.services.evobgp-web.loadbalancer.server.port=80
|
||||
|
||||
@@ -247,10 +247,18 @@ services:
|
||||
- evobgp-all
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# Публичные firewall-эндпоинты — без WEBUI_IP_WHITELIST (установка с произвольных серверов).
|
||||
- traefik.http.routers.evobgp-firewall-public.rule=Host(`${WEBUI_DOMAIN}`) && (Path(`/v1/firewall/install.sh`) || Path(`/v1/firewall/sync-script`) || PathPrefix(`/v1/firewall/enroll`))
|
||||
- traefik.http.routers.evobgp-firewall-public.entrypoints=websecure
|
||||
- traefik.http.routers.evobgp-firewall-public.tls=true
|
||||
- traefik.http.routers.evobgp-firewall-public.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.evobgp-firewall-public.priority=100
|
||||
- traefik.http.routers.evobgp-firewall-public.service=evobgp-web
|
||||
- traefik.http.routers.evobgp-web.rule=Host(`${WEBUI_DOMAIN}`)
|
||||
- traefik.http.routers.evobgp-web.entrypoints=websecure
|
||||
- traefik.http.routers.evobgp-web.tls=true
|
||||
- traefik.http.routers.evobgp-web.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.evobgp-web.priority=10
|
||||
- traefik.http.routers.evobgp-web.middlewares=webui-ipwhitelist@docker
|
||||
- traefik.http.middlewares.webui-ipwhitelist.ipallowlist.sourcerange=${WEBUI_IP_WHITELIST}
|
||||
- traefik.http.services.evobgp-web.loadbalancer.server.port=80
|
||||
|
||||
@@ -162,10 +162,18 @@ services:
|
||||
condition: service_started
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# Публичные firewall-эндпоинты — без WEBUI_IP_WHITELIST (установка с произвольных серверов).
|
||||
- traefik.http.routers.evobgp-firewall-public.rule=Host(`${WEBUI_DOMAIN}`) && (Path(`/v1/firewall/install.sh`) || Path(`/v1/firewall/sync-script`) || PathPrefix(`/v1/firewall/enroll`))
|
||||
- traefik.http.routers.evobgp-firewall-public.entrypoints=websecure
|
||||
- traefik.http.routers.evobgp-firewall-public.tls=true
|
||||
- traefik.http.routers.evobgp-firewall-public.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.evobgp-firewall-public.priority=100
|
||||
- traefik.http.routers.evobgp-firewall-public.service=evobgp-web
|
||||
- traefik.http.routers.evobgp-web.rule=Host(`${WEBUI_DOMAIN}`)
|
||||
- traefik.http.routers.evobgp-web.entrypoints=websecure
|
||||
- traefik.http.routers.evobgp-web.tls=true
|
||||
- traefik.http.routers.evobgp-web.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.evobgp-web.priority=10
|
||||
- traefik.http.routers.evobgp-web.middlewares=webui-ipwhitelist@docker
|
||||
- traefik.http.middlewares.webui-ipwhitelist.ipallowlist.sourcerange=${WEBUI_IP_WHITELIST}
|
||||
- traefik.http.services.evobgp-web.loadbalancer.server.port=80
|
||||
|
||||
@@ -17,7 +17,7 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location = /metrics {
|
||||
|
||||
@@ -58,6 +58,8 @@ RUN apt-get update \
|
||||
FROM runtime-base AS runtime
|
||||
ARG BIN=evobgp-api
|
||||
COPY --from=build-all /out/${BIN} /usr/local/bin/evobgp
|
||||
COPY scripts/firewall /opt/evobgp/scripts/firewall
|
||||
ENV EVOBGP_FIREWALL_SCRIPTS=/opt/evobgp/scripts/firewall
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/usr/local/bin/evobgp"]
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod
|
||||
| `editor` | 2 | Чтение + создание/изменение CRUD (модули, записи, peers и т.д.), без опасных операций уровня оператора. |
|
||||
| `operator` | 3 | Полный операторский доступ: apply, rollback, настройки, отмена задач и т.п. (как задано в handlers). |
|
||||
| `node` | отдельная | Только API для реплики: latest revision, скачивание бандла, enroll. Роль **`node` запрещена** для обычного CRUD — ответ `403 Forbidden`. |
|
||||
| `firewall` | отдельная | Только data-plane firewall-клиента: `GET /v1/firewall/blocklist`, `POST /v1/firewall/apply-report`, `POST /v1/firewall/heartbeat`. Токен в таблице `firewall_client`, не в `api_key`. См. [firewall.md](firewall.md). |
|
||||
|
||||
Обратное ограничение: для эндпоинтов ноды требуется именно роль **`node`**; остальные роли получают отказ.
|
||||
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
| `broker` | Опциональный `EVOBGP_BROKER_URL` для будущей шины; сейчас задачи только in-process (`jobs.Registry`), пакет лишь логирует факт настройки URL. |
|
||||
| `pipeline` | Ingest+render в одном шаге для `module_refresh`: выборка префиксов (CDN/AS/IP/пустые DOMAINS), `CreateRenderRevision`, превью BIRD через `birdfmt`. |
|
||||
| `nodedispatch` | Panel→Node HTTP wake-up (`POST /v1/agent/sync`) после `deploy_apply`. |
|
||||
| `agentserver` | HTTP API на реплике (`serve`): sync + health для Traefik. |
|
||||
| `agentserver` | HTTP API на реплике (`serve`): sync + health для Traefik; опционально firewall failover (`/v1/firewall/*`). |
|
||||
| `firewall` | Вычисление policy block/accept → плоский CIDR blocklist. |
|
||||
|
||||
## Удалённые спикеры
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Firewall blocklist
|
||||
|
||||
Подсистема синхронизации blocklist на произвольные Linux-серверы через bash-скрипт и HTTP API.
|
||||
|
||||
## Авторизация
|
||||
|
||||
1. **Enroll** — `POST /v1/firewall/enroll` с заголовком `X-EvoBGP-Seed` (значение `EVOBGP_BUNDLE_SEED_HEX` на CP). Клиент генерирует токен `evobgp_fw_*` локально.
|
||||
2. **Approve** — operator в Web UI (`/firewall` → Запросы).
|
||||
3. **Sync** — `GET /v1/firewall/blocklist` с `Authorization: Bearer <client_token>`.
|
||||
|
||||
## Политика block/accept
|
||||
|
||||
- **`block`** — добавить префиксы выбранного BGP community в kernel blocklist.
|
||||
- **`accept`** — не блокировать префиксы этого community.
|
||||
- **Community** — правило применяется к префиксам с этим `community_id` в опубликованной revision; пустое значение («Все») — ко всем communities.
|
||||
- **Default** — accept (пустой blocklist без явных `block`).
|
||||
|
||||
Порядок: сначала per-server overrides клиента, затем tenant-default. Для каждого community берётся первое подходящее правило по приоритету.
|
||||
|
||||
Справочник communities: Web UI → Справочники, или модули с привязкой community к префиксам.
|
||||
|
||||
## Установка на сервер
|
||||
|
||||
Публичные URL (без API-ключа, вне `WEBUI_IP_WHITELIST` Traefik): `GET /v1/firewall/install.sh`, `GET /v1/firewall/sync-script`, `POST /v1/firewall/enroll`. Всегда **HTTPS**.
|
||||
|
||||
Требуется миграция **`000027_firewall`** в PostgreSQL (применяется при старте API с актуальным бинарём). Если enroll отвечает `503` / `database schema outdated` — перезапустите `evobgp-api` / `evobgp-all` после деплоя новой версии.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://<api>/v1/firewall/install.sh | \
|
||||
EVOBGP_CP_URL=https://<api> \
|
||||
EVOBGP_SEED=<bundle_seed_hex> \
|
||||
EVOBGP_CLIENT_NAME="web-01" \
|
||||
bash
|
||||
```
|
||||
|
||||
Файлы: `/etc/evobgp/firewall.conf`, `/usr/local/sbin/evobgp-firewall.sh`, systemd timer `evobgp-firewall.timer`.
|
||||
|
||||
После **approve** в UI выполните на сервере (или дождитесь timer):
|
||||
|
||||
```bash
|
||||
sudo rm -f /var/lib/evobgp-firewall/last_hash
|
||||
sudo /usr/local/sbin/evobgp-firewall.sh
|
||||
sudo nft list table inet evobgp_blocklist
|
||||
```
|
||||
|
||||
Для парсинга JSON нужен `jq` или `python3` (install.sh ставит `jq` на Debian/Ubuntu при отсутствии).
|
||||
|
||||
## Failover через speaker
|
||||
|
||||
При `EVOBGP_FIREWALL_FAILOVER_ENABLED=1` на speaker-agent CP реплицирует состояние через `POST /v1/agent/firewall-replicate`. Клиенты используют тот же DNS-домен.
|
||||
|
||||
См. также [access.md](access.md), [remote-speakers.md](remote-speakers.md).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user