Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac727ad1e3 | ||
|
|
c265c06f93 | ||
|
|
d434eb0d94 | ||
|
|
d0bd4d661d | ||
|
|
642db1a83a | ||
|
|
f66d68d1c7 | ||
|
|
e04fea657c | ||
|
|
452f6b2db0 | ||
|
|
b321aa5321 | ||
|
|
5d1102b497 | ||
|
|
5edbd656ba | ||
|
|
4a4c11c6bf | ||
|
|
e51999c908 | ||
|
|
b7f7669685 | ||
|
|
947d1f0cc4 | ||
|
|
68f9d4b832 | ||
|
|
72045afcde | ||
|
|
e15768b25b | ||
|
|
7b3f002e5f | ||
|
|
fa2abc81f3 | ||
|
|
7a3eae98b1 | ||
|
|
276194a9d0 | ||
|
|
53b3c49612 | ||
|
|
db79820df0 | ||
|
|
a902a4270d | ||
|
|
3a3e6db018 | ||
|
|
144d342c16 | ||
|
|
0af37d55c4 | ||
|
|
78f2ecc246 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"pid": 52448,
|
"pid": 43636,
|
||||||
"version": "0.9.9",
|
"version": "0.9.9",
|
||||||
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
||||||
"startedAt": 1782969267595
|
"startedAt": 1783570299043
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Plus, RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
|
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 { QueryState } from '@/components/query-state'
|
||||||
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
|
import { useRevokeApiKeyMutation, useRotateApiKeyMutation } from '@/queries/api-keys'
|
||||||
|
import type { ApiKey, ApiKeyCreated } from '@/types/api'
|
||||||
|
|
||||||
|
interface AccessApiKeysCardProps {
|
||||||
|
items: ApiKey[]
|
||||||
|
isLoading: boolean
|
||||||
|
isError: boolean
|
||||||
|
error: unknown
|
||||||
|
onRetry: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccessApiKeysCard({
|
||||||
|
items,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
}: AccessApiKeysCardProps) {
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [tokenDialogOpen, setTokenDialogOpen] = useState(false)
|
||||||
|
const [revealedToken, setRevealedToken] = useState('')
|
||||||
|
|
||||||
|
const revoke = useRevokeApiKeyMutation()
|
||||||
|
const rotate = useRotateApiKeyMutation()
|
||||||
|
|
||||||
|
function showToken(created: ApiKeyCreated) {
|
||||||
|
setRevealedToken(created.token)
|
||||||
|
setTokenDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRotated(id: string) {
|
||||||
|
rotate.mutate(id, {
|
||||||
|
onSuccess: (created) => showToken(created),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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" type="button" onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus />
|
||||||
|
Создать
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
onCreated={showToken}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ApiKeyTokenDialog
|
||||||
|
open={tokenDialogOpen}
|
||||||
|
token={revealedToken}
|
||||||
|
onOpenChange={setTokenDialogOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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-ключей…"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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 { API_KEY_ROLE_ITEMS } from '@/lib/access/api-key-labels'
|
||||||
|
import { useCreateApiKeyMutation } from '@/queries/api-keys'
|
||||||
|
import type { ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '@/types/api'
|
||||||
|
|
||||||
|
interface ApiKeyCreateDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onCreated: (created: ApiKeyCreated) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiKeyCreateDialog({ open, onOpenChange, onCreated }: ApiKeyCreateDialogProps) {
|
||||||
|
const createMutation = useCreateApiKeyMutation()
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [role, setRole] = useState<ApiKeyRole>('editor')
|
||||||
|
const [expiresLocal, setExpiresLocal] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setName('')
|
||||||
|
setRole('editor')
|
||||||
|
setExpiresLocal('')
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
onOpenChange(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!name.trim()) {
|
||||||
|
toast.error('Укажите имя')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const body: ApiKeyCreate = {
|
||||||
|
name: name.trim(),
|
||||||
|
role,
|
||||||
|
}
|
||||||
|
if (expiresLocal.trim()) {
|
||||||
|
const d = new Date(expiresLocal)
|
||||||
|
if (Number.isNaN(d.getTime())) {
|
||||||
|
toast.error('Некорректная дата истечения')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body.expires_at = d.toISOString()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const created = await createMutation.mutateAsync(body)
|
||||||
|
onOpenChange(false)
|
||||||
|
onCreated(created)
|
||||||
|
} catch {
|
||||||
|
// toast handled in mutation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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,51 @@
|
|||||||
|
import { Copy } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@evobgp/ui/components/dialog'
|
||||||
|
|
||||||
|
interface ApiKeyTokenDialogProps {
|
||||||
|
open: boolean
|
||||||
|
token: string
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiKeyTokenDialog({ open, token, onOpenChange }: ApiKeyTokenDialogProps) {
|
||||||
|
async function copyToken() {
|
||||||
|
if (!token) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(token)
|
||||||
|
toast.success('Скопировано')
|
||||||
|
} catch {
|
||||||
|
toast.error('Не удалось скопировать')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Сохраните токен</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Он больше не будет показан. Скопируйте в безопасное хранилище.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="break-all rounded-md border bg-muted/40 p-3 font-mono text-xs">{token}</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={copyToken}>
|
||||||
|
<Copy />
|
||||||
|
Копировать
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => onOpenChange(false)}>Готово</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 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-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,130 @@
|
|||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
Drawer,
|
||||||
AlertDialogAction,
|
DrawerClose,
|
||||||
AlertDialogCancel,
|
DrawerContent,
|
||||||
AlertDialogContent,
|
DrawerDescription,
|
||||||
AlertDialogDescription,
|
DrawerHeader,
|
||||||
AlertDialogFooter,
|
DrawerTitle,
|
||||||
AlertDialogHeader,
|
DrawerTrigger,
|
||||||
AlertDialogTitle,
|
} from '@evobgp/ui/components/drawer'
|
||||||
AlertDialogTrigger,
|
|
||||||
} from '@evobgp/ui/components/alert-dialog'
|
|
||||||
import type { ReactElement, ReactNode } from 'react'
|
import type { ReactElement, ReactNode } from 'react'
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
import {
|
||||||
trigger: ReactElement
|
confirmDrawerContentClassName,
|
||||||
|
DrawerActionsFooter,
|
||||||
|
} from '@/components/drawer-layout'
|
||||||
|
|
||||||
|
type ConfirmDialogBaseProps = {
|
||||||
title: string
|
title: string
|
||||||
description?: ReactNode
|
description?: ReactNode
|
||||||
confirmLabel?: string
|
confirmLabel?: string
|
||||||
cancelLabel?: string
|
cancelLabel?: string
|
||||||
destructive?: boolean
|
destructive?: boolean
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
|
confirmDisabled?: boolean
|
||||||
|
confirmLoading?: boolean
|
||||||
|
confirmLoadingLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfirmDialog({
|
type ConfirmDialogWithTrigger = ConfirmDialogBaseProps & {
|
||||||
trigger,
|
trigger: ReactElement
|
||||||
|
open?: never
|
||||||
|
onOpenChange?: never
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfirmDialogControlled = ConfirmDialogBaseProps & {
|
||||||
|
trigger?: never
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfirmDialogProps = ConfirmDialogWithTrigger | ConfirmDialogControlled
|
||||||
|
|
||||||
|
function ConfirmDrawerBody({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
confirmLabel = 'Подтвердить',
|
confirmLabel = 'Подтвердить',
|
||||||
cancelLabel = 'Отмена',
|
cancelLabel = 'Отмена',
|
||||||
destructive,
|
destructive,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: ConfirmDialogProps) {
|
confirmDisabled,
|
||||||
|
confirmLoading,
|
||||||
|
confirmLoadingLabel,
|
||||||
|
controlled,
|
||||||
|
}: ConfirmDialogBaseProps & { controlled?: boolean }) {
|
||||||
|
const confirmText =
|
||||||
|
confirmLoading && confirmLoadingLabel
|
||||||
|
? confirmLoadingLabel
|
||||||
|
: confirmLoading
|
||||||
|
? `${confirmLabel}…`
|
||||||
|
: confirmLabel
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<>
|
||||||
<AlertDialogTrigger render={trigger} />
|
<DrawerHeader className="shrink-0 border-b border-border pb-4">
|
||||||
<AlertDialogContent>
|
<DrawerTitle>{title}</DrawerTitle>
|
||||||
<AlertDialogHeader>
|
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
|
||||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
</DrawerHeader>
|
||||||
{description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null}
|
<DrawerActionsFooter>
|
||||||
</AlertDialogHeader>
|
<DrawerClose render={<Button variant="outline" disabled={confirmLoading} />}>
|
||||||
<AlertDialogFooter>
|
{cancelLabel}
|
||||||
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
|
</DrawerClose>
|
||||||
<AlertDialogAction
|
{controlled ? (
|
||||||
|
<Button
|
||||||
variant={destructive ? 'destructive' : 'default'}
|
variant={destructive ? 'destructive' : 'default'}
|
||||||
|
disabled={confirmDisabled || confirmLoading}
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
>
|
>
|
||||||
{confirmLabel}
|
{confirmText}
|
||||||
</AlertDialogAction>
|
</Button>
|
||||||
</AlertDialogFooter>
|
) : (
|
||||||
</AlertDialogContent>
|
<DrawerClose
|
||||||
</AlertDialog>
|
render={
|
||||||
|
<Button
|
||||||
|
variant={destructive ? 'destructive' : 'default'}
|
||||||
|
disabled={confirmDisabled || confirmLoading}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</DrawerClose>
|
||||||
|
)}
|
||||||
|
</DrawerActionsFooter>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDialog(props: ConfirmDialogProps) {
|
||||||
|
const bodyProps: ConfirmDialogBaseProps = {
|
||||||
|
title: props.title,
|
||||||
|
description: props.description,
|
||||||
|
confirmLabel: props.confirmLabel,
|
||||||
|
cancelLabel: props.cancelLabel,
|
||||||
|
destructive: props.destructive,
|
||||||
|
onConfirm: props.onConfirm,
|
||||||
|
confirmDisabled: props.confirmDisabled,
|
||||||
|
confirmLoading: props.confirmLoading,
|
||||||
|
confirmLoadingLabel: props.confirmLoadingLabel,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.trigger) {
|
||||||
|
return (
|
||||||
|
<Drawer swipeDirection="right">
|
||||||
|
<DrawerTrigger render={props.trigger} />
|
||||||
|
<DrawerContent className={confirmDrawerContentClassName}>
|
||||||
|
<ConfirmDrawerBody {...bodyProps} />
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer open={props.open} onOpenChange={props.onOpenChange} swipeDirection="right">
|
||||||
|
<DrawerContent className={confirmDrawerContentClassName}>
|
||||||
|
<ConfirmDrawerBody {...bodyProps} controlled />
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { aggregateNetworkMetrics } from '@/queries/overview'
|
||||||
|
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
|
function Row({ label, value, variant = 'default' }: { label: string; value: string; variant?: 'default' | 'warning' }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2 px-1 text-sm">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
variant === 'warning'
|
||||||
|
? 'font-medium text-warning-foreground tabular-nums'
|
||||||
|
: 'font-medium tabular-nums'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardNetworkPanel({
|
||||||
|
peers,
|
||||||
|
speakers,
|
||||||
|
}: {
|
||||||
|
peers: PeerRow[]
|
||||||
|
speakers: SpeakerRow[]
|
||||||
|
}) {
|
||||||
|
const m = aggregateNetworkMetrics(peers, speakers)
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-3">
|
||||||
|
<Row label="Пиры 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,31 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import { DrawerFooter } from '@evobgp/ui/components/drawer'
|
||||||
|
|
||||||
|
/** Shared footer layout for right-side form and confirm drawers. */
|
||||||
|
export function DrawerActionsFooter({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DrawerFooter
|
||||||
|
className={cn(
|
||||||
|
'mt-0 shrink-0 border-t border-border bg-muted/50 p-4',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</DrawerFooter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formDrawerContentClassName =
|
||||||
|
'flex h-full max-h-dvh flex-col sm:max-w-lg'
|
||||||
|
|
||||||
|
export const confirmDrawerContentClassName = 'flex h-auto max-h-dvh flex-col sm:max-w-sm'
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import { Checkbox } from "@evobgp/ui/components/checkbox"
|
||||||
|
import { Field } from "@evobgp/ui/components/field"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupInput,
|
||||||
|
} from "@evobgp/ui/components/input-group"
|
||||||
|
import { Label } from "@evobgp/ui/components/label"
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@evobgp/ui/components/popover"
|
||||||
|
import { SearchIcon, XIcon, ListFilterIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const statuses = ["Pending", "Shipped", "Cancelled"] as const
|
||||||
|
|
||||||
|
type Status = (typeof statuses)[number]
|
||||||
|
|
||||||
|
function toggleStatus(values: Status[], value: Status) {
|
||||||
|
return values.includes(value)
|
||||||
|
? values.filter((item) => item !== value)
|
||||||
|
: [...values, value]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pattern() {
|
||||||
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
const [selectedStatuses, setSelectedStatuses] = useState<Status[]>([])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field className="max-w-sm">
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupAddon align="inline-start">
|
||||||
|
<SearchIcon aria-hidden="true" />
|
||||||
|
</InputGroupAddon>
|
||||||
|
|
||||||
|
<InputGroupInput
|
||||||
|
placeholder="Search orders..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(event) => setSearchQuery(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputGroupAddon align="inline-end" className="gap-1">
|
||||||
|
{searchQuery.length > 0 ? (
|
||||||
|
<InputGroupButton
|
||||||
|
aria-label="Clear search"
|
||||||
|
size="icon-xs"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setSearchQuery("")}
|
||||||
|
>
|
||||||
|
<XIcon aria-hidden="true" />
|
||||||
|
</InputGroupButton>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<InputGroupButton
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
className="gap-1.5"
|
||||||
|
aria-label="Filter order status"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListFilterIcon className="size-3.5" aria-hidden="true" />
|
||||||
|
Status
|
||||||
|
{selectedStatuses.length > 0 ? (
|
||||||
|
<span className="text-muted-foreground tabular-nums">
|
||||||
|
{selectedStatuses.length}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="w-40 p-3">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{statuses.map((status) => (
|
||||||
|
<div key={status} className="flex items-center gap-2.5">
|
||||||
|
<Checkbox
|
||||||
|
id={`order-status-${status}`}
|
||||||
|
checked={selectedStatuses.includes(status)}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
setSelectedStatuses((previous) =>
|
||||||
|
toggleStatus(previous, status)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`order-status-${status}`}
|
||||||
|
className="text-sm font-normal"
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Field } from '@evobgp/ui/components/field'
|
||||||
|
|
||||||
|
import { SelectMenu } from '@/components/select-field'
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ label: 'Select an item', value: 'placeholder' as const },
|
||||||
|
...Array.from({ length: 100 }).map((_, i) => ({
|
||||||
|
label: `Item ${i}`,
|
||||||
|
value: `item-${i}` as const,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
|
export function Pattern() {
|
||||||
|
return (
|
||||||
|
<Field className="max-w-xs">
|
||||||
|
<SelectMenu items={items} placeholder="Select an item" />
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "@evobgp/ui/components/tabs"
|
||||||
|
import { LayoutDashboardIcon, BarChart3Icon, SettingsIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export function Pattern() {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full max-w-md flex-col gap-6">
|
||||||
|
<Tabs defaultValue="overview">
|
||||||
|
<TabsList className="w-full">
|
||||||
|
<TabsTrigger value="overview">
|
||||||
|
<LayoutDashboardIcon className="size-4" />
|
||||||
|
Overview
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="analytics">
|
||||||
|
<BarChart3Icon className="size-4" />
|
||||||
|
Analytics
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="settings">
|
||||||
|
<SettingsIcon className="size-4" />
|
||||||
|
Settings
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="overview">
|
||||||
|
<Card>
|
||||||
|
<CardContent>Overview dashboard content goes here.</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="analytics">
|
||||||
|
<Card>
|
||||||
|
<CardContent>Analytics charts and metrics.</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="settings">
|
||||||
|
<Card>
|
||||||
|
<CardContent>Application settings and preferences.</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
|
||||||
|
import { Card, CardContent } from "@evobgp/ui/components/card"
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "@evobgp/ui/components/tabs"
|
||||||
|
|
||||||
|
export function Pattern() {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full max-w-md flex-col gap-6">
|
||||||
|
<Tabs defaultValue="inbox">
|
||||||
|
<TabsList variant="line" className="mb-3.5 w-full">
|
||||||
|
<TabsTrigger value="inbox" className="gap-2">
|
||||||
|
Inbox
|
||||||
|
<Badge variant="primary-light" size="sm">
|
||||||
|
12
|
||||||
|
</Badge>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="drafts" className="gap-2">
|
||||||
|
Drafts
|
||||||
|
<Badge variant="info-light" size="sm">
|
||||||
|
3
|
||||||
|
</Badge>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="sent" className="gap-2">
|
||||||
|
Sent
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="spam" className="gap-2">
|
||||||
|
Spam
|
||||||
|
<Badge variant="destructive-light" size="sm">
|
||||||
|
24
|
||||||
|
</Badge>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="inbox">
|
||||||
|
<Card>
|
||||||
|
<CardContent>12 unread messages in your inbox.</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="drafts">
|
||||||
|
<Card>
|
||||||
|
<CardContent>3 drafts waiting to be sent.</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="sent">
|
||||||
|
<Card>
|
||||||
|
<CardContent>All sent messages appear here.</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="spam">
|
||||||
|
<Card>
|
||||||
|
<CardContent>24 spam messages detected.</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { ColumnDef } from '@tanstack/react-table'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
|
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
|
import { DataGridSection } from '@/components/data-grid-shell'
|
||||||
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
|
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||||
|
import type { FirewallClient } from '@/types/api'
|
||||||
|
|
||||||
|
function formatPacketCount(value?: number | null): string | null {
|
||||||
|
if (value == null || value <= 0) return null
|
||||||
|
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
|
||||||
|
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirewallClientsGridProps {
|
||||||
|
clients: FirewallClient[]
|
||||||
|
isLoading?: boolean
|
||||||
|
onApprove: (id: string) => void
|
||||||
|
onReject: (id: string) => void
|
||||||
|
approvePending?: boolean
|
||||||
|
rejectPending?: boolean
|
||||||
|
emptyTitle?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FirewallClientsGrid({
|
||||||
|
clients,
|
||||||
|
isLoading = false,
|
||||||
|
onApprove,
|
||||||
|
onReject,
|
||||||
|
approvePending = false,
|
||||||
|
rejectPending = false,
|
||||||
|
emptyTitle = 'Нет клиентов',
|
||||||
|
}: FirewallClientsGridProps) {
|
||||||
|
const columns = useMemo<ColumnDef<FirewallClient>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DataGridPrimaryCell
|
||||||
|
title={row.original.name}
|
||||||
|
subtitle={row.original.hostname || row.original.token_prefix}
|
||||||
|
accent="primary"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: 'Имя' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||||
|
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||||
|
meta: { headerTitle: 'Статус' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'last_seen_at',
|
||||||
|
accessorFn: (row) => row.last_seen_at ?? '',
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="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,51 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
} from '@evobgp/ui/components/drawer'
|
||||||
|
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
|
||||||
|
|
||||||
|
import {
|
||||||
|
DrawerActionsFooter,
|
||||||
|
formDrawerContentClassName,
|
||||||
|
} from '@/components/drawer-layout'
|
||||||
|
|
||||||
|
interface FormDrawerProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
children: ReactNode
|
||||||
|
footer: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormDrawer({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
className,
|
||||||
|
}: FormDrawerProps) {
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={onOpenChange} swipeDirection="right">
|
||||||
|
<DrawerContent className={cn(formDrawerContentClassName, className)}>
|
||||||
|
<DrawerHeader className="shrink-0 border-b border-border pb-4">
|
||||||
|
<DrawerTitle>{title}</DrawerTitle>
|
||||||
|
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
|
||||||
|
</DrawerHeader>
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<div className="space-y-4 px-4 py-4">{children}</div>
|
||||||
|
</ScrollArea>
|
||||||
|
<DrawerActionsFooter>{footer}</DrawerActionsFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
BookText,
|
BookText,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
ServerCog,
|
ServerCog,
|
||||||
|
Shield,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -69,6 +70,7 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
label: 'Операции',
|
label: 'Операции',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/operations', label: 'Операции', icon: Cog },
|
{ to: '/operations', label: 'Операции', icon: Cog },
|
||||||
|
{ to: '/firewall', label: 'Firewall', icon: Shield },
|
||||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks },
|
{ to: '/schedule', label: 'Задачи', icon: ListChecks },
|
||||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity },
|
{ to: '/monitoring', label: 'Мониторинг', icon: Activity },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
import { Field, FieldLabel } from '@evobgp/ui/components/field'
|
||||||
|
|
||||||
|
import { SelectMenu } from '@/components/select-field'
|
||||||
|
import {
|
||||||
|
NONE_OPTION,
|
||||||
|
communityOptionLabel,
|
||||||
|
fromNullableSelect,
|
||||||
|
nullableSelectValue,
|
||||||
|
} from '@/lib/modules/helpers'
|
||||||
|
import type { BgpCommunity } from '@/types/api'
|
||||||
|
|
||||||
|
interface CommunitySelectProps {
|
||||||
|
id?: string
|
||||||
|
label?: string
|
||||||
|
value: string | null
|
||||||
|
onValueChange: (value: string | null) => void
|
||||||
|
communities: BgpCommunity[]
|
||||||
|
nullable?: boolean
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommunitySelect({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
communities,
|
||||||
|
nullable = false,
|
||||||
|
placeholder = 'Выберите community',
|
||||||
|
}: CommunitySelectProps) {
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const communityItems = communities.map((c) => ({
|
||||||
|
value: c.id,
|
||||||
|
label: communityOptionLabel(c),
|
||||||
|
}))
|
||||||
|
if (nullable) {
|
||||||
|
return [{ value: NONE_OPTION, label: 'Не выбрано' }, ...communityItems]
|
||||||
|
}
|
||||||
|
return communityItems
|
||||||
|
}, [communities, nullable])
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||||
|
{select}
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
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'
|
||||||
|
import type { AsEntry, AsEntryCreate, AsEntryPatch, BgpCommunity } from '@/types/api'
|
||||||
|
|
||||||
|
interface ModuleAsEntryDialogProps {
|
||||||
|
open: boolean
|
||||||
|
moduleId: string
|
||||||
|
edit: AsEntry | null
|
||||||
|
communities: BgpCommunity[]
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSaved: () => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModuleAsEntryDialog({
|
||||||
|
open,
|
||||||
|
moduleId,
|
||||||
|
edit,
|
||||||
|
communities,
|
||||||
|
onOpenChange,
|
||||||
|
onSaved,
|
||||||
|
}: ModuleAsEntryDialogProps) {
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [form, setForm] = useState<AsEntryCreate>({ asn: 0, community_id: null })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setForm(
|
||||||
|
edit
|
||||||
|
? { asn: edit.asn, community_id: edit.community_id }
|
||||||
|
: { asn: 0, community_id: null },
|
||||||
|
)
|
||||||
|
}, [open, edit])
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const asn = Number(form.asn)
|
||||||
|
if (!Number.isFinite(asn) || asn < 1 || asn > 4294967295) {
|
||||||
|
toast.error('Укажите корректный ASN (1–4294967295)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const body: AsEntryCreate | AsEntryPatch = { asn, community_id: form.community_id }
|
||||||
|
if (edit) {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/as-entries/${edit.id}`, 'PATCH', body)
|
||||||
|
toast.success('Запись обновлена')
|
||||||
|
} else {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/as-entries`, 'POST', body as AsEntryCreate)
|
||||||
|
toast.success('Запись добавлена')
|
||||||
|
}
|
||||||
|
onOpenChange(false)
|
||||||
|
await onSaved()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { useEffect, useMemo, 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 { 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'
|
||||||
|
|
||||||
|
const CDN_KIND_ITEMS = [
|
||||||
|
{ value: 'plaintext', label: 'plaintext' },
|
||||||
|
{ value: 'json', label: 'json' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
interface ModuleCdnSourceDialogProps {
|
||||||
|
open: boolean
|
||||||
|
moduleId: string
|
||||||
|
edit: CdnSource | null
|
||||||
|
communities: BgpCommunity[]
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSaved: () => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type CdnForm = CdnSourceCreate & { refresh_interval_sec?: number | null }
|
||||||
|
|
||||||
|
export function ModuleCdnSourceDialog({
|
||||||
|
open,
|
||||||
|
moduleId,
|
||||||
|
edit,
|
||||||
|
communities,
|
||||||
|
onOpenChange,
|
||||||
|
onSaved,
|
||||||
|
}: ModuleCdnSourceDialogProps) {
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false)
|
||||||
|
const [previewItems, setPreviewItems] = useState<string[]>([])
|
||||||
|
const [previewTotal, setPreviewTotal] = useState(0)
|
||||||
|
const [previewTruncated, setPreviewTruncated] = useState(false)
|
||||||
|
const [previewError, setPreviewError] = useState<string | null>(null)
|
||||||
|
const [previewOk, setPreviewOk] = useState(false)
|
||||||
|
const [form, setForm] = useState<CdnForm>({
|
||||||
|
url: '',
|
||||||
|
source_kind: 'plaintext',
|
||||||
|
prefix_path: '',
|
||||||
|
community_id: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const kindItems = useMemo(() => [...CDN_KIND_ITEMS], [])
|
||||||
|
|
||||||
|
function clearPreview() {
|
||||||
|
setPreviewLoading(false)
|
||||||
|
setPreviewItems([])
|
||||||
|
setPreviewTotal(0)
|
||||||
|
setPreviewTruncated(false)
|
||||||
|
setPreviewError(null)
|
||||||
|
setPreviewOk(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
clearPreview()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setForm(
|
||||||
|
edit
|
||||||
|
? {
|
||||||
|
url: edit.url,
|
||||||
|
source_kind: normalizeCdnSourceKind(edit.source_kind),
|
||||||
|
prefix_path: edit.prefix_path ?? '',
|
||||||
|
community_id: edit.community_id,
|
||||||
|
refresh_interval_sec: edit.refresh_interval_sec,
|
||||||
|
}
|
||||||
|
: { url: '', source_kind: 'plaintext', prefix_path: '', community_id: null },
|
||||||
|
)
|
||||||
|
clearPreview()
|
||||||
|
}, [open, edit])
|
||||||
|
|
||||||
|
async function previewCdn() {
|
||||||
|
const urlTrim = form.url.trim()
|
||||||
|
if (!urlTrim) {
|
||||||
|
toast.error('Укажите URL')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPreviewLoading(true)
|
||||||
|
setPreviewError(null)
|
||||||
|
setPreviewOk(false)
|
||||||
|
try {
|
||||||
|
const res = await apiMutate<CdnPreviewResponse>(
|
||||||
|
`/v1/modules/${moduleId}/cdn-sources/preview`,
|
||||||
|
'POST',
|
||||||
|
{
|
||||||
|
url: urlTrim,
|
||||||
|
source_kind: form.source_kind,
|
||||||
|
prefix_path: form.prefix_path?.trim() ?? '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
setPreviewItems(res.items)
|
||||||
|
setPreviewTotal(res.total)
|
||||||
|
setPreviewTruncated(res.truncated)
|
||||||
|
setPreviewOk(true)
|
||||||
|
} catch (e) {
|
||||||
|
setPreviewError(e instanceof ApiError ? e.message : String(e))
|
||||||
|
setPreviewItems([])
|
||||||
|
setPreviewTotal(0)
|
||||||
|
setPreviewTruncated(false)
|
||||||
|
setPreviewOk(false)
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const urlTrim = form.url.trim()
|
||||||
|
if (!urlTrim) {
|
||||||
|
toast.error('Укажите URL')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
...form,
|
||||||
|
url: urlTrim,
|
||||||
|
source_kind: form.source_kind,
|
||||||
|
prefix_path: form.prefix_path?.trim() ?? '',
|
||||||
|
}
|
||||||
|
if (edit) {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${edit.id}`, 'PATCH', body)
|
||||||
|
toast.success('Источник обновлён')
|
||||||
|
} else {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/cdn-sources`, 'POST', body)
|
||||||
|
toast.success('Источник добавлен')
|
||||||
|
}
|
||||||
|
clearPreview()
|
||||||
|
onOpenChange(false)
|
||||||
|
await onSaved()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
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'
|
||||||
|
import type { BgpCommunity, DomainEntry, DomainEntryCreate } from '@/types/api'
|
||||||
|
|
||||||
|
interface ModuleDomainEntryDialogProps {
|
||||||
|
open: boolean
|
||||||
|
moduleId: string
|
||||||
|
edit: DomainEntry | null
|
||||||
|
communities: BgpCommunity[]
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSaved: () => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModuleDomainEntryDialog({
|
||||||
|
open,
|
||||||
|
moduleId,
|
||||||
|
edit,
|
||||||
|
communities,
|
||||||
|
onOpenChange,
|
||||||
|
onSaved,
|
||||||
|
}: ModuleDomainEntryDialogProps) {
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [form, setForm] = useState<DomainEntryCreate>({ fqdn: '', community_id: null })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setForm(
|
||||||
|
edit
|
||||||
|
? { fqdn: edit.fqdn, community_id: edit.community_id }
|
||||||
|
: { fqdn: '', community_id: null },
|
||||||
|
)
|
||||||
|
}, [open, edit])
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!form.fqdn.trim()) {
|
||||||
|
toast.error('Укажите FQDN')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const body = { fqdn: form.fqdn.trim(), community_id: form.community_id }
|
||||||
|
if (edit) {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${edit.id}`, 'PATCH', body)
|
||||||
|
toast.success('Домен обновлён')
|
||||||
|
} else {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/domain-entries`, 'POST', body)
|
||||||
|
toast.success('Домен добавлен')
|
||||||
|
}
|
||||||
|
onOpenChange(false)
|
||||||
|
await onSaved()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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 }
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Plus } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
|
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 type {
|
||||||
|
AsEntry,
|
||||||
|
BgpCommunity,
|
||||||
|
CdnSource,
|
||||||
|
DomainEntry,
|
||||||
|
IpRangeEntry,
|
||||||
|
ModuleRow,
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
|
interface ModuleEntriesSectionProps {
|
||||||
|
moduleId: string
|
||||||
|
mod: ModuleRow
|
||||||
|
items: Record<string, unknown>[]
|
||||||
|
communities: BgpCommunity[]
|
||||||
|
isLoading: boolean
|
||||||
|
isError: boolean
|
||||||
|
error: Error | null
|
||||||
|
onRetry: () => void
|
||||||
|
onChanged: () => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteTarget = ModuleEntryDeleteTarget
|
||||||
|
|
||||||
|
const CARD_META: Record<
|
||||||
|
ModuleRow['type'],
|
||||||
|
{ title: string; description: string; emptyTitle: string; emptyDescription: string }
|
||||||
|
> = {
|
||||||
|
DOMAINS: {
|
||||||
|
title: 'Домены',
|
||||||
|
description: 'FQDN для резолвинга через DoH.',
|
||||||
|
emptyTitle: 'Нет доменов',
|
||||||
|
emptyDescription: 'Добавьте FQDN для резолвинга.',
|
||||||
|
},
|
||||||
|
IP_RANGES: {
|
||||||
|
title: 'IP-диапазоны',
|
||||||
|
description: 'Статические CIDR для анонса.',
|
||||||
|
emptyTitle: 'Нет диапазонов',
|
||||||
|
emptyDescription: 'Добавьте CIDR.',
|
||||||
|
},
|
||||||
|
CDN_CIDRS: {
|
||||||
|
title: 'CDN-источники',
|
||||||
|
description: 'URL источников для скачивания списков CIDR.',
|
||||||
|
emptyTitle: 'Нет источников',
|
||||||
|
emptyDescription: 'Добавьте CDN-источник.',
|
||||||
|
},
|
||||||
|
AS_PREFIXES: {
|
||||||
|
title: 'AS-записи',
|
||||||
|
description: 'ASN для получения префиксов через RIPEstat.',
|
||||||
|
emptyTitle: 'Нет записей',
|
||||||
|
emptyDescription: 'Добавьте ASN.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModuleEntriesSection({
|
||||||
|
moduleId,
|
||||||
|
mod,
|
||||||
|
items,
|
||||||
|
communities,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
onChanged,
|
||||||
|
}: ModuleEntriesSectionProps) {
|
||||||
|
const meta = CARD_META[mod.type]
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
const [editDomain, setEditDomain] = useState<DomainEntry | null>(null)
|
||||||
|
const [editIpRange, setEditIpRange] = useState<IpRangeEntry | null>(null)
|
||||||
|
const [editCdn, setEditCdn] = useState<CdnSource | null>(null)
|
||||||
|
const [editAs, setEditAs] = useState<AsEntry | null>(null)
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditDomain(null)
|
||||||
|
setEditIpRange(null)
|
||||||
|
setEditCdn(null)
|
||||||
|
setEditAs(null)
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
setDialogOpen(false)
|
||||||
|
setEditDomain(null)
|
||||||
|
setEditIpRange(null)
|
||||||
|
setEditCdn(null)
|
||||||
|
setEditAs(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
const { kind, entry } = deleteTarget
|
||||||
|
const pathByKind = {
|
||||||
|
domain: `/v1/modules/${moduleId}/domain-entries/${entry.id}`,
|
||||||
|
'ip-range': `/v1/modules/${moduleId}/ip-range-entries/${entry.id}`,
|
||||||
|
cdn: `/v1/modules/${moduleId}/cdn-sources/${entry.id}`,
|
||||||
|
as: `/v1/modules/${moduleId}/as-entries/${entry.id}`,
|
||||||
|
} as const
|
||||||
|
await apiMutate(pathByKind[kind], 'DELETE', undefined, { idempotent: false })
|
||||||
|
toast.success('Удалено')
|
||||||
|
setDeleteTarget(null)
|
||||||
|
await onChanged()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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
|
||||||
|
open={dialogOpen}
|
||||||
|
moduleId={moduleId}
|
||||||
|
edit={editDomain}
|
||||||
|
communities={communities}
|
||||||
|
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
|
||||||
|
onSaved={onChanged}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{mod.type === 'IP_RANGES' ? (
|
||||||
|
<ModuleIpRangeEntryDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
moduleId={moduleId}
|
||||||
|
edit={editIpRange}
|
||||||
|
communities={communities}
|
||||||
|
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
|
||||||
|
onSaved={onChanged}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{mod.type === 'CDN_CIDRS' ? (
|
||||||
|
<ModuleCdnSourceDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
moduleId={moduleId}
|
||||||
|
edit={editCdn}
|
||||||
|
communities={communities}
|
||||||
|
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
|
||||||
|
onSaved={onChanged}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{mod.type === 'AS_PREFIXES' ? (
|
||||||
|
<ModuleAsEntryDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
moduleId={moduleId}
|
||||||
|
edit={editAs}
|
||||||
|
communities={communities}
|
||||||
|
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
|
||||||
|
onSaved={onChanged}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteTarget !== null}
|
||||||
|
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||||
|
title="Удалить запись?"
|
||||||
|
description={deleteDescription(deleteTarget)}
|
||||||
|
confirmLabel="Удалить"
|
||||||
|
confirmLoadingLabel="Удаление…"
|
||||||
|
destructive
|
||||||
|
confirmLoading={deleting}
|
||||||
|
onConfirm={() => void confirmDelete()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteDescription(target: DeleteTarget | null): string {
|
||||||
|
if (!target) return ''
|
||||||
|
switch (target.kind) {
|
||||||
|
case 'domain':
|
||||||
|
return target.entry.fqdn
|
||||||
|
case 'ip-range':
|
||||||
|
return target.entry.prefix
|
||||||
|
case 'cdn':
|
||||||
|
return target.entry.url
|
||||||
|
case 'as':
|
||||||
|
return `AS${target.entry.asn}`
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
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'
|
||||||
|
import type { BgpCommunity, IpRangeEntry, IpRangeEntryCreate } from '@/types/api'
|
||||||
|
|
||||||
|
interface ModuleIpRangeEntryDialogProps {
|
||||||
|
open: boolean
|
||||||
|
moduleId: string
|
||||||
|
edit: IpRangeEntry | null
|
||||||
|
communities: BgpCommunity[]
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSaved: () => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModuleIpRangeEntryDialog({
|
||||||
|
open,
|
||||||
|
moduleId,
|
||||||
|
edit,
|
||||||
|
communities,
|
||||||
|
onOpenChange,
|
||||||
|
onSaved,
|
||||||
|
}: ModuleIpRangeEntryDialogProps) {
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [form, setForm] = useState<IpRangeEntryCreate>({ prefix: '', community_id: '' })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setForm(
|
||||||
|
edit
|
||||||
|
? { prefix: edit.prefix, community_id: edit.community_id }
|
||||||
|
: { prefix: '', community_id: '' },
|
||||||
|
)
|
||||||
|
}, [open, edit])
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!form.prefix.trim() || !form.community_id) {
|
||||||
|
toast.error('Укажите префикс и community')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const body = { prefix: form.prefix.trim(), community_id: form.community_id }
|
||||||
|
if (edit) {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${edit.id}`, 'PATCH', body)
|
||||||
|
toast.success('Диапазон обновлён')
|
||||||
|
} else {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries`, 'POST', body)
|
||||||
|
toast.success('Диапазон добавлен')
|
||||||
|
}
|
||||||
|
onOpenChange(false)
|
||||||
|
await onSaved()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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,84 @@
|
|||||||
|
import {
|
||||||
|
ArrowDownUp,
|
||||||
|
Network,
|
||||||
|
RefreshCw,
|
||||||
|
ShieldCheck,
|
||||||
|
Timer,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||||
|
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||||
|
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
dohProfileLabel,
|
||||||
|
moduleDohProfileIds,
|
||||||
|
} from '@/lib/modules/helpers'
|
||||||
|
import { dohPolicyRu } from '@/lib/ui-labels'
|
||||||
|
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
|
||||||
|
|
||||||
|
interface ModuleKpiCardsProps {
|
||||||
|
mod: ModuleRow | null
|
||||||
|
communities: BgpCommunity[]
|
||||||
|
dohProfiles: DohProfile[]
|
||||||
|
asEntries: AsEntry[]
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModuleKpiCards({
|
||||||
|
mod,
|
||||||
|
communities,
|
||||||
|
dohProfiles,
|
||||||
|
asEntries,
|
||||||
|
loading = false,
|
||||||
|
}: ModuleKpiCardsProps) {
|
||||||
|
if (loading || !mod) {
|
||||||
|
return <SectionCardsSkeleton count={5} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const asPrefixTotal = asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
|
||||||
|
const dohIds = moduleDohProfileIds(mod)
|
||||||
|
|
||||||
|
const items: SectionCardItem[] = [
|
||||||
|
{
|
||||||
|
label: 'Приоритет',
|
||||||
|
value: String(mod.priority ?? 0),
|
||||||
|
hint: 'порядок в сборке ревизии',
|
||||||
|
icon: <ArrowDownUp className="size-3.5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Интервал',
|
||||||
|
value: moduleIntervalLabel(mod),
|
||||||
|
hint: 'refresh_interval_sec / cron',
|
||||||
|
icon: <Timer className="size-3.5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'DoH',
|
||||||
|
value: mod.type === 'DOMAINS' ? dohPolicyRu(mod.doh_resolver_policy) : '—',
|
||||||
|
hint:
|
||||||
|
mod.type === 'DOMAINS'
|
||||||
|
? dohIds.length
|
||||||
|
? dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join('; ')
|
||||||
|
: 'Системный DNS'
|
||||||
|
: 'не применимо',
|
||||||
|
icon: <Network className="size-3.5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Community по умолч.',
|
||||||
|
value: communityLabel(mod.default_community_id, communities),
|
||||||
|
hint: 'для записей без своего community',
|
||||||
|
icon: <ShieldCheck className="size-3.5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Последнее обновление',
|
||||||
|
value: formatDateTime(mod.last_refreshed_at),
|
||||||
|
hint:
|
||||||
|
mod.type === 'AS_PREFIXES'
|
||||||
|
? `ASN: ${asEntries.length}, префиксов: ${asPrefixTotal}`
|
||||||
|
: 'время последнего refresh',
|
||||||
|
icon: <RefreshCw className="size-3.5" />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return <SectionCards items={items} />
|
||||||
|
}
|
||||||
@@ -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"
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
|
||||||
const badgeVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -19,19 +23,19 @@ const badgeVariants = cva(
|
|||||||
focus: "bg-focus text-focus-foreground",
|
focus: "bg-focus text-focus-foreground",
|
||||||
invert: "bg-invert text-invert-foreground",
|
invert: "bg-invert text-invert-foreground",
|
||||||
"primary-light":
|
"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":
|
"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":
|
"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":
|
"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":
|
"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":
|
"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":
|
"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":
|
"primary-outline":
|
||||||
"bg-background border-border text-primary dark:bg-input/30",
|
"bg-background border-border text-primary dark:bg-input/30",
|
||||||
"warning-outline":
|
"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",
|
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",
|
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: {
|
radius: {
|
||||||
default:
|
default:
|
||||||
"rounded-sm",
|
"rounded-sm",
|
||||||
|
|||||||
@@ -4,12 +4,8 @@ import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
|||||||
import { cn } from "@evobgp/ui/lib/utils"
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
import { Button } from "@evobgp/ui/components/button"
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
import {
|
import {
|
||||||
Select,
|
SelectMenu,
|
||||||
SelectContent,
|
} from "@/components/select-field"
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@evobgp/ui/components/select"
|
|
||||||
import { Skeleton } from "@evobgp/ui/components/skeleton"
|
import { Skeleton } from "@evobgp/ui/components/skeleton"
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
||||||
|
|
||||||
@@ -152,24 +148,23 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
|||||||
<div className="text-muted-foreground text-sm">
|
<div className="text-muted-foreground text-sm">
|
||||||
{mergedProps.rowsPerPageLabel}
|
{mergedProps.rowsPerPageLabel}
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<SelectMenu
|
||||||
|
items={
|
||||||
|
mergedProps?.sizes?.map((size: number) => ({
|
||||||
|
value: `${size}`,
|
||||||
|
label: `${size}`,
|
||||||
|
})) ?? []
|
||||||
|
}
|
||||||
value={`${pageSize}`}
|
value={`${pageSize}`}
|
||||||
|
triggerClassName="w-14"
|
||||||
|
size="sm"
|
||||||
|
side="top"
|
||||||
|
contentClassName="min-w-18"
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
const newPageSize = Number(value)
|
if (!value) return
|
||||||
table.setPageSize(newPageSize)
|
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>
|
</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 { 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 }) {
|
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
|
||||||
return (
|
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:items-start">
|
||||||
|
<Card className="gap-0">
|
||||||
|
<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 }) {
|
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
|
||||||
return (
|
return (
|
||||||
<Card className="gap-0">
|
<Card className="gap-0">
|
||||||
|
|||||||
@@ -1,30 +1,71 @@
|
|||||||
import type { ComponentProps } from 'react'
|
import type { ComponentProps } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
|
||||||
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
||||||
|
|
||||||
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||||
active: 'success',
|
active: 'success-light',
|
||||||
ok: 'success',
|
ok: 'success-light',
|
||||||
paid: 'success',
|
paid: 'success-light',
|
||||||
established: 'success',
|
established: 'success-light',
|
||||||
succeeded: 'success',
|
succeeded: 'success-light',
|
||||||
healthy: 'success',
|
healthy: 'success-light',
|
||||||
paused: 'secondary',
|
approved: 'success-light',
|
||||||
disabled: 'secondary',
|
accept: 'success-light',
|
||||||
|
paused: 'invert-light',
|
||||||
|
disabled: 'invert-light',
|
||||||
archived: 'outline',
|
archived: 'outline',
|
||||||
error: 'destructive',
|
error: 'destructive-light',
|
||||||
failed: 'destructive',
|
failed: 'destructive-light',
|
||||||
running: 'info',
|
revoked: 'destructive-light',
|
||||||
queued: 'info',
|
block: 'destructive-light',
|
||||||
overdue: 'warning',
|
cancelled: 'destructive-light',
|
||||||
stale: 'warning',
|
running: 'info-light',
|
||||||
warning: 'warning',
|
queued: 'info-light',
|
||||||
mismatch: 'warning',
|
overdue: 'warning-light',
|
||||||
|
stale: 'warning-light',
|
||||||
|
warning: 'warning-light',
|
||||||
|
mismatch: 'warning-light',
|
||||||
|
pending: 'warning-light',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusBadge({ status, label }: { status: string; label?: string }) {
|
const DOT_COLOR: Record<string, string> = {
|
||||||
const variant = STATUS_VARIANT[status.toLowerCase()] ?? 'outline'
|
'success-light': 'bg-success',
|
||||||
return <Badge variant={variant}>{label ?? status}</Badge>
|
'info-light': 'bg-info',
|
||||||
|
'warning-light': 'bg-warning',
|
||||||
|
'destructive-light': 'bg-destructive',
|
||||||
|
'invert-light': 'bg-muted-foreground',
|
||||||
|
outline: 'bg-muted-foreground',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jobStatusVariant(status: string): BadgeVariant {
|
||||||
|
return STATUS_VARIANT[status.toLowerCase()] ?? 'outline'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({
|
||||||
|
status,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
status: string
|
||||||
|
label?: string
|
||||||
|
hint?: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const variant = jobStatusVariant(status)
|
||||||
|
const dotColor = DOT_COLOR[variant] ?? 'bg-muted-foreground'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col gap-0.5', className)}>
|
||||||
|
<Badge variant={variant} size="sm" radius="full" className="gap-1.5">
|
||||||
|
<span className={cn('size-1.5 shrink-0 rounded-full', dotColor)} aria-hidden />
|
||||||
|
{label ?? status}
|
||||||
|
</Badge>
|
||||||
|
{hint ? <span className="text-xs text-muted-foreground">{hint}</span> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,19 @@
|
|||||||
|
import type { ApiKeyRole } from '@/types/api'
|
||||||
|
|
||||||
|
export const API_KEY_ROLE_ITEMS: ReadonlyArray<{ value: ApiKeyRole; label: string }> = [
|
||||||
|
{ value: 'viewer', label: 'viewer — только чтение' },
|
||||||
|
{ value: 'editor', label: 'editor — CRUD без apply' },
|
||||||
|
{ value: 'operator', label: 'operator — полный доступ' },
|
||||||
|
{ value: 'node', label: 'node — только API ноды' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function apiKeyRoleLabel(role: ApiKeyRole): string {
|
||||||
|
return API_KEY_ROLE_ITEMS.find((o) => o.value === role)?.label ?? role
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatApiKeyDate(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime())) return '—'
|
||||||
|
return d.toLocaleString('ru-RU')
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ import type {
|
|||||||
|
|
||||||
export const TOKEN_STORAGE_KEY = 'evobgp_api_token'
|
export const TOKEN_STORAGE_KEY = 'evobgp_api_token'
|
||||||
|
|
||||||
|
/** Локальный demo-токен (operator) при включённом demo-seed — см. docs/access.md */
|
||||||
|
export const DEV_API_TOKEN = 'dev'
|
||||||
|
|
||||||
export type Problem = {
|
export type Problem = {
|
||||||
type?: string
|
type?: string
|
||||||
title?: string
|
title?: string
|
||||||
@@ -18,14 +21,31 @@ export type Problem = {
|
|||||||
detail?: string
|
detail?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Убирает пробелы и опциональный префикс Bearer (UI часто вставляет «Bearer dev»). */
|
||||||
|
export function normalizeApiToken(raw: string): string {
|
||||||
|
let t = raw.trim()
|
||||||
|
if (/^bearer\s+/i.test(t)) {
|
||||||
|
t = t.replace(/^bearer\s+/i, '').trim()
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
function getToken(): string | null {
|
function getToken(): string | null {
|
||||||
if (typeof window === 'undefined') return null
|
if (typeof window === 'undefined') return null
|
||||||
return window.localStorage.getItem(TOKEN_STORAGE_KEY)
|
const raw = window.localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
const normalized = normalizeApiToken(raw)
|
||||||
|
return normalized || null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setToken(token: string | null): void {
|
export function setToken(token: string | null): void {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
if (token) window.localStorage.setItem(TOKEN_STORAGE_KEY, token)
|
if (!token) {
|
||||||
|
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const normalized = normalizeApiToken(token)
|
||||||
|
if (normalized) window.localStorage.setItem(TOKEN_STORAGE_KEY, normalized)
|
||||||
else window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
else window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,24 @@
|
|||||||
|
import type { ModuleRow } from '@/types/api'
|
||||||
|
|
||||||
|
export function formatDateTime(value: string | null | undefined): string {
|
||||||
|
if (typeof value !== 'string' || value.trim().length === 0) return '—'
|
||||||
|
const parsed = new Date(value)
|
||||||
|
if (Number.isNaN(parsed.getTime())) return '—'
|
||||||
|
return parsed.toLocaleString('ru-RU')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moduleIntervalLabel(moduleRow: ModuleRow): string {
|
||||||
|
const cron = typeof moduleRow.cron_expr === 'string' ? moduleRow.cron_expr.trim() : ''
|
||||||
|
const raw = moduleRow.refresh_interval_sec as unknown
|
||||||
|
const interval =
|
||||||
|
typeof raw === 'number'
|
||||||
|
? raw
|
||||||
|
: typeof raw === 'string' && raw.trim().length > 0
|
||||||
|
? Number(raw)
|
||||||
|
: null
|
||||||
|
const intervalLabel = interval !== null && Number.isFinite(interval) ? `${interval}с` : ''
|
||||||
|
if (cron && intervalLabel) return `${intervalLabel} (${cron})`
|
||||||
|
if (cron) return cron
|
||||||
|
if (intervalLabel) return intervalLabel
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
|
||||||
|
|
||||||
|
export const NONE_OPTION = '__none__'
|
||||||
|
|
||||||
|
export function communityLabel(id: string | null | undefined, communities: BgpCommunity[]): string {
|
||||||
|
if (!id) return '—'
|
||||||
|
const c = communities.find((x) => x.id === id)
|
||||||
|
if (!c) return `${id.slice(0, 8)}…`
|
||||||
|
const t = c.title?.trim()
|
||||||
|
return t || c.community
|
||||||
|
}
|
||||||
|
|
||||||
|
export function communityOptionLabel(c: BgpCommunity): string {
|
||||||
|
const t = c.title?.trim()
|
||||||
|
return t || c.community
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nullableSelectValue(value: string | null | undefined): string {
|
||||||
|
if (value === null || value === undefined || value === '') return NONE_OPTION
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromNullableSelect(value: string): string | null {
|
||||||
|
if (value === NONE_OPTION || value === '') return null
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moduleDohProfileIds(modRow: ModuleRow | null): string[] {
|
||||||
|
if (!modRow) return []
|
||||||
|
if (modRow.doh_profile_ids?.length) return modRow.doh_profile_ids
|
||||||
|
return modRow.doh_profile_id ? [modRow.doh_profile_id] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dohProfileLabel(id: string, dohProfiles: DohProfile[]): string {
|
||||||
|
const p = dohProfiles.find((d) => d.id === id)
|
||||||
|
return p ? (p.name?.trim() ? `${p.name} (${p.url})` : p.url) : `${id.slice(0, 8)}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCdnSourceKind(k: string): 'plaintext' | 'json' {
|
||||||
|
return k.trim().toLowerCase() === 'json' ? 'json' : 'plaintext'
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { DohResolverPolicy } from '@/types/api'
|
||||||
|
|
||||||
|
export function dohPolicyRu(policy: DohResolverPolicy | string | null | undefined): string {
|
||||||
|
switch (policy) {
|
||||||
|
case 'primary_only':
|
||||||
|
return 'Только первый'
|
||||||
|
case 'failover':
|
||||||
|
return 'Резервирование'
|
||||||
|
case 'union':
|
||||||
|
return 'Объединение'
|
||||||
|
default:
|
||||||
|
return 'Только первый'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moduleTypeRu(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'AS_PREFIXES':
|
||||||
|
return 'AS (номера)'
|
||||||
|
case 'CDN_CIDRS':
|
||||||
|
return 'CDN CIDR'
|
||||||
|
case 'DOMAINS':
|
||||||
|
return 'Домены'
|
||||||
|
case 'IP_RANGES':
|
||||||
|
return 'IP-диапазоны'
|
||||||
|
default:
|
||||||
|
return type
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { queryOptions } from '@tanstack/react-query'
|
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { apiJSON } from '@/lib/api-client'
|
import { toast } from 'sonner'
|
||||||
import type { ApiKey, ApiKeysResponse } from '@/types/api'
|
|
||||||
|
import { apiJSON, apiMutate } from '@/lib/api-client'
|
||||||
|
import type { ApiKey, ApiKeyCreate, ApiKeyCreated, ApiKeysResponse } from '@/types/api'
|
||||||
|
|
||||||
export const apiKeysKeys = {
|
export const apiKeysKeys = {
|
||||||
all: ['api-keys'] as const,
|
all: ['api-keys'] as const,
|
||||||
@@ -17,3 +19,48 @@ export function apiKeysQueryOptions() {
|
|||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function invalidateApiKeysList(qc: ReturnType<typeof useQueryClient>) {
|
||||||
|
void qc.invalidateQueries({ queryKey: apiKeysKeys.list() })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateApiKeyMutation() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: ApiKeyCreate) =>
|
||||||
|
apiMutate<ApiKeyCreated>('/v1/api-keys', 'POST', body, { idempotent: false }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Ключ создан')
|
||||||
|
invalidateApiKeysList(qc)
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось создать ключ'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRevokeApiKeyMutation() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiMutate(`/v1/api-keys/${id}`, 'DELETE', undefined, { idempotent: false }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Ключ отозван')
|
||||||
|
invalidateApiKeysList(qc)
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отозвать'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRotateApiKeyMutation() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiMutate<ApiKeyCreated>(`/v1/api-keys/${id}/rotate`, 'POST', undefined, {
|
||||||
|
idempotent: false,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Ключ ротирован')
|
||||||
|
invalidateApiKeysList(qc)
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось ротировать'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { apiJSON } from '@/lib/api-client'
|
import { toast } from 'sonner'
|
||||||
import type { BirdStatus, PeersResponse, SpeakersResponse } from '@/types/api'
|
|
||||||
|
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
|
export const NETWORK_AUTO_REFRESH_MS = 30_000
|
||||||
|
|
||||||
@@ -34,3 +46,86 @@ export function networkBirdQueryOptions() {
|
|||||||
staleTime: 15_000,
|
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() {
|
export function overviewJobsQueryOptions() {
|
||||||
return queryOptions<JobsResponse>({
|
return queryOptions<JobsResponse>({
|
||||||
queryKey: overviewKeys.jobs(),
|
queryKey: overviewKeys.jobs(),
|
||||||
queryFn: () => apiJSON<JobsResponse>('/v1/jobs?limit=10'),
|
queryFn: () => apiJSON<JobsResponse>('/v1/jobs?limit=100'),
|
||||||
staleTime: 15_000,
|
staleTime: 15_000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { normalizeApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth')({
|
export const Route = createFileRoute('/_auth')({
|
||||||
beforeLoad: () => {
|
beforeLoad: ({ location }) => {
|
||||||
const token =
|
// Настройки доступны без токена — сюда попадают при первом входе (в т.ч. для `dev`).
|
||||||
typeof window !== 'undefined' ? window.localStorage.getItem('evobgp_api_token') : null
|
if (location.pathname === '/settings') return
|
||||||
if (!token) {
|
const raw =
|
||||||
|
typeof window !== 'undefined' ? window.localStorage.getItem(TOKEN_STORAGE_KEY) : null
|
||||||
|
if (!raw || !normalizeApiToken(raw)) {
|
||||||
throw redirect({ to: '/settings' })
|
throw redirect({ to: '/settings' })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,29 +1,17 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import {
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@evobgp/ui/components/table'
|
|
||||||
import { RefreshCw } from 'lucide-react'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
|
import { AccessApiKeysCard } from '@/components/access/access-api-keys-card'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||||
import { authSessionQueryOptions } from '@/queries/auth'
|
import { authSessionQueryOptions } from '@/queries/auth'
|
||||||
import { apiKeysQueryOptions } from '@/queries/api-keys'
|
import { apiKeysQueryOptions } from '@/queries/api-keys'
|
||||||
import { apiMutate } from '@/lib/api-client'
|
|
||||||
import { useMutation } from '@tanstack/react-query'
|
|
||||||
import type { ApiKeyCreated } from '@/types/api'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Copy } from 'lucide-react'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/access')({
|
export const Route = createFileRoute('/_auth/access')({
|
||||||
component: AccessComponent,
|
component: AccessComponent,
|
||||||
@@ -39,20 +27,64 @@ function AccessComponent() {
|
|||||||
enabled: isOperator,
|
enabled: isOperator,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const keys = keysQuery.data ?? []
|
||||||
|
const activeCount = keys.filter((k) => !k.revoked_at).length
|
||||||
|
const revokedCount = keys.filter((k) => k.revoked_at).length
|
||||||
|
|
||||||
|
const refreshing = sessionQuery.isFetching || keysQuery.isFetching
|
||||||
|
|
||||||
|
const kpiItems: SectionCardItem[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: 'Всего ключей',
|
||||||
|
value: keys.length,
|
||||||
|
icon: <KeyRound className="size-4" />,
|
||||||
|
hint: 'в tenant',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Активных',
|
||||||
|
value: activeCount,
|
||||||
|
icon: <ShieldCheck className="size-4" />,
|
||||||
|
hint: 'не отозваны',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Отозванных',
|
||||||
|
value: revokedCount,
|
||||||
|
icon: <ShieldOff className="size-4" />,
|
||||||
|
hint: 'revoked',
|
||||||
|
variant: revokedCount > 0 ? 'warning' : 'default',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[keys.length, activeCount, revokedCount],
|
||||||
|
)
|
||||||
|
|
||||||
|
function refetchAll() {
|
||||||
|
void sessionQuery.refetch()
|
||||||
|
if (isOperator) void keysQuery.refetch()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-4xl flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Права доступа"
|
title="Права доступа"
|
||||||
description="API-ключи control plane и текущая сессия Bearer-токена."
|
description="API-ключи control plane и текущая сессия Bearer-токена."
|
||||||
|
actions={
|
||||||
|
isOperator ? (
|
||||||
|
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||||
|
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{session ? (
|
{session ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="border-b py-3">
|
||||||
<CardTitle className="text-base">Текущая сессия</CardTitle>
|
<CardTitle className="text-base">Текущая сессия</CardTitle>
|
||||||
<CardDescription>Tenant и роль ключа, с которым открыта панель.</CardDescription>
|
<CardDescription>Tenant и роль ключа, с которым открыта панель.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-3 text-sm sm:grid-cols-2">
|
<CardContent className="grid gap-3 p-4 text-sm sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground">Tenant</p>
|
<p className="text-muted-foreground">Tenant</p>
|
||||||
<p className="break-all font-mono text-xs">{session.tenant_id}</p>
|
<p className="break-all font-mono text-xs">{session.tenant_id}</p>
|
||||||
@@ -63,183 +95,46 @@ function AccessComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-6 text-sm text-muted-foreground">
|
||||||
|
Не удалось определить сессию. Укажите токен в{' '}
|
||||||
|
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
|
||||||
|
настройках
|
||||||
|
</Link>{' '}
|
||||||
|
(для dev-окружения — <code className="text-xs">dev</code> при включённом demo-seed).
|
||||||
|
{sessionQuery.isError && sessionQuery.error instanceof Error ? (
|
||||||
|
<span className="mt-2 block text-destructive">{sessionQuery.error.message}</span>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{isOperator ? (
|
{isOperator ? (
|
||||||
<ApiKeysCard
|
<>
|
||||||
items={keysQuery.data ?? []}
|
{keysQuery.isLoading ? (
|
||||||
isLoading={keysQuery.isLoading}
|
<SectionCardsSkeleton count={3} />
|
||||||
isError={keysQuery.isError}
|
) : (
|
||||||
error={keysQuery.error}
|
<SectionCards items={kpiItems} />
|
||||||
onRetry={() => keysQuery.refetch()}
|
)}
|
||||||
/>
|
<AccessApiKeysCard
|
||||||
|
items={keys}
|
||||||
|
isLoading={keysQuery.isLoading}
|
||||||
|
isError={keysQuery.isError}
|
||||||
|
error={keysQuery.error}
|
||||||
|
onRetry={() => keysQuery.refetch()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : session ? (
|
) : session ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-6 text-sm text-muted-foreground">
|
<CardContent className="py-6 text-sm text-muted-foreground">
|
||||||
Управление API-ключами доступно только роли <strong>operator</strong>. Текущая роль:{' '}
|
Управление API-ключами доступно только роли <strong>operator</strong>. Текущая роль:{' '}
|
||||||
<span className="font-mono">{session.role}</span>.
|
<span className="font-mono">{session.role}</span>. Для выдачи ключей войдите с
|
||||||
|
operator-ключом или создайте ключ через API / переменную{' '}
|
||||||
|
<code className="text-xs">EVOBGP_API_KEYS</code>.
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ApiKeysCard({
|
|
||||||
items,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
error,
|
|
||||||
onRetry,
|
|
||||||
}: {
|
|
||||||
items: import('@/types/api').ApiKey[]
|
|
||||||
isLoading: boolean
|
|
||||||
isError: boolean
|
|
||||||
error: unknown
|
|
||||||
onRetry: () => void
|
|
||||||
}) {
|
|
||||||
const [revealedToken, setRevealedToken] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const revoke = useMutation({
|
|
||||||
mutationFn: (id: string) =>
|
|
||||||
apiMutate(`/v1/api-keys/${id}`, 'DELETE', undefined, { idempotent: false }),
|
|
||||||
onSuccess: () => toast.success('Ключ отозван'),
|
|
||||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отозвать'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const rotate = useMutation({
|
|
||||||
mutationFn: (id: string) =>
|
|
||||||
apiMutate<ApiKeyCreated>(`/v1/api-keys/${id}/rotate`, 'POST', undefined, {
|
|
||||||
idempotent: false,
|
|
||||||
}),
|
|
||||||
onSuccess: (created) => {
|
|
||||||
toast.success('Ключ ротирован')
|
|
||||||
setRevealedToken(created.token)
|
|
||||||
},
|
|
||||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось ротировать'),
|
|
||||||
})
|
|
||||||
|
|
||||||
async function copyToken() {
|
|
||||||
if (!revealedToken) return
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(revealedToken)
|
|
||||||
toast.success('Скопировано')
|
|
||||||
} catch {
|
|
||||||
toast.error('Не удалось скопировать')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
<Button size="sm" variant="outline" onClick={onRetry} disabled={isLoading}>
|
|
||||||
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
|
|
||||||
Обновить
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<QueryState
|
|
||||||
data={items}
|
|
||||||
isLoading={isLoading}
|
|
||||||
isError={isError}
|
|
||||||
error={error}
|
|
||||||
empty={items.length === 0}
|
|
||||||
emptyTitle="Нет ключей"
|
|
||||||
emptyDescription="Ключи можно создать через API."
|
|
||||||
onRetry={onRetry}
|
|
||||||
>
|
|
||||||
{(data) => (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<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 className="font-mono text-sm">{k.role}</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">{k.prefix}…</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{k.revoked_at ? (
|
|
||||||
<span className="text-sm text-destructive">отозван</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-muted-foreground">активен</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<ConfirmDialog
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
title="Ротировать"
|
|
||||||
disabled={!!k.revoked_at}
|
|
||||||
>
|
|
||||||
<RefreshCw className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
title="Ротировать ключ?"
|
|
||||||
description="Старый токен перестанет работать сразу."
|
|
||||||
confirmLabel="Ротировать"
|
|
||||||
onConfirm={() => rotate.mutate(k.id)}
|
|
||||||
/>
|
|
||||||
<ConfirmDialog
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="text-destructive"
|
|
||||||
disabled={!!k.revoked_at}
|
|
||||||
title="Отозвать"
|
|
||||||
>
|
|
||||||
<Copy 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>
|
|
||||||
|
|
||||||
{revealedToken ? (
|
|
||||||
<div className="flex flex-col gap-3 border-t p-4">
|
|
||||||
<div className="text-sm font-medium">Новый токен (сохраните сейчас):</div>
|
|
||||||
<div className="break-all rounded-md border bg-muted/40 p-3 font-mono text-xs">
|
|
||||||
{revealedToken}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" size="sm" onClick={copyToken}>
|
|
||||||
<Copy /> Копировать
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={() => setRevealedToken(null)}>
|
|
||||||
Готово
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,44 +1,36 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQueries } from '@tanstack/react-query'
|
import { useQueries } from '@tanstack/react-query'
|
||||||
import {
|
import { RefreshCw } from 'lucide-react'
|
||||||
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 { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
import {
|
||||||
|
Card,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@evobgp/ui/components/card'
|
||||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
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 { PageHeader } from '@/components/page-header'
|
||||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
import { AnalyticsDashboardSkeleton } from '@/components/skeletons'
|
||||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
aggregateNetworkMetrics,
|
|
||||||
moduleNameById,
|
moduleNameById,
|
||||||
overviewHealthQueryOptions,
|
|
||||||
overviewJobsQueryOptions,
|
overviewJobsQueryOptions,
|
||||||
overviewModulesQueryOptions,
|
overviewModulesQueryOptions,
|
||||||
overviewPeersQueryOptions,
|
overviewPeersQueryOptions,
|
||||||
overviewRevisionsQueryOptions,
|
overviewRevisionsQueryOptions,
|
||||||
overviewSpeakersQueryOptions,
|
overviewSpeakersQueryOptions,
|
||||||
runningJobCount,
|
|
||||||
} from '@/queries/overview'
|
} from '@/queries/overview'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/dashboard')({
|
export const Route = createFileRoute('/_auth/dashboard')({
|
||||||
@@ -50,7 +42,6 @@ function DashboardComponent() {
|
|||||||
|
|
||||||
const results = useQueries({
|
const results = useQueries({
|
||||||
queries: [
|
queries: [
|
||||||
overviewHealthQueryOptions(),
|
|
||||||
overviewModulesQueryOptions(),
|
overviewModulesQueryOptions(),
|
||||||
overviewPeersQueryOptions(),
|
overviewPeersQueryOptions(),
|
||||||
overviewSpeakersQueryOptions(),
|
overviewSpeakersQueryOptions(),
|
||||||
@@ -59,7 +50,7 @@ function DashboardComponent() {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
const [healthQ, modulesQ, peersQ, speakersQ, revisionsQ, jobsQ] = results
|
const [modulesQ, peersQ, speakersQ, revisionsQ, jobsQ] = results
|
||||||
const initialLoading =
|
const initialLoading =
|
||||||
modulesQ.isLoading || peersQ.isLoading || speakersQ.isLoading || revisionsQ.isLoading || jobsQ.isLoading
|
modulesQ.isLoading || peersQ.isLoading || speakersQ.isLoading || revisionsQ.isLoading || jobsQ.isLoading
|
||||||
const refreshing = results.some((r) => r.isFetching && !r.isLoading)
|
const refreshing = results.some((r) => r.isFetching && !r.isLoading)
|
||||||
@@ -78,60 +69,13 @@ function DashboardComponent() {
|
|||||||
const speakers = speakersQ.data?.items ?? []
|
const speakers = speakersQ.data?.items ?? []
|
||||||
const revisions = revisionsQ.data?.items ?? []
|
const revisions = revisionsQ.data?.items ?? []
|
||||||
const jobs = jobsQ.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 nameById = moduleNameById(modules)
|
||||||
|
|
||||||
const countBadge = (n: number, hasMore: boolean, suffix: string) => (hasMore ? '200+' : suffix)
|
const activityLoading =
|
||||||
|
refreshing && jobs.length === 0 && revisions.length === 0
|
||||||
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'),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-5">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Обзор"
|
title="Обзор"
|
||||||
description={
|
description={
|
||||||
@@ -147,254 +91,55 @@ function DashboardComponent() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Alert className="border-info/30 bg-info/5">
|
{initialLoading ? (
|
||||||
<Info className="text-info" />
|
<AnalyticsDashboardSkeleton />
|
||||||
<AlertTitle>Панель управления EvoBGP</AlertTitle>
|
) : (
|
||||||
<AlertDescription>
|
<div className="grid gap-4 lg:grid-cols-3 lg:items-start">
|
||||||
Сводка по модулям, сети и фоновым задачам. BGP и ноды — «Сеть», префиксы — «Модули»,
|
<DashboardPlatformCard
|
||||||
деплой — «Операции», здоровье API — «Мониторинг».
|
modules={modules}
|
||||||
</AlertDescription>
|
peers={peers}
|
||||||
</Alert>
|
speakers={speakers}
|
||||||
|
jobs={jobs}
|
||||||
|
revisions={revisions}
|
||||||
|
/>
|
||||||
|
<DashboardNetworkCapacityCard peers={peers} speakers={speakers} jobs={jobs} />
|
||||||
|
<DashboardOperationsFlowCard jobs={jobs} modules={modules} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<HealthAlert
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
loading={healthQ.isLoading}
|
<DataGridCard
|
||||||
ok={healthQ.data === true}
|
title="Недавние задачи"
|
||||||
loadError={modulesQ.isError || peersQ.isError ? 'Некоторые данные не загружены' : null}
|
description="Последние фоновые операции"
|
||||||
/>
|
className="h-full"
|
||||||
|
>
|
||||||
|
{activityLoading ? (
|
||||||
|
<Skeleton className="m-3 h-24 w-auto" />
|
||||||
|
) : (
|
||||||
|
<DashboardRecentJobsGrid jobs={jobs.slice(0, 10)} nameById={nameById} isLoading={refreshing} />
|
||||||
|
)}
|
||||||
|
</DataGridCard>
|
||||||
|
|
||||||
{initialLoading ? <SectionCardsSkeleton count={5} /> : <SectionCards items={items} />}
|
<DataGridCard
|
||||||
|
title="Последние ревизии"
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
description="История конфигураций"
|
||||||
<RecentJobsCard jobs={jobs} nameById={nameById} loading={refreshing} />
|
className="h-full"
|
||||||
<RecentRevisionsCard revisions={revisions} loading={refreshing} />
|
>
|
||||||
<NetworkStatusCard peers={peers} speakers={speakers} loading={refreshing} />
|
{activityLoading ? (
|
||||||
|
<Skeleton className="m-3 h-24 w-auto" />
|
||||||
|
) : (
|
||||||
|
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
|
||||||
|
)}
|
||||||
|
</DataGridCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card className="gap-0">
|
||||||
<CardHeader className="border-b py-3">
|
<CardHeader className="border-b py-3">
|
||||||
<CardTitle className="text-base">Быстрые действия</CardTitle>
|
<CardTitle className="text-base">Быстрые действия</CardTitle>
|
||||||
<CardDescription>Частые переходы к настройке и деплою</CardDescription>
|
<CardDescription>Частые переходы к настройке и деплою</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-wrap gap-2 p-4">
|
<DashboardQuickActions />
|
||||||
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
Создать модуль
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/directories')}>
|
|
||||||
<Tags className="size-4" />
|
|
||||||
Добавить community
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/network?tab=overview')}>
|
|
||||||
<Network className="size-4" />
|
|
||||||
Сеть
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/network?tab=peers')}>
|
|
||||||
<Share2 className="size-4" />
|
|
||||||
Добавить пира
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/operations')}>
|
|
||||||
<Play className="size-4" />
|
|
||||||
Деплой (Apply)
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/monitoring')}>
|
|
||||||
<Gauge className="size-4" />
|
|
||||||
Мониторинг
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function HealthAlert({
|
|
||||||
loading,
|
|
||||||
ok,
|
|
||||||
loadError,
|
|
||||||
}: {
|
|
||||||
loading: boolean
|
|
||||||
ok: boolean | undefined
|
|
||||||
loadError: string | null
|
|
||||||
}) {
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<Alert>
|
|
||||||
<Skeleton className="size-5 rounded-full" />
|
|
||||||
<AlertTitle>Проверка API…</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Запрос к <code className="text-xs">/v1/health</code>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (ok && !loadError) {
|
|
||||||
return (
|
|
||||||
<Alert className="border-success/30 bg-success/5">
|
|
||||||
<CheckCircle className="text-success" />
|
|
||||||
<AlertTitle>API работает</AlertTitle>
|
|
||||||
<AlertDescription>Сервер отвечает на запросы health-check.</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (ok && loadError) {
|
|
||||||
return (
|
|
||||||
<Alert className="border-warning/30 bg-warning/5">
|
|
||||||
<Info className="text-warning" />
|
|
||||||
<AlertTitle>API доступен, данные не загружены</AlertTitle>
|
|
||||||
<AlertDescription>{loadError}. Проверьте Bearer-токен в «Настройках».</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Alert variant="destructive" className="border-destructive/30 bg-destructive/5">
|
|
||||||
<XCircle className="text-destructive" />
|
|
||||||
<AlertTitle>API недоступен</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Не удалось получить ответ от сервера. Проверьте, что API запущен (порт 8080) и в dev работает
|
|
||||||
прокси Vite.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RecentJobsCard({
|
|
||||||
jobs,
|
|
||||||
nameById,
|
|
||||||
loading,
|
|
||||||
}: {
|
|
||||||
jobs: import('@/types/api').JobRow[]
|
|
||||||
nameById: Map<string, string>
|
|
||||||
loading: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="border-b py-3">
|
|
||||||
<CardTitle className="text-base">Недавние задачи</CardTitle>
|
|
||||||
<CardDescription>Последние фоновые операции</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-3">
|
|
||||||
{loading && jobs.length === 0 ? (
|
|
||||||
<Skeleton className="h-24 w-full" />
|
|
||||||
) : jobs.length === 0 ? (
|
|
||||||
<p className="px-2 py-6 text-center text-sm text-muted-foreground">Нет задач</p>
|
|
||||||
) : (
|
|
||||||
<ul className="flex flex-col gap-1">
|
|
||||||
{jobs.slice(0, 8).map((j) => (
|
|
||||||
<li
|
|
||||||
key={j.job_id}
|
|
||||||
className="flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/40"
|
|
||||||
>
|
|
||||||
<span className="flex min-w-0 flex-col gap-0.5">
|
|
||||||
<span className="truncate font-mono text-xs text-muted-foreground">{j.kind}</span>
|
|
||||||
<span className="truncate text-xs">
|
|
||||||
{j.meta?.module_id ? nameById.get(String(j.meta.module_id)) ?? '' : ''}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
j.status === 'succeeded'
|
|
||||||
? 'text-xs text-success'
|
|
||||||
: j.status === 'failed'
|
|
||||||
? 'text-xs text-destructive'
|
|
||||||
: 'text-xs text-muted-foreground'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{j.status}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RecentRevisionsCard({
|
|
||||||
revisions,
|
|
||||||
loading,
|
|
||||||
}: {
|
|
||||||
revisions: import('@/types/api').RevisionRow[]
|
|
||||||
loading: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="border-b py-3">
|
|
||||||
<CardTitle className="text-base">Последние ревизии</CardTitle>
|
|
||||||
<CardDescription>История конфигураций</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-3">
|
|
||||||
{loading && revisions.length === 0 ? (
|
|
||||||
<Skeleton className="h-24 w-full" />
|
|
||||||
) : revisions.length === 0 ? (
|
|
||||||
<p className="px-2 py-6 text-center text-sm text-muted-foreground">Нет ревизий</p>
|
|
||||||
) : (
|
|
||||||
<ul className="flex flex-col gap-1">
|
|
||||||
{revisions.slice(0, 8).map((r) => (
|
|
||||||
<li
|
|
||||||
key={r.id}
|
|
||||||
className="flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/40"
|
|
||||||
>
|
|
||||||
<span className="truncate font-mono text-xs text-muted-foreground">{r.id.slice(0, 10)}…</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{new Date(r.created_at).toLocaleString('ru-RU')}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NetworkStatusCard({
|
|
||||||
peers,
|
|
||||||
speakers,
|
|
||||||
loading,
|
|
||||||
}: {
|
|
||||||
peers: import('@/types/api').PeerRow[]
|
|
||||||
speakers: import('@/types/api').SpeakerRow[]
|
|
||||||
loading: boolean
|
|
||||||
}) {
|
|
||||||
const m = aggregateNetworkMetrics(peers, speakers)
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="border-b py-3">
|
|
||||||
<CardTitle className="text-base">Состояние сети</CardTitle>
|
|
||||||
<CardDescription>BGP-сессии и спикеры</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-3">
|
|
||||||
{loading && peers.length === 0 && speakers.length === 0 ? (
|
|
||||||
<Skeleton className="h-24 w-full" />
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2 px-1 py-1 text-sm">
|
|
||||||
<Row label="Пиры Established" value={`${m.peersEstablished} / ${m.peersEnabled}`} />
|
|
||||||
<Row label="Спикеры online" value={`${m.speakersOnline} / ${m.speakersTotal}`} />
|
|
||||||
{m.peersMismatch > 0 ? (
|
|
||||||
<Row label="Mismatches" value={String(m.peersMismatch)} variant="warning" />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Row({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
variant = 'default',
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
variant?: 'default' | 'warning'
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-muted-foreground">{label}</span>
|
|
||||||
<span className={variant === 'warning' ? 'font-medium text-warning-foreground' : 'font-medium tabular-nums'}>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { BookText, Globe, Info, RefreshCw, Tags } from 'lucide-react'
|
import { BookText, Globe, RefreshCw, Tags } from 'lucide-react'
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
|
||||||
import { Badge } from '@evobgp/ui/components/badge'
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
import { DataGridCard } from '@/components/data-grid-shell'
|
||||||
import {
|
import { DirectoriesCommunitiesGrid } from '@/components/directories/directories-communities-grid'
|
||||||
Table,
|
import { DirectoriesDohGrid } from '@/components/directories/directories-doh-grid'
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@evobgp/ui/components/table'
|
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||||
@@ -76,111 +67,62 @@ function DirectoriesComponent() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Alert className="border-info/30 bg-info/5">
|
|
||||||
<Info className="text-info" />
|
|
||||||
<AlertTitle>О справочниках</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Сообщества BGP используются в AS- и CDN-модулях для тегирования префиксов. DoH-профили — в
|
|
||||||
доменных модулях для DNS-over-HTTPS резолвинга.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
|
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
|
||||||
|
|
||||||
<Tabs defaultValue="communities">
|
<BadgeTabs
|
||||||
<TabsList>
|
defaultValue="communities"
|
||||||
<TabsTrigger value="communities">Сообщества BGP</TabsTrigger>
|
items={[
|
||||||
<TabsTrigger value="doh">DoH профили</TabsTrigger>
|
{ value: 'communities', label: 'Сообщества BGP', count: communities.length },
|
||||||
</TabsList>
|
{ value: 'doh', label: 'DoH профили', count: dohProfiles.length, badgeVariant: 'info-light' },
|
||||||
|
]}
|
||||||
<TabsContent value="communities" className="mt-4">
|
>
|
||||||
<Card>
|
<TabsContent value="communities" className="mt-0">
|
||||||
<CardHeader className="border-b py-3">
|
<DataGridCard
|
||||||
<CardTitle className="text-base">Сообщества BGP</CardTitle>
|
title="Сообщества BGP"
|
||||||
<CardDescription>Теги для префиксов в фильтрах BIRD</CardDescription>
|
description="Теги для префиксов в фильтрах BIRD"
|
||||||
</CardHeader>
|
>
|
||||||
<CardContent className="p-0">
|
<QueryState
|
||||||
<QueryState
|
data={communities}
|
||||||
data={communities}
|
isLoading={communitiesQ.isLoading}
|
||||||
isLoading={communitiesQ.isLoading}
|
isError={communitiesQ.isError}
|
||||||
isError={communitiesQ.isError}
|
error={communitiesQ.error}
|
||||||
error={communitiesQ.error}
|
empty={communities.length === 0}
|
||||||
empty={communities.length === 0}
|
emptyTitle="Нет сообществ"
|
||||||
emptyTitle="Нет сообществ"
|
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||||
skeleton={<TableSkeleton rows={4} cols={3} />}
|
onRetry={() => communitiesQ.refetch()}
|
||||||
onRetry={() => communitiesQ.refetch()}
|
>
|
||||||
>
|
{(items) => (
|
||||||
{(items) => (
|
<DirectoriesCommunitiesGrid
|
||||||
<Table>
|
items={items}
|
||||||
<TableHeader>
|
isLoading={communitiesQ.isFetching && !communitiesQ.isLoading}
|
||||||
<TableRow>
|
/>
|
||||||
<TableHead>Название</TableHead>
|
)}
|
||||||
<TableHead>Значение</TableHead>
|
</QueryState>
|
||||||
<TableHead>Тип</TableHead>
|
</DataGridCard>
|
||||||
</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>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="doh" className="mt-4">
|
<TabsContent value="doh" className="mt-0">
|
||||||
<Card>
|
<DataGridCard title="DoH профили" description="Резолверы DNS-over-HTTPS для доменных модулей">
|
||||||
<CardHeader className="border-b py-3">
|
<QueryState
|
||||||
<CardTitle className="text-base">DoH профили</CardTitle>
|
data={dohProfiles}
|
||||||
<CardDescription>Резолверы DNS-over-HTTPS для доменных модулей</CardDescription>
|
isLoading={dohQ.isLoading}
|
||||||
</CardHeader>
|
isError={dohQ.isError}
|
||||||
<CardContent className="p-0">
|
error={dohQ.error}
|
||||||
<QueryState
|
empty={dohProfiles.length === 0}
|
||||||
data={dohProfiles}
|
emptyTitle="Нет DoH профилей"
|
||||||
isLoading={dohQ.isLoading}
|
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||||
isError={dohQ.isError}
|
onRetry={() => dohQ.refetch()}
|
||||||
error={dohQ.error}
|
>
|
||||||
empty={dohProfiles.length === 0}
|
{(items) => (
|
||||||
emptyTitle="Нет DoH профилей"
|
<DirectoriesDohGrid
|
||||||
skeleton={<TableSkeleton rows={4} cols={3} />}
|
items={items}
|
||||||
onRetry={() => dohQ.refetch()}
|
isLoading={dohQ.isFetching && !dohQ.isLoading}
|
||||||
>
|
/>
|
||||||
{(items) => (
|
)}
|
||||||
<Table>
|
</QueryState>
|
||||||
<TableHeader>
|
</DataGridCard>
|
||||||
<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>
|
</TabsContent>
|
||||||
</Tabs>
|
</BadgeTabs>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Copy, Plus, RefreshCw, Shield } from 'lucide-react'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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,24 +1,22 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { ArrowLeft, RefreshCw } from 'lucide-react'
|
import { ArrowLeft, RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@evobgp/ui/components/table'
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { moduleDetailQueryOptions, moduleEntriesQueryOptions } from '@/queries/modules'
|
import { ModuleEntriesSection } from '@/components/modules/module-entries-section'
|
||||||
|
import { ModuleKpiCards } from '@/components/modules/module-kpi-cards'
|
||||||
|
import { moduleTypeRu } from '@/lib/ui-labels'
|
||||||
|
import {
|
||||||
|
directoriesCommunitiesQueryOptions,
|
||||||
|
directoriesDohQueryOptions,
|
||||||
|
} from '@/queries/directories'
|
||||||
|
import { moduleDetailQueryOptions, moduleEntriesQueryOptions, modulesKeys } from '@/queries/modules'
|
||||||
|
import type { AsEntry } from '@/types/api'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/modules/$moduleId')({
|
export const Route = createFileRoute('/_auth/modules/$moduleId')({
|
||||||
component: ModuleDetailComponent,
|
component: ModuleDetailComponent,
|
||||||
@@ -26,19 +24,45 @@ export const Route = createFileRoute('/_auth/modules/$moduleId')({
|
|||||||
|
|
||||||
function ModuleDetailComponent() {
|
function ModuleDetailComponent() {
|
||||||
const { moduleId } = Route.useParams()
|
const { moduleId } = Route.useParams()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const detail = useQuery(moduleDetailQueryOptions(moduleId))
|
const detail = useQuery(moduleDetailQueryOptions(moduleId))
|
||||||
const mod = detail.data
|
const mod = detail.data
|
||||||
|
|
||||||
|
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
|
||||||
|
const dohQ = useQuery(directoriesDohQueryOptions())
|
||||||
|
|
||||||
const entriesQuery = useQuery({
|
const entriesQuery = useQuery({
|
||||||
...moduleEntriesQueryOptions(moduleId, mod?.type ?? 'DOMAINS'),
|
...moduleEntriesQueryOptions(moduleId, mod?.type ?? 'DOMAINS'),
|
||||||
enabled: !!mod,
|
enabled: !!mod,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const asEntriesQ = useQuery({
|
||||||
|
...moduleEntriesQueryOptions(moduleId, 'AS_PREFIXES'),
|
||||||
|
enabled: mod?.type === 'AS_PREFIXES',
|
||||||
|
select: (data) => data.items as unknown as AsEntry[],
|
||||||
|
})
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
await Promise.all([detail.refetch(), entriesQuery.refetch(), communitiesQ.refetch(), dohQ.refetch()])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onEntriesChanged() {
|
||||||
|
await entriesQuery.refetch()
|
||||||
|
if (mod?.type === 'AS_PREFIXES') {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: modulesKeys.asEntries(moduleId) })
|
||||||
|
}
|
||||||
|
await detail.refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const communities = communitiesQ.data?.items ?? []
|
||||||
|
const dohProfiles = dohQ.data?.items ?? []
|
||||||
|
const asEntries = mod?.type === 'AS_PREFIXES' ? (asEntriesQ.data ?? []) : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={mod?.name ?? moduleId}
|
title={mod?.name ?? moduleId}
|
||||||
description={mod ? `Тип: ${mod.type}` : 'Загрузка модуля…'}
|
description={mod ? `Тип: ${moduleTypeRu(mod.type)} (${mod.type})` : 'Загрузка модуля…'}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
|
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
|
||||||
@@ -48,10 +72,7 @@ function ModuleDetailComponent() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => void refreshAll()}
|
||||||
void detail.refetch()
|
|
||||||
void entriesQuery.refetch()
|
|
||||||
}}
|
|
||||||
disabled={detail.isFetching}
|
disabled={detail.isFetching}
|
||||||
>
|
>
|
||||||
<RefreshCw className={detail.isFetching ? 'animate-spin' : ''} />
|
<RefreshCw className={detail.isFetching ? 'animate-spin' : ''} />
|
||||||
@@ -70,133 +91,37 @@ function ModuleDetailComponent() {
|
|||||||
onRetry={() => detail.refetch()}
|
onRetry={() => detail.refetch()}
|
||||||
>
|
>
|
||||||
{(m) => (
|
{(m) => (
|
||||||
<>
|
<div className="flex flex-col gap-6">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Card>
|
{m.enabled ? (
|
||||||
<CardHeader className="border-b py-3">
|
<StatusBadge status="active" label="включён" />
|
||||||
<CardTitle className="text-base">Параметры</CardTitle>
|
) : (
|
||||||
</CardHeader>
|
<StatusBadge status="paused" label="выключен" />
|
||||||
<CardContent className="grid grid-cols-2 gap-3 p-4 text-sm">
|
)}
|
||||||
<Field label="ID" value={<code className="font-mono text-xs">{m.id}</code>} />
|
|
||||||
<Field label="Тип" value={<Badge variant="outline">{m.type}</Badge>} />
|
|
||||||
<Field label="Приоритет" value={<span className="font-mono">{m.priority}</span>} />
|
|
||||||
<Field
|
|
||||||
label="Состояние"
|
|
||||||
value={
|
|
||||||
m.enabled ? (
|
|
||||||
<StatusBadge status="active" label="включён" />
|
|
||||||
) : (
|
|
||||||
<StatusBadge status="paused" label="выключен" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Интервал"
|
|
||||||
value={
|
|
||||||
m.refresh_interval_sec ? `${m.refresh_interval_sec}s` : '—'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Cron"
|
|
||||||
value={m.cron_expr ? <code className="font-mono text-xs">{m.cron_expr}</code> : '—'}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Последний рефреш"
|
|
||||||
value={
|
|
||||||
m.last_refreshed_at
|
|
||||||
? new Date(m.last_refreshed_at).toLocaleString('ru-RU')
|
|
||||||
: '—'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="border-b py-3">
|
|
||||||
<CardTitle className="text-base">Маршрутные списки</CardTitle>
|
|
||||||
<CardDescription>Источник префиксов для модуля</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<QueryState
|
|
||||||
data={entriesQuery.data?.items}
|
|
||||||
isLoading={entriesQuery.isLoading}
|
|
||||||
isError={entriesQuery.isError}
|
|
||||||
error={entriesQuery.error}
|
|
||||||
empty={(entriesQuery.data?.items?.length ?? 0) === 0}
|
|
||||||
emptyTitle="Записей нет"
|
|
||||||
emptyDescription="Добавьте записи через API или создание ревизии."
|
|
||||||
skeleton={<TableSkeleton rows={5} cols={2} />}
|
|
||||||
onRetry={() => entriesQuery.refetch()}
|
|
||||||
>
|
|
||||||
{(items) => <EntriesTable items={items} moduleType={m.type} />}
|
|
||||||
</QueryState>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
|
<ModuleKpiCards
|
||||||
|
mod={m}
|
||||||
|
communities={communities}
|
||||||
|
dohProfiles={dohProfiles}
|
||||||
|
asEntries={asEntries}
|
||||||
|
loading={detail.isLoading || communitiesQ.isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ModuleEntriesSection
|
||||||
|
moduleId={moduleId}
|
||||||
|
mod={m}
|
||||||
|
items={entriesQuery.data?.items ?? []}
|
||||||
|
communities={communities}
|
||||||
|
isLoading={entriesQuery.isLoading}
|
||||||
|
isError={entriesQuery.isError}
|
||||||
|
error={entriesQuery.error}
|
||||||
|
onRetry={() => entriesQuery.refetch()}
|
||||||
|
onChanged={onEntriesChanged}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</QueryState>
|
</QueryState>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({ label, value }: { label: string; value: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<span className="text-xs text-muted-foreground">{label}</span>
|
|
||||||
<span>{value}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EntriesTable({
|
|
||||||
items,
|
|
||||||
moduleType,
|
|
||||||
}: {
|
|
||||||
items: Record<string, unknown>[]
|
|
||||||
moduleType: string
|
|
||||||
}) {
|
|
||||||
const primary = ENTRY_PRIMARY_KEY[moduleType as keyof typeof ENTRY_PRIMARY_KEY] ?? 'id'
|
|
||||||
const secondary = ENTRY_SECONDARY_KEY[moduleType as keyof typeof ENTRY_SECONDARY_KEY] ?? null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>{primary}</TableHead>
|
|
||||||
{secondary ? <TableHead>{secondary}</TableHead> : null}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{items.map((item, idx) => {
|
|
||||||
const id = String(item.id ?? idx)
|
|
||||||
const primaryVal = String(item[primary] ?? '—')
|
|
||||||
return (
|
|
||||||
<TableRow key={id}>
|
|
||||||
<TableCell className="font-mono text-sm">{primaryVal}</TableCell>
|
|
||||||
{secondary ? (
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
||||||
{String(item[secondary] ?? '—')}
|
|
||||||
</TableCell>
|
|
||||||
) : null}
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ENTRY_PRIMARY_KEY = {
|
|
||||||
DOMAINS: 'fqdn',
|
|
||||||
AS_PREFIXES: 'asn',
|
|
||||||
CDN_CIDRS: 'url',
|
|
||||||
IP_RANGES: 'prefix',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const ENTRY_SECONDARY_KEY = {
|
|
||||||
DOMAINS: 'community_id',
|
|
||||||
AS_PREFIXES: 'community_id',
|
|
||||||
CDN_CIDRS: 'community_id',
|
|
||||||
IP_RANGES: 'community_id',
|
|
||||||
} as const
|
|
||||||
|
|||||||
@@ -1,25 +1,15 @@
|
|||||||
import { Link, createFileRoute } from '@tanstack/react-router'
|
import { Link, createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
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 { 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 { PageHeader } from '@/components/page-header'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
import { TruncatedText } from '@/components/truncated-text'
|
|
||||||
import { modulesListQueryOptions } from '@/queries/modules'
|
import { modulesListQueryOptions } from '@/queries/modules'
|
||||||
import type { ModuleRow } from '@/types/api'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/modules/')({
|
export const Route = createFileRoute('/_auth/modules/')({
|
||||||
component: ModulesListComponent,
|
component: ModulesListComponent,
|
||||||
@@ -48,76 +38,34 @@ function ModulesListComponent() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card>
|
<DataGridCard
|
||||||
<CardHeader className="flex flex-row items-center justify-between border-b py-3">
|
title="Все модули"
|
||||||
<CardTitle className="text-base">Все модули</CardTitle>
|
actions={
|
||||||
<Button size="sm" render={<Link to="/modules/new" />}>
|
<Button size="sm" render={<Link to="/modules/new" />}>
|
||||||
<Plus />
|
<Plus />
|
||||||
Создать
|
Создать
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
}
|
||||||
<CardContent className="p-0">
|
>
|
||||||
<QueryState
|
<QueryState
|
||||||
data={query.data?.items}
|
data={query.data?.items}
|
||||||
isLoading={query.isLoading}
|
isLoading={query.isLoading}
|
||||||
isError={query.isError}
|
isError={query.isError}
|
||||||
error={query.error}
|
error={query.error}
|
||||||
empty={query.data?.items?.length === 0}
|
empty={query.data?.items?.length === 0}
|
||||||
emptyTitle="Нет модулей"
|
emptyTitle="Нет модулей"
|
||||||
emptyDescription="Создайте первый модуль (AS, CDN, домены, IP)."
|
emptyDescription="Создайте первый модуль (AS, CDN, домены, IP)."
|
||||||
skeleton={<TableSkeleton rows={6} cols={5} />}
|
skeleton={<TableSkeleton rows={6} cols={5} />}
|
||||||
onRetry={() => query.refetch()}
|
onRetry={() => query.refetch()}
|
||||||
>
|
>
|
||||||
{(items) => <ModulesTable items={items} />}
|
{(items) => (
|
||||||
</QueryState>
|
<ModulesListGrid
|
||||||
</CardContent>
|
items={items}
|
||||||
</Card>
|
isLoading={query.isFetching && !query.isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</DataGridCard>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModulesTable({ items }: { items: ModuleRow[] }) {
|
|
||||||
return (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Название</TableHead>
|
|
||||||
<TableHead>Тип</TableHead>
|
|
||||||
<TableHead>Приоритет</TableHead>
|
|
||||||
<TableHead>Состояние</TableHead>
|
|
||||||
<TableHead>Обновлено</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{items.map((m) => (
|
|
||||||
<TableRow
|
|
||||||
key={m.id}
|
|
||||||
className="cursor-pointer hover:bg-muted/40"
|
|
||||||
onClick={() => (window.location.href = `/modules/${m.id}`)}
|
|
||||||
>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Boxes className="size-4 text-muted-foreground" />
|
|
||||||
<TruncatedText className="max-w-[280px]">{m.name}</TruncatedText>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline">{m.type}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-sm tabular-nums">{m.priority}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{m.enabled ? (
|
|
||||||
<Badge variant="success">включён</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="secondary">выключен</Badge>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
|
||||||
{m.last_refreshed_at ? new Date(m.last_refreshed_at).toLocaleString('ru-RU') : '—'}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,36 +1,31 @@
|
|||||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Activity, AlertTriangle, Bird, Database, Gauge, HardDrive, HeartPulse, Info, ListTodo, RefreshCw, ShieldCheck } from 'lucide-react'
|
import { Activity, AlertTriangle, Bird, RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
|
||||||
import { Badge } from '@evobgp/ui/components/badge'
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||||
import { Separator } from '@evobgp/ui/components/separator'
|
import { Separator } from '@evobgp/ui/components/separator'
|
||||||
import { 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 {
|
import {
|
||||||
Table,
|
DashboardOperationsFlowCard,
|
||||||
TableBody,
|
MonitoringHealthCard,
|
||||||
TableCell,
|
} from '@/components/analytics'
|
||||||
TableHead,
|
import { MonitoringReadyGrid } from '@/components/monitoring/monitoring-ready-grid'
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@evobgp/ui/components/table'
|
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
import { AnalyticsDashboardSkeleton } from '@/components/skeletons'
|
||||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
monitoringHealthQueryOptions,
|
monitoringHealthQueryOptions,
|
||||||
monitoringReadyQueryOptions,
|
monitoringReadyQueryOptions,
|
||||||
monitoringVersionQueryOptions,
|
monitoringVersionQueryOptions,
|
||||||
type ReadyStatus,
|
|
||||||
type VersionInfo,
|
type VersionInfo,
|
||||||
} from '@/queries/monitoring'
|
} from '@/queries/monitoring'
|
||||||
import { networkBirdQueryOptions } from '@/queries/network'
|
import { networkBirdQueryOptions } from '@/queries/network'
|
||||||
import { operationsJobsQueryOptions } from '@/queries/operations'
|
import { operationsJobsQueryOptions } from '@/queries/operations'
|
||||||
|
import { overviewModulesQueryOptions } from '@/queries/overview'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/monitoring')({
|
export const Route = createFileRoute('/_auth/monitoring')({
|
||||||
component: MonitoringComponent,
|
component: MonitoringComponent,
|
||||||
@@ -44,58 +39,31 @@ export const Route = createFileRoute('/_auth/monitoring')({
|
|||||||
|
|
||||||
function MonitoringComponent() {
|
function MonitoringComponent() {
|
||||||
const search = useSearch({ from: '/_auth/monitoring' })
|
const search = useSearch({ from: '/_auth/monitoring' })
|
||||||
|
const navigate = Route.useNavigate()
|
||||||
const healthQ = useQuery(monitoringHealthQueryOptions())
|
const healthQ = useQuery(monitoringHealthQueryOptions())
|
||||||
const readyQ = useQuery(monitoringReadyQueryOptions())
|
const readyQ = useQuery(monitoringReadyQueryOptions())
|
||||||
const versionQ = useQuery(monitoringVersionQueryOptions())
|
const versionQ = useQuery(monitoringVersionQueryOptions())
|
||||||
const birdQ = useQuery(networkBirdQueryOptions())
|
const birdQ = useQuery(networkBirdQueryOptions())
|
||||||
const jobsQ = useQuery(operationsJobsQueryOptions())
|
const jobsQ = useQuery(operationsJobsQueryOptions())
|
||||||
|
const modulesQ = useQuery(overviewModulesQueryOptions())
|
||||||
|
|
||||||
const refreshing =
|
const refreshing =
|
||||||
healthQ.isFetching ||
|
healthQ.isFetching ||
|
||||||
readyQ.isFetching ||
|
readyQ.isFetching ||
|
||||||
versionQ.isFetching ||
|
versionQ.isFetching ||
|
||||||
birdQ.isFetching ||
|
birdQ.isFetching ||
|
||||||
jobsQ.isFetching
|
jobsQ.isFetching ||
|
||||||
|
modulesQ.isFetching
|
||||||
|
|
||||||
const jobs = jobsQ.data?.items ?? []
|
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) =>
|
const failed = jobs.filter((j) =>
|
||||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||||
).length
|
).length
|
||||||
|
|
||||||
const versionText = formatVersion(versionQ.data)
|
const versionText = formatVersion(versionQ.data)
|
||||||
|
const analyticsLoading =
|
||||||
const items: SectionCardItem[] = [
|
(healthQ.isLoading || readyQ.isLoading || jobsQ.isLoading) && jobs.length === 0
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function refetchAll() {
|
function refetchAll() {
|
||||||
void healthQ.refetch()
|
void healthQ.refetch()
|
||||||
@@ -103,6 +71,7 @@ function MonitoringComponent() {
|
|||||||
void versionQ.refetch()
|
void versionQ.refetch()
|
||||||
void birdQ.refetch()
|
void birdQ.refetch()
|
||||||
void jobsQ.refetch()
|
void jobsQ.refetch()
|
||||||
|
void modulesQ.refetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
const failedJobs = jobs
|
const failedJobs = jobs
|
||||||
@@ -113,7 +82,13 @@ function MonitoringComponent() {
|
|||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Мониторинг"
|
title="Мониторинг"
|
||||||
description="Состояние API, BGP и задач для диагностики инцидентов"
|
description={`Состояние API, BGP и задач для диагностики инцидентов${
|
||||||
|
versionText !== '—'
|
||||||
|
? ` · версия ${versionText}${
|
||||||
|
versionQ.data?.git_sha ? ` (${versionQ.data.git_sha.slice(0, 8)})` : ''
|
||||||
|
}`
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
actions={
|
actions={
|
||||||
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||||
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
|
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
|
||||||
@@ -122,35 +97,47 @@ function MonitoringComponent() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs defaultValue={search.tab}>
|
<BadgeTabs
|
||||||
<TabsList>
|
value={search.tab}
|
||||||
<TabsTrigger value="system">Система</TabsTrigger>
|
onValueChange={(tab) =>
|
||||||
<TabsTrigger value="postgres">PostgreSQL</TabsTrigger>
|
navigate({ search: { tab: tab as 'system' | 'postgres' | 'runtime-logs' } })
|
||||||
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger>
|
}
|
||||||
</TabsList>
|
items={[
|
||||||
|
{ value: 'system', label: 'Система' },
|
||||||
<TabsContent value="system" className="mt-4 flex flex-col gap-6">
|
{ value: 'postgres', label: 'PostgreSQL' },
|
||||||
{refreshing ? <SectionCardsSkeleton count={4} /> : <SectionCards items={items} />}
|
{ value: 'runtime-logs', label: 'Файловые логи' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TabsContent value="system" className="mt-0 flex flex-col gap-6">
|
||||||
|
{analyticsLoading ? (
|
||||||
|
<AnalyticsDashboardSkeleton />
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<MonitoringHealthCard
|
||||||
|
healthOk={healthQ.data?.ok ?? false}
|
||||||
|
ready={readyQ.data}
|
||||||
|
loading={healthQ.isLoading || readyQ.isLoading}
|
||||||
|
/>
|
||||||
|
<DashboardOperationsFlowCard jobs={jobs} modules={modules} loading={jobsQ.isLoading} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<Card>
|
<DataGridCard
|
||||||
<CardHeader>
|
title="Доступность и готовность"
|
||||||
<CardTitle className="text-base">Доступность и готовность</CardTitle>
|
description="GET /v1/health · GET /v1/ready"
|
||||||
<CardDescription>GET /v1/health · GET /v1/ready</CardDescription>
|
>
|
||||||
</CardHeader>
|
<QueryState
|
||||||
<CardContent className="space-y-4">
|
data={readyQ.data}
|
||||||
<QueryState
|
isLoading={readyQ.isLoading}
|
||||||
data={readyQ.data}
|
isError={readyQ.isError}
|
||||||
isLoading={readyQ.isLoading}
|
error={readyQ.error}
|
||||||
isError={readyQ.isError}
|
skeleton={<div className="h-40" />}
|
||||||
error={readyQ.error}
|
onRetry={() => readyQ.refetch()}
|
||||||
skeleton={<div className="h-40" />}
|
>
|
||||||
onRetry={() => readyQ.refetch()}
|
{(ready) => <MonitoringReadyGrid health={healthQ.data} ready={ready} />}
|
||||||
>
|
</QueryState>
|
||||||
{(ready) => <ReadyTable health={healthQ.data} ready={ready} />}
|
</DataGridCard>
|
||||||
</QueryState>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -186,7 +173,7 @@ function MonitoringComponent() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex flex-wrap gap-4 text-sm">
|
<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
|
<Metric
|
||||||
label="С ошибками"
|
label="С ошибками"
|
||||||
value={failed}
|
value={failed}
|
||||||
@@ -203,7 +190,7 @@ function MonitoringComponent() {
|
|||||||
<li key={job.job_id} className="rounded-lg border px-3 py-2 text-sm">
|
<li key={job.job_id} className="rounded-lg border px-3 py-2 text-sm">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<p className="font-medium">{job.kind}</p>
|
<p className="font-medium">{job.kind}</p>
|
||||||
<Badge variant="destructive">{job.status}</Badge>
|
<StatusBadge status={job.status} />
|
||||||
</div>
|
</div>
|
||||||
{job.error ? (
|
{job.error ? (
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
@@ -231,80 +218,56 @@ function MonitoringComponent() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Короткая шпаргалка для triage</CardDescription>
|
<CardDescription>Короткая шпаргалка для triage</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent>
|
||||||
<Alert>
|
<ul className="space-y-3 text-sm text-muted-foreground">
|
||||||
<HeartPulse className="size-4" />
|
<li>
|
||||||
<AlertTitle>API недоступен</AlertTitle>
|
<span className="font-medium text-foreground">API недоступен.</span> Если{' '}
|
||||||
<AlertDescription>
|
<code className="text-xs">/v1/health</code> возвращает ошибку — проверьте процесс API и
|
||||||
Если <code className="text-xs">/v1/health</code> возвращает ошибку — проверьте процесс
|
его логи.
|
||||||
API и его логи.
|
</li>
|
||||||
</AlertDescription>
|
<li>
|
||||||
</Alert>
|
<span className="font-medium text-foreground">Readiness не «Готов».</span> Сначала{' '}
|
||||||
<Alert>
|
<code className="text-xs">postgres</code>, затем <code className="text-xs">store</code>{' '}
|
||||||
<Database className="size-4" />
|
и <code className="text-xs">jobs</code> в checks.
|
||||||
<AlertTitle>Readiness не «Готов»</AlertTitle>
|
</li>
|
||||||
<AlertDescription>
|
<li>
|
||||||
Сначала <code className="text-xs">postgres</code>, затем{' '}
|
<span className="font-medium text-foreground">Низкий ratio BGP.</span> Проверьте{' '}
|
||||||
<code className="text-xs">store</code> и <code className="text-xs">jobs</code> в checks.
|
<code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
|
||||||
</AlertDescription>
|
</li>
|
||||||
</Alert>
|
<li>
|
||||||
<Alert>
|
<span className="font-medium text-foreground">Ошибки задач.</span> Откройте Операции и
|
||||||
<Bird className="size-4" />
|
проверьте последние неуспешные jobs.
|
||||||
<AlertTitle>Низкий ratio BGP</AlertTitle>
|
</li>
|
||||||
<AlertDescription>
|
</ul>
|
||||||
Проверьте <code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
<Alert>
|
|
||||||
<ListTodo className="size-4" />
|
|
||||||
<AlertTitle>Ошибки задач</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Откройте Операции и проверьте последние неуспешные jobs.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="postgres" className="mt-4">
|
<TabsContent value="postgres" className="mt-0">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">PostgreSQL</CardTitle>
|
<CardTitle className="text-base">PostgreSQL</CardTitle>
|
||||||
<CardDescription>Статус соединения и пул</CardDescription>
|
<CardDescription>
|
||||||
|
Статус соединения и пул. PostgreSQL отображается в readiness-проверке на вкладке «Система»
|
||||||
|
(check <code className="text-xs">postgres</code>).
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
|
||||||
<Alert>
|
|
||||||
<Database className="size-4" />
|
|
||||||
<AlertTitle>Статус готовности</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
PostgreSQL-соединение отображается в readiness-проверке на вкладке «Система» (check{' '}
|
|
||||||
<code className="text-xs">postgres</code>).
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="runtime-logs" className="mt-4">
|
<TabsContent value="runtime-logs" className="mt-0">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Файловые логи</CardTitle>
|
<CardTitle className="text-base">Файловые логи</CardTitle>
|
||||||
<CardDescription>Логи API и pipeline</CardDescription>
|
<CardDescription>
|
||||||
|
Логи API и pipeline настраиваются переменной <code className="text-xs">EVOBGP_LOG_*</code> и
|
||||||
|
управляются в tenant-settings.
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
|
||||||
<Alert>
|
|
||||||
<Info className="size-4" />
|
|
||||||
<AlertTitle>Логи на сервере</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Файловые логи настраиваются переменной <code className="text-xs">EVOBGP_LOG_*</code> и
|
|
||||||
управляются tenant-settings на странице «Настройки BIRD».
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</BadgeTabs>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -323,103 +286,6 @@ function formatVersion(version?: VersionInfo | null): string {
|
|||||||
return version.version ?? version.app ?? '—'
|
return version.version ?? version.app ?? '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OverallInput {
|
|
||||||
health?: { ok?: boolean } | null
|
|
||||||
ready?: ReadyStatus | null
|
|
||||||
jobsFailed: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function overallStatusLabel(input: OverallInput): string {
|
|
||||||
if (!input.health?.ok) return 'Ошибка'
|
|
||||||
if (input.jobsFailed > 0) return 'Внимание'
|
|
||||||
if (input.ready?.status && input.ready.status !== 'ok') return 'Внимание'
|
|
||||||
return 'В норме'
|
|
||||||
}
|
|
||||||
|
|
||||||
function overallHint(input: OverallInput): string {
|
|
||||||
if (!input.health?.ok) return 'API недоступен или возвращает ошибку'
|
|
||||||
if (input.jobsFailed > 0) return `Есть провальные задачи (${input.jobsFailed})`
|
|
||||||
return 'Все системы работают в штатном режиме'
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReadyTable({
|
|
||||||
health,
|
|
||||||
ready,
|
|
||||||
}: {
|
|
||||||
health?: { ok?: boolean; status?: string; error?: string } | null
|
|
||||||
ready: ReadyStatus
|
|
||||||
}) {
|
|
||||||
const checks = ready.checks ?? {}
|
|
||||||
const iconByKey: Record<string, typeof Database> = {
|
|
||||||
postgres: Database,
|
|
||||||
store: HardDrive,
|
|
||||||
jobs: ListTodo,
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="w-[55%]">Проверка</TableHead>
|
|
||||||
<TableHead>Статус</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<HeartPulse className="size-4 shrink-0 text-muted-foreground" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">Liveness</p>
|
|
||||||
<p className="text-xs text-muted-foreground">/v1/health</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={health?.ok ? 'default' : 'destructive'}>
|
|
||||||
{health?.ok ? 'OK' : 'Ошибка'}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ShieldCheck className="size-4 shrink-0 text-muted-foreground" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">Readiness</p>
|
|
||||||
<p className="text-xs text-muted-foreground">/v1/ready</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={ready.status === 'ok' ? 'default' : 'secondary'}>
|
|
||||||
{ready.status ?? '—'}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
{Object.entries(checks).map(([key, value]) => {
|
|
||||||
const ok = typeof value === 'boolean' ? value : value?.ok !== false
|
|
||||||
const Icon = iconByKey[key] ?? ListTodo
|
|
||||||
return (
|
|
||||||
<TableRow key={key}>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">{key}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={ok ? 'default' : 'destructive'}>{ok ? 'OK' : 'Ошибка'}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
|
function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
|
||||||
if (!bird.birdc_configured) {
|
if (!bird.birdc_configured) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,26 +1,21 @@
|
|||||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||||
import { Info, RefreshCw } from 'lucide-react'
|
import { 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 { PageHeader } from '@/components/page-header'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
import { networkBirdQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions } from '@/queries/network'
|
import { networkBirdQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions } from '@/queries/network'
|
||||||
import { aggregateNetworkMetrics } from '@/queries/overview'
|
import { overviewJobsQueryOptions } from '@/queries/overview'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/network')({
|
export const Route = createFileRoute('/_auth/network')({
|
||||||
component: NetworkComponent,
|
component: NetworkComponent,
|
||||||
@@ -33,14 +28,17 @@ export const Route = createFileRoute('/_auth/network')({
|
|||||||
|
|
||||||
function NetworkComponent() {
|
function NetworkComponent() {
|
||||||
const search = useSearch({ from: '/_auth/network' })
|
const search = useSearch({ from: '/_auth/network' })
|
||||||
|
const navigate = Route.useNavigate()
|
||||||
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
|
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
|
||||||
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
|
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
|
||||||
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
|
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
|
||||||
|
const jobsQ = useQuery(overviewJobsQueryOptions())
|
||||||
|
|
||||||
const refreshing = peersQ.isFetching || speakersQ.isFetching
|
const refreshing = peersQ.isFetching || speakersQ.isFetching
|
||||||
const peers = peersQ.data?.items ?? []
|
const peers = peersQ.data?.items ?? []
|
||||||
const speakers = speakersQ.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() {
|
function refetchAll() {
|
||||||
void peersQ.refetch()
|
void peersQ.refetch()
|
||||||
@@ -61,104 +59,78 @@ function NetworkComponent() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Alert className="border-info/30 bg-info/5">
|
<BadgeTabs
|
||||||
<Info className="text-info" />
|
value={search.tab}
|
||||||
<AlertTitle>О сетевой конфигурации</AlertTitle>
|
onValueChange={(tab) =>
|
||||||
<AlertDescription>
|
navigate({
|
||||||
Вкладка «Обзор» — live-статус agent и BGP на CP и репликах. Apply и ревизии — на странице «Операции».
|
search: {
|
||||||
</AlertDescription>
|
tab: tab as 'overview' | 'peers' | 'speakers' | 'control-plane',
|
||||||
</Alert>
|
},
|
||||||
|
})
|
||||||
<Tabs defaultValue={search.tab}>
|
}
|
||||||
<TabsList>
|
items={[
|
||||||
<TabsTrigger value="overview">Обзор</TabsTrigger>
|
{ value: 'overview', label: 'Обзор' },
|
||||||
<TabsTrigger value="peers">Пиры ({peers.length})</TabsTrigger>
|
{ value: 'peers', label: 'Пиры', count: peers.length },
|
||||||
<TabsTrigger value="speakers">Спикеры ({speakers.length})</TabsTrigger>
|
{ value: 'speakers', label: 'Спикеры', count: speakers.length, badgeVariant: 'info-light' },
|
||||||
<TabsTrigger value="control-plane">Control plane</TabsTrigger>
|
{ value: 'control-plane', label: 'Control plane' },
|
||||||
</TabsList>
|
]}
|
||||||
|
>
|
||||||
<TabsContent value="overview" className="mt-4">
|
<TabsContent value="overview" className="mt-0">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<Card>
|
<NetworkOverviewAnalyticsCard
|
||||||
<CardHeader className="border-b py-3">
|
peers={peers}
|
||||||
<CardTitle className="text-base">Сводка сети</CardTitle>
|
speakers={speakers}
|
||||||
</CardHeader>
|
loading={overviewLoading}
|
||||||
<CardContent className="grid grid-cols-2 gap-3 p-4 text-sm">
|
/>
|
||||||
<Field label="Пиры всего" value={String(net.peersTotal)} />
|
<DashboardNetworkCapacityCard
|
||||||
<Field label="Established" value={`${net.peersEstablished} / ${net.peersEnabled}`} />
|
peers={peers}
|
||||||
<Field label="Спикеры всего" value={String(net.speakersTotal)} />
|
speakers={speakers}
|
||||||
<Field label="Online" value={`${net.speakersOnline} / ${net.speakersTotal}`} />
|
jobs={jobs}
|
||||||
{net.peersMismatch > 0 ? (
|
loading={overviewLoading}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
<Card className="mt-4">
|
||||||
|
|
||||||
<TabsContent value="peers" className="mt-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="border-b py-3">
|
<CardHeader className="border-b py-3">
|
||||||
<CardTitle className="text-base">Пиры</CardTitle>
|
<CardTitle className="text-base">BIRD (control plane)</CardTitle>
|
||||||
|
<CardDescription>Статус birdc на хосте API</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-4">
|
||||||
<QueryState
|
<QueryState
|
||||||
data={peers}
|
data={birdQ.data}
|
||||||
isLoading={peersQ.isLoading}
|
isLoading={birdQ.isLoading}
|
||||||
isError={peersQ.isError}
|
isError={birdQ.isError}
|
||||||
error={peersQ.error}
|
error={birdQ.error}
|
||||||
empty={peers.length === 0}
|
skeleton={<TableSkeleton rows={3} cols={2} />}
|
||||||
emptyTitle="Нет пиров"
|
onRetry={() => birdQ.refetch()}
|
||||||
skeleton={<TableSkeleton rows={6} cols={4} />}
|
|
||||||
onRetry={() => peersQ.refetch()}
|
|
||||||
>
|
>
|
||||||
{(items) => <PeersTable items={items} />}
|
{(bird) => <BirdSummary bird={bird} />}
|
||||||
</QueryState>
|
</QueryState>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="speakers" className="mt-4">
|
<TabsContent value="peers" className="mt-0">
|
||||||
<Card>
|
<NetworkPeersCard
|
||||||
<CardHeader className="border-b py-3">
|
items={peers}
|
||||||
<CardTitle className="text-base">Спикеры</CardTitle>
|
speakers={speakers}
|
||||||
</CardHeader>
|
isLoading={peersQ.isLoading}
|
||||||
<CardContent className="p-0">
|
isError={peersQ.isError}
|
||||||
<QueryState
|
error={peersQ.error}
|
||||||
data={speakers}
|
onRetry={() => peersQ.refetch()}
|
||||||
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>
|
</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>
|
<Card>
|
||||||
<CardHeader className="border-b py-3">
|
<CardHeader className="border-b py-3">
|
||||||
<CardTitle className="text-base">Настройки Control Plane (BIRD)</CardTitle>
|
<CardTitle className="text-base">Настройки Control Plane (BIRD)</CardTitle>
|
||||||
@@ -169,7 +141,7 @@ function NetworkComponent() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</BadgeTabs>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -196,78 +168,3 @@ function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
|
|||||||
</div>
|
</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,38 +1,24 @@
|
|||||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { AlertTriangle, Clock, Activity, Info, RefreshCw } from 'lucide-react'
|
import { RefreshCw } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||||
import {
|
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||||
Select,
|
import { DataGridCard } from '@/components/data-grid-shell'
|
||||||
SelectContent,
|
import { OperationsAnalyticsCard } from '@/components/analytics'
|
||||||
SelectItem,
|
import { SelectMenu } from '@/components/select-field'
|
||||||
SelectTrigger,
|
import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid'
|
||||||
} from '@evobgp/ui/components/select'
|
import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@evobgp/ui/components/table'
|
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
|
||||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
|
||||||
import { operationsJobsQueryOptions, operationsRevisionsQueryOptions, operationsDiffQueryOptions } from '@/queries/operations'
|
import { operationsJobsQueryOptions, operationsRevisionsQueryOptions, operationsDiffQueryOptions } from '@/queries/operations'
|
||||||
import { moduleNameById, overviewModulesQueryOptions } from '@/queries/overview'
|
import { moduleNameById, overviewModulesQueryOptions } from '@/queries/overview'
|
||||||
import { apiMutate, waitForJob } from '@/lib/api-client'
|
import { apiMutate, waitForJob } from '@/lib/api-client'
|
||||||
import type { JobRow } from '@/types/api'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/operations')({
|
export const Route = createFileRoute('/_auth/operations')({
|
||||||
component: OperationsComponent,
|
component: OperationsComponent,
|
||||||
@@ -46,6 +32,7 @@ export const Route = createFileRoute('/_auth/operations')({
|
|||||||
|
|
||||||
function OperationsComponent() {
|
function OperationsComponent() {
|
||||||
const search = useSearch({ from: '/_auth/operations' })
|
const search = useSearch({ from: '/_auth/operations' })
|
||||||
|
const navigate = Route.useNavigate()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const revisionsQ = useQuery(operationsRevisionsQueryOptions())
|
const revisionsQ = useQuery(operationsRevisionsQueryOptions())
|
||||||
@@ -57,32 +44,6 @@ function OperationsComponent() {
|
|||||||
const nameById = moduleNameById(modulesQ.data?.items ?? [])
|
const nameById = moduleNameById(modulesQ.data?.items ?? [])
|
||||||
|
|
||||||
const refreshing = revisionsQ.isFetching || jobsQ.isFetching
|
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() {
|
function refetchAll() {
|
||||||
void revisionsQ.refetch()
|
void revisionsQ.refetch()
|
||||||
@@ -132,15 +93,6 @@ function OperationsComponent() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Alert className="border-info/30 bg-info/5">
|
|
||||||
<Info className="text-info" />
|
|
||||||
<AlertTitle>Три раздела на одной странице</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
<strong>Ревизии</strong> — история конфигов и откат; <strong>Сравнение</strong> — diff
|
|
||||||
префиксов; <strong>Задачи</strong> — ingest, apply, rollback.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
trigger={
|
trigger={
|
||||||
@@ -166,207 +118,88 @@ function OperationsComponent() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{revisionsQ.isLoading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
|
{revisionsQ.isLoading ? (
|
||||||
|
<OperationsAnalyticsCard jobs={[]} revisions={[]} loading />
|
||||||
|
) : (
|
||||||
|
<OperationsAnalyticsCard jobs={jobs} revisions={revisions} />
|
||||||
|
)}
|
||||||
|
|
||||||
<Tabs defaultValue={search.tab}>
|
<BadgeTabs
|
||||||
<TabsList>
|
value={search.tab}
|
||||||
<TabsTrigger value="revisions">Ревизии ({revisions.length})</TabsTrigger>
|
onValueChange={(tab) =>
|
||||||
<TabsTrigger value="diff">Сравнение</TabsTrigger>
|
navigate({ search: { tab: tab as 'revisions' | 'diff' | 'jobs' } })
|
||||||
<TabsTrigger value="jobs">Задачи ({jobs.length})</TabsTrigger>
|
}
|
||||||
</TabsList>
|
items={[
|
||||||
|
{ value: 'revisions', label: 'Ревизии', count: revisions.length },
|
||||||
<TabsContent value="revisions" className="mt-4">
|
{ value: 'diff', label: 'Сравнение' },
|
||||||
<Card>
|
{ value: 'jobs', label: 'Задачи', count: jobs.length, badgeVariant: 'info-light' },
|
||||||
<CardHeader className="border-b py-3">
|
]}
|
||||||
<CardTitle className="text-base">История ревизий</CardTitle>
|
>
|
||||||
</CardHeader>
|
<TabsContent value="revisions" className="mt-0">
|
||||||
<CardContent className="p-0">
|
<DataGridCard title="История ревизий">
|
||||||
<QueryState
|
<QueryState
|
||||||
data={revisions}
|
data={revisions}
|
||||||
isLoading={revisionsQ.isLoading}
|
isLoading={revisionsQ.isLoading}
|
||||||
isError={revisionsQ.isError}
|
isError={revisionsQ.isError}
|
||||||
error={revisionsQ.error}
|
error={revisionsQ.error}
|
||||||
empty={revisions.length === 0}
|
empty={revisions.length === 0}
|
||||||
emptyTitle="Нет ревизий"
|
emptyTitle="Нет ревизий"
|
||||||
onRetry={() => revisionsQ.refetch()}
|
onRetry={() => revisionsQ.refetch()}
|
||||||
>
|
>
|
||||||
{(items) => <RevisionsTable items={items} qc={qc} />}
|
{(items) => (
|
||||||
</QueryState>
|
<OperationsRevisionsGrid
|
||||||
</CardContent>
|
items={items}
|
||||||
</Card>
|
qc={qc}
|
||||||
|
isLoading={revisionsQ.isFetching && !revisionsQ.isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</DataGridCard>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="diff" className="mt-4">
|
<TabsContent value="diff" className="mt-0">
|
||||||
<DiffTab revisions={revisions} />
|
<DiffTab revisions={revisions} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="jobs" className="mt-4">
|
<TabsContent value="jobs" className="mt-0">
|
||||||
<Card>
|
<DataGridCard title="Задачи">
|
||||||
<CardHeader className="border-b py-3">
|
<QueryState
|
||||||
<CardTitle className="text-base">Задачи</CardTitle>
|
data={jobs}
|
||||||
</CardHeader>
|
isLoading={jobsQ.isLoading}
|
||||||
<CardContent className="p-0">
|
isError={jobsQ.isError}
|
||||||
<QueryState
|
error={jobsQ.error}
|
||||||
data={jobs}
|
empty={jobs.length === 0}
|
||||||
isLoading={jobsQ.isLoading}
|
emptyTitle="Нет задач"
|
||||||
isError={jobsQ.isError}
|
onRetry={() => jobsQ.refetch()}
|
||||||
error={jobsQ.error}
|
>
|
||||||
empty={jobs.length === 0}
|
{(items) => (
|
||||||
emptyTitle="Нет задач"
|
<OperationsJobsGrid
|
||||||
onRetry={() => jobsQ.refetch()}
|
items={items}
|
||||||
>
|
nameById={nameById}
|
||||||
{(items) => <JobsTable items={items} nameById={nameById} qc={qc} />}
|
qc={qc}
|
||||||
</QueryState>
|
isLoading={jobsQ.isFetching && !jobsQ.isLoading}
|
||||||
</CardContent>
|
/>
|
||||||
</Card>
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</DataGridCard>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</BadgeTabs>
|
||||||
</div>
|
</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[] }) {
|
function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[] }) {
|
||||||
const [a, setA] = useState('')
|
const [a, setA] = useState('')
|
||||||
const [b, setB] = useState('')
|
const [b, setB] = useState('')
|
||||||
const diffQ = useQuery(operationsDiffQueryOptions(a, b))
|
const diffQ = useQuery(operationsDiffQueryOptions(a, b))
|
||||||
|
const revisionItems = useMemo(
|
||||||
|
() =>
|
||||||
|
revisions.map((r) => ({
|
||||||
|
value: r.id,
|
||||||
|
label: `${r.id.slice(0, 12)}…`,
|
||||||
|
})),
|
||||||
|
[revisions],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -377,29 +210,21 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
|
|||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<div className="flex w-full max-w-xs flex-col gap-1">
|
<div className="flex w-full max-w-xs flex-col gap-1">
|
||||||
<span className="text-xs text-muted-foreground">Ревизия A</span>
|
<span className="text-xs text-muted-foreground">Ревизия A</span>
|
||||||
<Select value={a} onValueChange={(v) => v && setA(v)}>
|
<SelectMenu
|
||||||
<SelectTrigger>{a ? a.slice(0, 12) + '…' : 'Выберите'}</SelectTrigger>
|
items={revisionItems}
|
||||||
<SelectContent>
|
value={a}
|
||||||
{revisions.map((r) => (
|
placeholder="Выберите"
|
||||||
<SelectItem key={r.id} value={r.id}>
|
onValueChange={(v) => v && setA(v)}
|
||||||
{r.id.slice(0, 12)}…
|
/>
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full max-w-xs flex-col gap-1">
|
<div className="flex w-full max-w-xs flex-col gap-1">
|
||||||
<span className="text-xs text-muted-foreground">Ревизия B</span>
|
<span className="text-xs text-muted-foreground">Ревизия B</span>
|
||||||
<Select value={b} onValueChange={(v) => v && setB(v)}>
|
<SelectMenu
|
||||||
<SelectTrigger>{b ? b.slice(0, 12) + '…' : 'Выберите'}</SelectTrigger>
|
items={revisionItems}
|
||||||
<SelectContent>
|
value={b}
|
||||||
{revisions.map((r) => (
|
placeholder="Выберите"
|
||||||
<SelectItem key={r.id} value={r.id}>
|
onValueChange={(v) => v && setB(v)}
|
||||||
{r.id.slice(0, 12)}…
|
/>
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => diffQ.refetch()} disabled={!a || !b || diffQ.isFetching}>
|
<Button onClick={() => diffQ.refetch()} disabled={!a || !b || diffQ.isFetching}>
|
||||||
Сравнить
|
Сравнить
|
||||||
|
|||||||
@@ -1,28 +1,18 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { AlertTriangle, Clock, Info, ListTodo, RefreshCw } from 'lucide-react'
|
import { AlertTriangle, Clock, ListTodo, RefreshCw } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useState } from 'react'
|
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 { Button } from '@evobgp/ui/components/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
import { DataGridCard } from '@/components/data-grid-shell'
|
||||||
import {
|
import { ScheduleJobsGrid } from '@/components/schedule/schedule-jobs-grid'
|
||||||
Table,
|
import { ScheduleModulesGrid } from '@/components/schedule/schedule-modules-grid'
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@evobgp/ui/components/table'
|
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
|
||||||
|
|
||||||
import { operationsJobsQueryOptions } from '@/queries/operations'
|
import { operationsJobsQueryOptions } from '@/queries/operations'
|
||||||
import { modulesListQueryOptions } from '@/queries/modules'
|
import { modulesListQueryOptions } from '@/queries/modules'
|
||||||
@@ -96,94 +86,35 @@ function ScheduleComponent() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Alert className="border-info/30 bg-info/5">
|
|
||||||
<Info className="text-info" />
|
|
||||||
<AlertTitle>Как работает расписание</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Планировщик использует <code className="text-xs">refresh_interval_sec</code> и опционально{' '}
|
|
||||||
<code className="text-xs">cron_expr</code>. Ручной запуск —{' '}
|
|
||||||
<code className="text-xs">POST /v1/modules/{id}/refresh</code>.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
|
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
|
||||||
|
|
||||||
<Card>
|
<DataGridCard
|
||||||
<CardHeader>
|
title="Модули"
|
||||||
<CardTitle className="text-base">Модули</CardTitle>
|
description="Расписание обновления и ручной запуск ingest"
|
||||||
<CardDescription>Расписание обновления и ручной запуск ingest</CardDescription>
|
>
|
||||||
</CardHeader>
|
<QueryState
|
||||||
<CardContent className="p-0">
|
data={modules}
|
||||||
<QueryState
|
isLoading={modulesQ.isLoading}
|
||||||
data={modules}
|
isError={modulesQ.isError}
|
||||||
isLoading={modulesQ.isLoading}
|
error={modulesQ.error}
|
||||||
isError={modulesQ.isError}
|
empty={modules.length === 0}
|
||||||
error={modulesQ.error}
|
emptyTitle="Нет модулей"
|
||||||
empty={modules.length === 0}
|
onRetry={() => modulesQ.refetch()}
|
||||||
emptyTitle="Нет модулей"
|
>
|
||||||
onRetry={() => modulesQ.refetch()}
|
{(items) => (
|
||||||
>
|
<ScheduleModulesGrid
|
||||||
{(items) => (
|
items={items}
|
||||||
<Table>
|
refreshing={refreshing}
|
||||||
<TableHeader>
|
onRefresh={(id) => refreshMutation.mutate(id)}
|
||||||
<TableRow>
|
isLoading={modulesQ.isFetching && !modulesQ.isLoading}
|
||||||
<TableHead>Модуль</TableHead>
|
/>
|
||||||
<TableHead>Тип</TableHead>
|
)}
|
||||||
<TableHead>Расписание</TableHead>
|
</QueryState>
|
||||||
<TableHead>Обновлено</TableHead>
|
</DataGridCard>
|
||||||
<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>
|
|
||||||
|
|
||||||
<Card>
|
<DataGridCard title="Задачи" description="Последние задачи из API">
|
||||||
<CardHeader>
|
<JobsTabs jobs={jobs} loading={jobsQ.isLoading} />
|
||||||
<CardTitle className="text-base">Задачи</CardTitle>
|
</DataGridCard>
|
||||||
<CardDescription>Последние задачи из API</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<JobsTabs jobs={jobs} loading={jobsQ.isLoading} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -195,69 +126,29 @@ function JobsTabs({ jobs, loading }: { jobs: JobRow[]; loading: boolean }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue="all">
|
<BadgeTabs
|
||||||
<TabsList>
|
defaultValue="all"
|
||||||
<TabsTrigger value="all">Все ({jobs.length})</TabsTrigger>
|
listClassName="mx-3 mb-0 w-auto"
|
||||||
<TabsTrigger value="refresh">Обновление ({refresh.length})</TabsTrigger>
|
items={[
|
||||||
<TabsTrigger value="failed">С ошибкой ({failed.length})</TabsTrigger>
|
{ value: 'all', label: 'Все', count: jobs.length },
|
||||||
</TabsList>
|
{ 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">
|
<TabsContent value="all" className="mt-0">
|
||||||
<JobsTable items={jobs} loading={loading} />
|
<ScheduleJobsGrid items={jobs} isLoading={loading} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="refresh" className="mt-0">
|
<TabsContent value="refresh" className="mt-0">
|
||||||
<JobsTable items={refresh} loading={loading} />
|
<ScheduleJobsGrid items={refresh} isLoading={loading} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="failed" className="mt-0">
|
<TabsContent value="failed" className="mt-0">
|
||||||
<JobsTable items={failed} loading={loading} />
|
<ScheduleJobsGrid items={failed} isLoading={loading} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</BadgeTabs>
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user