feat: refactor components to utilize DataGridCard for improved UI consistency
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 57s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m27s
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 57s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m27s
Updated various components including AccessApiKeysCard, FirewallClientsGrid, FirewallRulesGrid, ModuleEntriesSection, and DirectoriesComponent to replace traditional card and table structures with the new DataGridCard component. This change enhances the user interface by providing a more consistent layout and improved loading states. Additionally, integrated QueryState for better handling of loading and error scenarios across these components, streamlining the overall user experience.
This commit is contained in:
@@ -1,25 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { Plus, RefreshCw } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
|
||||
import { AccessApiKeysGrid } from '@/components/access/access-api-keys-grid'
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog'
|
||||
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
|
||||
import { useRevokeApiKeyMutation, useRotateApiKeyMutation } from '@/queries/api-keys'
|
||||
import type { ApiKey, ApiKeyCreated } from '@/types/api'
|
||||
|
||||
@@ -58,121 +47,45 @@ export function AccessApiKeysCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base">API-ключи</CardTitle>
|
||||
<CardDescription>
|
||||
Управление ключами tenant. Полный токен показывается только при создании и ротации.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onClick={onRetry} disabled={isLoading}>
|
||||
<DataGridCard
|
||||
title="API-ключи"
|
||||
description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
|
||||
actions={
|
||||
<>
|
||||
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
|
||||
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Button size="sm" type="button" onClick={() => setCreateOpen(true)}>
|
||||
<Plus />
|
||||
Создать
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle="Нет ключей"
|
||||
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
|
||||
skeleton={<TableSkeleton rows={4} cols={6} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(data) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Имя</TableHead>
|
||||
<TableHead>Роль</TableHead>
|
||||
<TableHead>Префикс</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Истекает</TableHead>
|
||||
<TableHead>Последнее использование</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((k) => (
|
||||
<TableRow key={k.id}>
|
||||
<TableCell className="font-medium">{k.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{k.role}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{k.prefix}…
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{k.revoked_at ? (
|
||||
<StatusBadge status="error" label="отозван" />
|
||||
) : (
|
||||
<StatusBadge status="active" label="активен" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatApiKeyDate(k.expires_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatApiKeyDate(k.last_used_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Ротировать"
|
||||
disabled={!!k.revoked_at || rotate.isPending}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
title="Ротировать ключ?"
|
||||
description="Старый токен перестанет работать сразу."
|
||||
confirmLabel="Ротировать"
|
||||
onConfirm={() => handleRotated(k.id)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive"
|
||||
disabled={!!k.revoked_at || revoke.isPending}
|
||||
title="Отозвать"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
title="Отозвать API-ключ?"
|
||||
description={`${k.name} (${k.prefix}…)`}
|
||||
confirmLabel="Отозвать"
|
||||
destructive
|
||||
onConfirm={() => revoke.mutate(k.id)}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle="Нет ключей"
|
||||
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
|
||||
skeleton={<TableSkeleton rows={4} cols={6} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(data) => (
|
||||
<AccessApiKeysGrid
|
||||
items={data}
|
||||
isLoading={isLoading}
|
||||
onRotate={handleRotated}
|
||||
onRevoke={(id) => revoke.mutate(id)}
|
||||
rotatePending={rotate.isPending}
|
||||
revokePending={revoke.isPending}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
|
||||
<ApiKeyCreateDialog
|
||||
open={createOpen}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { ColumnDef, useReactTable } from '@tanstack/react-table'
|
||||
import { RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridShell } from '@/components/data-grid-shell'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
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 { createClientDataGridOptions } from '@/lib/data-grid-defaults'
|
||||
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 }) => <span className="font-medium">{row.original.name}</span>,
|
||||
meta: { headerTitle: 'Имя' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{row.original.role}
|
||||
</Badge>
|
||||
),
|
||||
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 }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatApiKeyDate(row.original.expires_at)}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Истекает' },
|
||||
},
|
||||
{
|
||||
id: 'last_used_at',
|
||||
accessorFn: (row) => row.last_used_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Последнее использование" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatApiKeyDate(row.original.last_used_at)}
|
||||
</span>
|
||||
),
|
||||
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 = useReactTable({
|
||||
data: items,
|
||||
columns,
|
||||
...createClientDataGridOptions<ApiKey>(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={items.length}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет ключей"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 { FrameFooter } from '@/components/reui/frame'
|
||||
|
||||
export function DashboardQuickActions() {
|
||||
return (
|
||||
<FrameFooter className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/modules" />}>
|
||||
<Plus className="size-4" />
|
||||
Создать модуль
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" type="button" render={<Link to="/directories" />}>
|
||||
<Tags className="size-4" />
|
||||
Добавить 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>
|
||||
</FrameFooter>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DataGridShell } 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 type { JobRow } from '@/types/api'
|
||||
|
||||
function StatusText({ status }: { status: string }) {
|
||||
const cls =
|
||||
status === 'succeeded'
|
||||
? 'text-success'
|
||||
: status === 'failed' || status === 'cancelled'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
return <span className={`text-xs font-medium ${cls}`}>{status}</span>
|
||||
}
|
||||
|
||||
export function DashboardRecentJobsGrid({
|
||||
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 }) => (
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-mono text-xs text-muted-foreground">{row.original.kind}</div>
|
||||
{row.original.meta?.module_id ? (
|
||||
<div className="truncate text-xs">
|
||||
{nameById.get(String(row.original.meta.module_id)) ?? ''}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: 'Вид' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => <StatusText status={row.original.status} />,
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
],
|
||||
[nameById],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (row) => row.job_id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={data.length}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет задач"
|
||||
showPagination={false}
|
||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DataGridShell } 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 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 }) => (
|
||||
<span className="truncate font-mono text-xs text-muted-foreground">
|
||||
{row.original.id.slice(0, 10)}…
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'ID' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(row.original.created_at).toLocaleString('ru-RU')}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Создана' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={data.length}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет ревизий"
|
||||
showPagination={false}
|
||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Table } from '@tanstack/react-table'
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import {
|
||||
DATA_GRID_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 ? <DataGridPagination {...DATA_GRID_PAGINATION_RU} /> : 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ColumnDef, useReactTable } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Badge } from '@evobgp/ui/components/badge'
|
||||
|
||||
import { DataGridShell } from '@/components/data-grid-shell'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
|
||||
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 }) => <span className="font-medium">{row.original.title}</span>,
|
||||
meta: { headerTitle: 'Название' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'community',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />,
|
||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.community}</span>,
|
||||
meta: { headerTitle: 'Значение' },
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
enableSorting: false,
|
||||
header: 'Тип',
|
||||
cell: () => <Badge variant="outline">community</Badge>,
|
||||
meta: { headerTitle: 'Тип' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: items,
|
||||
columns,
|
||||
...createClientDataGridOptions<BgpCommunity>(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={items.length}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет сообществ"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ColumnDef, useReactTable } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Badge } from '@evobgp/ui/components/badge'
|
||||
|
||||
import { DataGridShell } from '@/components/data-grid-shell'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
|
||||
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 }) => <span className="font-medium">{row.original.name ?? row.original.url}</span>,
|
||||
meta: { headerTitle: 'Название' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'url',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="URL" />,
|
||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.url}</span>,
|
||||
meta: { headerTitle: 'URL' },
|
||||
},
|
||||
{
|
||||
id: 'default',
|
||||
enableSorting: false,
|
||||
header: 'По умолчанию',
|
||||
cell: () => <Badge variant="outline">—</Badge>,
|
||||
meta: { headerTitle: 'По умолчанию' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: items,
|
||||
columns,
|
||||
...createClientDataGridOptions<DohProfile>(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={items.length}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет DoH профилей"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,13 @@
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { ColumnDef, useReactTable } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridShell } 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 { 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 { createClientDataGridOptions } from '@/lib/data-grid-defaults'
|
||||
import type { FirewallClient } from '@/types/api'
|
||||
|
||||
function formatPacketCount(value?: number | null): string | null {
|
||||
@@ -183,33 +176,16 @@ export function FirewallClientsGrid({
|
||||
const table = useReactTable({
|
||||
data: clients,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
...createClientDataGridOptions<FirewallClient>(),
|
||||
getRowId: (row) => row.id,
|
||||
initialState: { pagination: { pageSize: 10 } },
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={clients.length}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyTitle}
|
||||
tableLayout={{ headerSticky: true, dense: true }}
|
||||
>
|
||||
<DataGridContainer>
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
<DataGridPagination
|
||||
sizes={[10, 25, 50]}
|
||||
sizesLabel="Показать"
|
||||
sizesDescription="на странице"
|
||||
info="{from}–{to} из {count}"
|
||||
rowsPerPageLabel="Строк на странице"
|
||||
previousPageLabel="Предыдущая страница"
|
||||
nextPageLabel="Следующая страница"
|
||||
/>
|
||||
</DataGrid>
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { ColumnDef, useReactTable } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridShell } 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 { 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 { createClientDataGridOptions } from '@/lib/data-grid-defaults'
|
||||
import { communityLabel } from '@/lib/modules/helpers'
|
||||
import type { BgpCommunity, FirewallRule } from '@/types/api'
|
||||
|
||||
@@ -110,33 +103,16 @@ export function FirewallRulesGrid({
|
||||
const table = useReactTable({
|
||||
data: rules,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
...createClientDataGridOptions<FirewallRule>(),
|
||||
getRowId: (row) => row.id,
|
||||
initialState: { pagination: { pageSize: 10 } },
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rules.length}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyTitle}
|
||||
tableLayout={{ headerSticky: true, dense: true }}
|
||||
>
|
||||
<DataGridContainer>
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
<DataGridPagination
|
||||
sizes={[10, 25, 50]}
|
||||
sizesLabel="Показать"
|
||||
sizesDescription="на странице"
|
||||
info="{from}–{to} из {count}"
|
||||
rowsPerPageLabel="Строк на странице"
|
||||
previousPageLabel="Предыдущая страница"
|
||||
nextPageLabel="Следующая страница"
|
||||
/>
|
||||
</DataGrid>
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { ColumnDef, useReactTable } from '@tanstack/react-table'
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { DataGridShell } 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 { createClientDataGridOptions } from '@/lib/data-grid-defaults'
|
||||
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 } }) => (
|
||||
<span className="font-mono text-sm">{row.original.fqdn}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 } }) => (
|
||||
<span className="font-mono text-sm">{row.original.prefix}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 } }) => (
|
||||
<span className="max-w-xs truncate font-mono text-xs">{row.original.url}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'source_kind',
|
||||
header: 'Тип',
|
||||
cell: ({ row }: { row: { original: CdnSource } }) => (
|
||||
<span className="text-sm">{row.original.source_kind}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 } }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDateTime(row.original.last_refreshed_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 = useReactTable({
|
||||
data,
|
||||
columns: columns as ColumnDef<RowType>[],
|
||||
...createClientDataGridOptions<RowType>(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={data.length}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет записей"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type { DeleteTarget as ModuleEntryDeleteTarget }
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
@@ -13,25 +13,16 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@evobgp/ui/components/alert-dialog'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { ModuleEntriesGrid, type ModuleEntryDeleteTarget } from '@/components/modules/module-entries-grid'
|
||||
import { ModuleAsEntryDialog } from '@/components/modules/module-as-entry-dialog'
|
||||
import { ModuleCdnSourceDialog } from '@/components/modules/module-cdn-source-dialog'
|
||||
import { ModuleDomainEntryDialog } from '@/components/modules/module-domain-entry-dialog'
|
||||
import { ModuleIpRangeEntryDialog } from '@/components/modules/module-ip-range-entry-dialog'
|
||||
import { ApiError, apiMutate } from '@/lib/api-client'
|
||||
import { communityLabel } from '@/lib/modules/helpers'
|
||||
import { formatDateTime } from '@/lib/modules/display'
|
||||
import type {
|
||||
AsEntry,
|
||||
BgpCommunity,
|
||||
@@ -53,11 +44,7 @@ interface ModuleEntriesSectionProps {
|
||||
onChanged: () => void | Promise<void>
|
||||
}
|
||||
|
||||
type DeleteTarget =
|
||||
| { kind: 'domain'; entry: DomainEntry }
|
||||
| { kind: 'ip-range'; entry: IpRangeEntry }
|
||||
| { kind: 'cdn'; entry: CdnSource }
|
||||
| { kind: 'as'; entry: AsEntry }
|
||||
type DeleteTarget = ModuleEntryDeleteTarget
|
||||
|
||||
const CARD_META: Record<
|
||||
ModuleRow['type'],
|
||||
@@ -150,49 +137,45 @@ export function ModuleEntriesSection({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base">{meta.title}</CardTitle>
|
||||
<CardDescription>{meta.description}</CardDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus />
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle={meta.emptyTitle}
|
||||
emptyDescription={meta.emptyDescription}
|
||||
skeleton={<TableSkeleton rows={5} cols={3} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(rows) => (
|
||||
<EntriesTable
|
||||
mod={mod}
|
||||
rows={rows}
|
||||
communities={communities}
|
||||
onEdit={(target) => {
|
||||
if (target.kind === 'domain') setEditDomain(target.entry)
|
||||
if (target.kind === 'ip-range') setEditIpRange(target.entry)
|
||||
if (target.kind === 'cdn') setEditCdn(target.entry)
|
||||
if (target.kind === 'as') setEditAs(target.entry)
|
||||
setDialogOpen(true)
|
||||
}}
|
||||
onDelete={setDeleteTarget}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DataGridCard
|
||||
title={meta.title}
|
||||
description={meta.description}
|
||||
actions={
|
||||
<Button size="sm" type="button" onClick={openCreate}>
|
||||
<Plus />
|
||||
Добавить
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle={meta.emptyTitle}
|
||||
emptyDescription={meta.emptyDescription}
|
||||
skeleton={<TableSkeleton rows={5} cols={3} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(rows) => (
|
||||
<ModuleEntriesGrid
|
||||
mod={mod}
|
||||
rows={rows}
|
||||
communities={communities}
|
||||
isLoading={isLoading}
|
||||
onEdit={(target) => {
|
||||
if (target.kind === 'domain') setEditDomain(target.entry)
|
||||
if (target.kind === 'ip-range') setEditIpRange(target.entry)
|
||||
if (target.kind === 'cdn') setEditCdn(target.entry)
|
||||
if (target.kind === 'as') setEditAs(target.entry)
|
||||
setDialogOpen(true)
|
||||
}}
|
||||
onDelete={setDeleteTarget}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
|
||||
{mod.type === 'DOMAINS' ? (
|
||||
<ModuleDomainEntryDialog
|
||||
@@ -270,168 +253,3 @@ function deleteDescription(target: DeleteTarget | null): string {
|
||||
return `AS${target.entry.asn}`
|
||||
}
|
||||
}
|
||||
|
||||
function EntriesTable({
|
||||
mod,
|
||||
rows,
|
||||
communities,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
mod: ModuleRow
|
||||
rows: Record<string, unknown>[]
|
||||
communities: BgpCommunity[]
|
||||
onEdit: (target: DeleteTarget) => void
|
||||
onDelete: (target: DeleteTarget) => void
|
||||
}) {
|
||||
if (mod.type === 'DOMAINS') {
|
||||
const entries = rows as unknown as DomainEntry[]
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>FQDN</TableHead>
|
||||
<TableHead>Community</TableHead>
|
||||
<TableHead className="w-20" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((entry) => (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell className="font-mono text-sm">{entry.fqdn}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{communityLabel(entry.community_id, communities)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RowActions
|
||||
onEdit={() => onEdit({ kind: 'domain', entry })}
|
||||
onDelete={() => onDelete({ kind: 'domain', entry })}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
if (mod.type === 'IP_RANGES') {
|
||||
const entries = rows as unknown as IpRangeEntry[]
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Префикс (CIDR)</TableHead>
|
||||
<TableHead>Community</TableHead>
|
||||
<TableHead className="w-20" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((entry) => (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell className="font-mono text-sm">{entry.prefix}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{communityLabel(entry.community_id, communities)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RowActions
|
||||
onEdit={() => onEdit({ kind: 'ip-range', entry })}
|
||||
onDelete={() => onDelete({ kind: 'ip-range', entry })}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
if (mod.type === 'CDN_CIDRS') {
|
||||
const entries = rows as unknown as CdnSource[]
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>URL</TableHead>
|
||||
<TableHead>Тип</TableHead>
|
||||
<TableHead>Community</TableHead>
|
||||
<TableHead>Обновлено</TableHead>
|
||||
<TableHead className="w-20" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((entry) => (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell className="max-w-xs truncate font-mono text-xs">{entry.url}</TableCell>
|
||||
<TableCell className="text-sm">{entry.source_kind}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{communityLabel(entry.community_id, communities)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatDateTime(entry.last_refreshed_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RowActions
|
||||
onEdit={() => onEdit({ kind: 'cdn', entry })}
|
||||
onDelete={() => onDelete({ kind: 'cdn', entry })}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
const entries = rows as unknown as AsEntry[]
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ASN</TableHead>
|
||||
<TableHead>Имя</TableHead>
|
||||
<TableHead>Префиксов</TableHead>
|
||||
<TableHead>Community</TableHead>
|
||||
<TableHead className="w-20" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((entry) => (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell className="font-mono text-sm">{entry.asn}</TableCell>
|
||||
<TableCell className="text-sm">{entry.asn_name ?? '—'}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{entry.prefix_count ?? '—'}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{communityLabel(entry.community_id, communities)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RowActions
|
||||
onEdit={() => onEdit({ kind: 'as', entry })}
|
||||
onDelete={() => onDelete({ kind: 'as', entry })}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) {
|
||||
return (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon-sm" onClick={onEdit} aria-label="Редактировать">
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive"
|
||||
onClick={onDelete}
|
||||
aria-label="Удалить"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ColumnDef, useReactTable } from '@tanstack/react-table'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Boxes } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DataGridShell } from '@/components/data-grid-shell'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { TruncatedText } from '@/components/truncated-text'
|
||||
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
|
||||
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 text-muted-foreground" />
|
||||
<TruncatedText className="max-w-[280px] font-medium">{row.original.name}</TruncatedText>
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: 'Название' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => <Badge variant="outline">{row.original.type}</Badge>,
|
||||
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 }) =>
|
||||
row.original.enabled ? (
|
||||
<Badge variant="success">включён</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">выключен</Badge>
|
||||
),
|
||||
meta: { headerTitle: 'Состояние' },
|
||||
},
|
||||
{
|
||||
id: 'last_refreshed_at',
|
||||
accessorFn: (row) => row.last_refreshed_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{row.original.last_refreshed_at
|
||||
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</span>
|
||||
),
|
||||
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 = useReactTable({
|
||||
data: items,
|
||||
columns,
|
||||
...createClientDataGridOptions<ModuleRow>(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={items.length}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет модулей"
|
||||
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { ColumnDef, useReactTable } from '@tanstack/react-table'
|
||||
import { Database, HardDrive, HeartPulse, ListTodo, ShieldCheck } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Badge } from '@evobgp/ui/components/badge'
|
||||
|
||||
import { DataGridShell } from '@/components/data-grid-shell'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
|
||||
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
|
||||
ok: boolean
|
||||
statusLabel: string
|
||||
variant: 'default' | 'destructive' | 'secondary'
|
||||
}
|
||||
|
||||
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,
|
||||
ok: health?.ok === true,
|
||||
statusLabel: health?.ok ? 'OK' : 'Ошибка',
|
||||
variant: health?.ok ? 'default' : 'destructive',
|
||||
},
|
||||
{
|
||||
id: 'readiness',
|
||||
label: 'Readiness',
|
||||
subtitle: '/v1/ready',
|
||||
icon: ShieldCheck,
|
||||
ok: ready.status === 'ok',
|
||||
statusLabel: ready.status ?? '—',
|
||||
variant: ready.status === 'ok' ? 'default' : 'secondary',
|
||||
},
|
||||
]
|
||||
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,
|
||||
ok,
|
||||
statusLabel: ok ? 'OK' : 'Ошибка',
|
||||
variant: ok ? 'default' : 'destructive',
|
||||
})
|
||||
}
|
||||
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" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{row.original.label}</p>
|
||||
{row.original.subtitle ? (
|
||||
<p className="text-xs text-muted-foreground">{row.original.subtitle}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: 'Проверка' },
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
enableSorting: false,
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.variant}>{row.original.statusLabel}</Badge>
|
||||
),
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
...createClientDataGridOptions<ReadyCheckRow>({
|
||||
initialState: { pagination: { pageSize: 20 } },
|
||||
}),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={data.length}
|
||||
showPagination={false}
|
||||
emptyMessage="Нет проверок"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ColumnDef, useReactTable } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DataGridShell } 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 { createClientDataGridOptions } from '@/lib/data-grid-defaults'
|
||||
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 }) => (
|
||||
<span className="font-medium">{row.original.name ?? row.original.neighbor}</span>
|
||||
),
|
||||
meta: { headerTitle: 'Имя' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'neighbor',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Neighbor" />,
|
||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.neighbor}</span>,
|
||||
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 ? (
|
||||
<Badge variant="warning" className="ml-1">
|
||||
mismatch
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: 'Состояние' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: items,
|
||||
columns,
|
||||
...createClientDataGridOptions<PeerRow>(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={items.length}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет пиров"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ColumnDef, useReactTable } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DataGridShell } 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 { createClientDataGridOptions } from '@/lib/data-grid-defaults'
|
||||
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 }) => <span className="font-mono text-xs">{row.original.endpoint}</span>,
|
||||
meta: { headerTitle: 'Endpoint' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
||||
cell: ({ row }) => <Badge variant="outline">{row.original.role}</Badge>,
|
||||
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">—</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 = useReactTable({
|
||||
data: items,
|
||||
columns,
|
||||
...createClientDataGridOptions<SpeakerRow>(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={items.length}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет спикеров"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { ColumnDef, useReactTable } 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 { DataGridShell } from '@/components/data-grid-shell'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { apiMutate } from '@/lib/api-client'
|
||||
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
|
||||
import type { JobRow } from '@/types/api'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
function StatusBadgeColored({ status }: { status: string }) {
|
||||
const cls =
|
||||
status === 'succeeded'
|
||||
? 'text-success'
|
||||
: status === 'failed' || status === 'cancelled'
|
||||
? 'text-destructive'
|
||||
: 'text-info'
|
||||
return <span className={`text-sm font-medium ${cls}`}>{status}</span>
|
||||
}
|
||||
|
||||
export function OperationsJobsGrid({
|
||||
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 }) => (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">{row.original.kind}</span>
|
||||
{row.original.meta?.module_id ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{nameById.get(String(row.original.meta.module_id)) ??
|
||||
String(row.original.meta.module_id)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: 'Вид' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => <StatusBadgeColored status={row.original.status} />,
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
{
|
||||
id: 'created_at',
|
||||
accessorFn: (row) => row.created_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{row.original.created_at
|
||||
? new Date(row.original.created_at).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Создана' },
|
||||
},
|
||||
{
|
||||
id: 'finished_at',
|
||||
accessorFn: (row) => row.finished_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Завершена" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{row.original.finished_at
|
||||
? new Date(row.original.finished_at).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</span>
|
||||
),
|
||||
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 = useReactTable({
|
||||
data: items,
|
||||
columns,
|
||||
...createClientDataGridOptions<JobRow>(),
|
||||
getRowId: (row) => row.job_id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={items.length}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет задач"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { ColumnDef, useReactTable } 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 { DataGridShell } from '@/components/data-grid-shell'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { apiMutate } from '@/lib/api-client'
|
||||
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
|
||||
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 }) => (
|
||||
<span className="font-mono text-xs">{row.original.id.slice(0, 12)}…</span>
|
||||
),
|
||||
meta: { headerTitle: 'ID' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(row.original.created_at).toLocaleString('ru-RU')}
|
||||
</span>
|
||||
),
|
||||
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 = useReactTable({
|
||||
data: items,
|
||||
columns,
|
||||
...createClientDataGridOptions<RevisionRow>(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={items.length}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет ревизий"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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,100 @@
|
||||
import { ColumnDef, useReactTable } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Badge } from '@evobgp/ui/components/badge'
|
||||
|
||||
import { DataGridShell } from '@/components/data-grid-shell'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
|
||||
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 }) => <span className="font-medium">{row.original.kind}</span>,
|
||||
meta: { headerTitle: 'Вид' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={
|
||||
row.original.status === 'succeeded'
|
||||
? 'default'
|
||||
: row.original.status === 'failed'
|
||||
? 'destructive'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
meta: { headerTitle: 'Статус' },
|
||||
},
|
||||
{
|
||||
id: 'created_at',
|
||||
accessorFn: (row) => row.created_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{row.original.created_at
|
||||
? new Date(row.original.created_at).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Создана' },
|
||||
},
|
||||
{
|
||||
id: 'finished_at',
|
||||
accessorFn: (row) => row.finished_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Завершена" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{row.original.finished_at
|
||||
? new Date(row.original.finished_at).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</span>
|
||||
),
|
||||
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 = useReactTable({
|
||||
data: items,
|
||||
columns,
|
||||
...createClientDataGridOptions<JobRow>(),
|
||||
getRowId: (row) => row.job_id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={items.length}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет задач"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { ColumnDef, useReactTable } from '@tanstack/react-table'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { Badge } from '@evobgp/ui/components/badge'
|
||||
|
||||
import { DataGridShell } from '@/components/data-grid-shell'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
|
||||
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 }) => <span className="font-medium">{row.original.name}</span>,
|
||||
meta: { headerTitle: 'Модуль' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => <Badge variant="outline">{row.original.type}</Badge>,
|
||||
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 }) => (
|
||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{row.original.last_refreshed_at
|
||||
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Обновлено' },
|
||||
},
|
||||
{
|
||||
id: 'enabled',
|
||||
accessorFn: (row) => (row.enabled ? 'on' : 'off'),
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) =>
|
||||
row.original.enabled ? (
|
||||
<Badge variant="default">Вкл</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Выкл</Badge>
|
||||
),
|
||||
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 = useReactTable({
|
||||
data: items,
|
||||
columns,
|
||||
...createClientDataGridOptions<ModuleRow>(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={items.length}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет модулей"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ColumnDef, useReactTable } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { DataGridShell } from '@/components/data-grid-shell'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { createClientDataGridOptions } from '@/lib/data-grid-defaults'
|
||||
|
||||
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 }) => <span className="font-mono text-xs">{row.original.key}</span>,
|
||||
meta: { headerTitle: 'Ключ' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'value',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />,
|
||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.value}</span>,
|
||||
meta: { headerTitle: 'Значение' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: items,
|
||||
columns,
|
||||
...createClientDataGridOptions<SettingsKvRow>({
|
||||
initialState: { pagination: { pageSize: 25 } },
|
||||
}),
|
||||
getRowId: (row) => String(row.id),
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={items.length}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет дополнительных настроек"
|
||||
showPagination={items.length > 10}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
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 createClientDataGridOptions<TData extends object>(
|
||||
overrides?: Partial<TableOptions<TData>>,
|
||||
): Pick<
|
||||
TableOptions<TData>,
|
||||
'getCoreRowModel' | 'getSortedRowModel' | 'getPaginationRowModel' | 'initialState'
|
||||
> {
|
||||
return {
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
initialState: { pagination: { pageSize: 10 } },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import {
|
||||
Boxes,
|
||||
@@ -6,26 +6,29 @@ import {
|
||||
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 { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
|
||||
import { DashboardNetworkPanel } from '@/components/dashboard/dashboard-network-panel'
|
||||
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 {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
|
||||
@@ -46,6 +49,7 @@ export const Route = createFileRoute('/_auth/dashboard')({
|
||||
})
|
||||
|
||||
function DashboardComponent() {
|
||||
const navigate = useNavigate()
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
||||
|
||||
const results = useQueries({
|
||||
@@ -95,7 +99,7 @@ function DashboardComponent() {
|
||||
value: initialLoading ? '—' : String(modules.length),
|
||||
icon: <Boxes className="size-4" />,
|
||||
hint: countBadge(modules.length, modulesHasMore, 'AS, CDN, домены, IP'),
|
||||
onClick: () => window.location.assign('/modules'),
|
||||
onClick: () => navigate({ to: '/modules' }),
|
||||
},
|
||||
{
|
||||
label: 'Пиры',
|
||||
@@ -104,7 +108,7 @@ function DashboardComponent() {
|
||||
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'),
|
||||
onClick: () => navigate({ to: '/network', search: { tab: 'peers' } }),
|
||||
},
|
||||
{
|
||||
label: 'Спикеры',
|
||||
@@ -112,26 +116,29 @@ function DashboardComponent() {
|
||||
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'),
|
||||
onClick: () => navigate({ to: '/network', search: { tab: 'overview' } }),
|
||||
},
|
||||
{
|
||||
label: 'Ревизии',
|
||||
value: initialLoading ? '—' : String(revisions.length),
|
||||
icon: <Activity className="size-4" />,
|
||||
hint: countBadge(revisions.length, revisionsHasMore, 'configs'),
|
||||
onClick: () => window.location.assign('/operations'),
|
||||
onClick: () => navigate({ to: '/operations', search: { tab: 'revisions' } }),
|
||||
},
|
||||
{
|
||||
label: 'Активных задач',
|
||||
value: initialLoading ? '—' : String(running),
|
||||
icon: <Clock className="size-4" />,
|
||||
hint: 'queued и running',
|
||||
onClick: () => window.location.assign('/operations?tab=jobs'),
|
||||
onClick: () => navigate({ to: '/operations', search: { tab: 'jobs' } }),
|
||||
},
|
||||
]
|
||||
|
||||
const activityLoading =
|
||||
refreshing && jobs.length === 0 && revisions.length === 0 && peers.length === 0 && speakers.length === 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
<PageHeader
|
||||
title="Обзор"
|
||||
description={
|
||||
@@ -162,46 +169,61 @@ function DashboardComponent() {
|
||||
loadError={modulesQ.isError || peersQ.isError ? 'Некоторые данные не загружены' : null}
|
||||
/>
|
||||
|
||||
{initialLoading ? <SectionCardsSkeleton count={5} /> : <SectionCards items={items} />}
|
||||
{initialLoading ? <SectionCardsSkeleton count={5} /> : <SectionCards items={items} className="gap-3" />}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<RecentJobsCard jobs={jobs} nameById={nameById} loading={refreshing} />
|
||||
<RecentRevisionsCard revisions={revisions} loading={refreshing} />
|
||||
<NetworkStatusCard peers={peers} speakers={speakers} loading={refreshing} />
|
||||
<Frame spacing="sm" className="h-full">
|
||||
<FramePanel className="flex h-full flex-col p-0">
|
||||
<FrameHeader className="border-b">
|
||||
<FrameTitle>Недавние задачи</FrameTitle>
|
||||
<FrameDescription>Последние фоновые операции</FrameDescription>
|
||||
</FrameHeader>
|
||||
{activityLoading ? (
|
||||
<Skeleton className="m-3 h-24 w-auto" />
|
||||
) : (
|
||||
<DashboardRecentJobsGrid jobs={jobs} nameById={nameById} isLoading={refreshing} />
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<Frame spacing="sm" className="h-full">
|
||||
<FramePanel className="flex h-full flex-col p-0">
|
||||
<FrameHeader className="border-b">
|
||||
<FrameTitle>Последние ревизии</FrameTitle>
|
||||
<FrameDescription>История конфигураций</FrameDescription>
|
||||
</FrameHeader>
|
||||
{activityLoading ? (
|
||||
<Skeleton className="m-3 h-24 w-auto" />
|
||||
) : (
|
||||
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<Frame spacing="sm" className="h-full">
|
||||
<FramePanel className="flex h-full flex-col p-0">
|
||||
<FrameHeader className="border-b">
|
||||
<FrameTitle>Состояние сети</FrameTitle>
|
||||
<FrameDescription>BGP-сессии и спикеры</FrameDescription>
|
||||
</FrameHeader>
|
||||
{activityLoading ? (
|
||||
<Skeleton className="m-3 h-24 w-auto" />
|
||||
) : (
|
||||
<DashboardNetworkPanel peers={peers} speakers={speakers} />
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Быстрые действия</CardTitle>
|
||||
<CardDescription>Частые переходы к настройке и деплою</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2 p-4">
|
||||
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
|
||||
<Plus className="size-4" />
|
||||
Создать модуль
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/directories')}>
|
||||
<Tags className="size-4" />
|
||||
Добавить community
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/network?tab=overview')}>
|
||||
<Network className="size-4" />
|
||||
Сеть
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/network?tab=peers')}>
|
||||
<Share2 className="size-4" />
|
||||
Добавить пира
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/operations')}>
|
||||
<Play className="size-4" />
|
||||
Деплой (Apply)
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/monitoring')}>
|
||||
<Gauge className="size-4" />
|
||||
Мониторинг
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Frame spacing="sm">
|
||||
<FramePanel className="p-0">
|
||||
<FrameHeader className="border-b">
|
||||
<FrameTitle>Быстрые действия</FrameTitle>
|
||||
<FrameDescription>Частые переходы к настройке и деплою</FrameDescription>
|
||||
</FrameHeader>
|
||||
<DashboardQuickActions />
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -255,146 +277,3 @@ function HealthAlert({
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
function RecentJobsCard({
|
||||
jobs,
|
||||
nameById,
|
||||
loading,
|
||||
}: {
|
||||
jobs: import('@/types/api').JobRow[]
|
||||
nameById: Map<string, string>
|
||||
loading: boolean
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Недавние задачи</CardTitle>
|
||||
<CardDescription>Последние фоновые операции</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3">
|
||||
{loading && jobs.length === 0 ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : jobs.length === 0 ? (
|
||||
<p className="px-2 py-6 text-center text-sm text-muted-foreground">Нет задач</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{jobs.slice(0, 8).map((j) => (
|
||||
<li
|
||||
key={j.job_id}
|
||||
className="flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/40"
|
||||
>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate font-mono text-xs text-muted-foreground">{j.kind}</span>
|
||||
<span className="truncate text-xs">
|
||||
{j.meta?.module_id ? nameById.get(String(j.meta.module_id)) ?? '' : ''}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
j.status === 'succeeded'
|
||||
? 'text-xs text-success'
|
||||
: j.status === 'failed'
|
||||
? 'text-xs text-destructive'
|
||||
: 'text-xs text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{j.status}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function RecentRevisionsCard({
|
||||
revisions,
|
||||
loading,
|
||||
}: {
|
||||
revisions: import('@/types/api').RevisionRow[]
|
||||
loading: boolean
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Последние ревизии</CardTitle>
|
||||
<CardDescription>История конфигураций</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3">
|
||||
{loading && revisions.length === 0 ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : revisions.length === 0 ? (
|
||||
<p className="px-2 py-6 text-center text-sm text-muted-foreground">Нет ревизий</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{revisions.slice(0, 8).map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
className="flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/40"
|
||||
>
|
||||
<span className="truncate font-mono text-xs text-muted-foreground">{r.id.slice(0, 10)}…</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(r.created_at).toLocaleString('ru-RU')}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function NetworkStatusCard({
|
||||
peers,
|
||||
speakers,
|
||||
loading,
|
||||
}: {
|
||||
peers: import('@/types/api').PeerRow[]
|
||||
speakers: import('@/types/api').SpeakerRow[]
|
||||
loading: boolean
|
||||
}) {
|
||||
const m = aggregateNetworkMetrics(peers, speakers)
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Состояние сети</CardTitle>
|
||||
<CardDescription>BGP-сессии и спикеры</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3">
|
||||
{loading && peers.length === 0 && speakers.length === 0 ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 px-1 py-1 text-sm">
|
||||
<Row label="Пиры Established" value={`${m.peersEstablished} / ${m.peersEnabled}`} />
|
||||
<Row label="Спикеры online" value={`${m.speakersOnline} / ${m.speakersTotal}`} />
|
||||
{m.peersMismatch > 0 ? (
|
||||
<Row label="Mismatches" value={String(m.peersMismatch)} variant="warning" />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
variant = 'default',
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
variant?: 'default' | 'warning'
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className={variant === 'warning' ? 'font-medium text-warning-foreground' : 'font-medium tabular-nums'}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,19 +3,12 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { BookText, Globe, Info, RefreshCw, Tags } from 'lucide-react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Badge } from '@evobgp/ui/components/badge'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { DirectoriesCommunitiesGrid } from '@/components/directories/directories-communities-grid'
|
||||
import { DirectoriesDohGrid } from '@/components/directories/directories-doh-grid'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||
@@ -94,91 +87,50 @@ function DirectoriesComponent() {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="communities" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Сообщества BGP</CardTitle>
|
||||
<CardDescription>Теги для префиксов в фильтрах BIRD</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={communities}
|
||||
isLoading={communitiesQ.isLoading}
|
||||
isError={communitiesQ.isError}
|
||||
error={communitiesQ.error}
|
||||
empty={communities.length === 0}
|
||||
emptyTitle="Нет сообществ"
|
||||
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||
onRetry={() => communitiesQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Название</TableHead>
|
||||
<TableHead>Значение</TableHead>
|
||||
<TableHead>Тип</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-medium">{c.title}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{c.community}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">community</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DataGridCard
|
||||
title="Сообщества BGP"
|
||||
description="Теги для префиксов в фильтрах BIRD"
|
||||
>
|
||||
<QueryState
|
||||
data={communities}
|
||||
isLoading={communitiesQ.isLoading}
|
||||
isError={communitiesQ.isError}
|
||||
error={communitiesQ.error}
|
||||
empty={communities.length === 0}
|
||||
emptyTitle="Нет сообществ"
|
||||
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||
onRetry={() => communitiesQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<DirectoriesCommunitiesGrid
|
||||
items={items}
|
||||
isLoading={communitiesQ.isFetching && !communitiesQ.isLoading}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="doh" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">DoH профили</CardTitle>
|
||||
<CardDescription>Резолверы DNS-over-HTTPS для доменных модулей</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={dohProfiles}
|
||||
isLoading={dohQ.isLoading}
|
||||
isError={dohQ.isError}
|
||||
error={dohQ.error}
|
||||
empty={dohProfiles.length === 0}
|
||||
emptyTitle="Нет DoH профилей"
|
||||
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||
onRetry={() => dohQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Название</TableHead>
|
||||
<TableHead>URL</TableHead>
|
||||
<TableHead>По умолчанию</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium">{p.name ?? p.url}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{p.url}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">—</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DataGridCard title="DoH профили" description="Резолверы DNS-over-HTTPS для доменных модулей">
|
||||
<QueryState
|
||||
data={dohProfiles}
|
||||
isLoading={dohQ.isLoading}
|
||||
isError={dohQ.isError}
|
||||
error={dohQ.error}
|
||||
empty={dohProfiles.length === 0}
|
||||
emptyTitle="Нет DoH профилей"
|
||||
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||
onRetry={() => dohQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<DirectoriesDohGrid
|
||||
items={items}
|
||||
isLoading={dohQ.isFetching && !dohQ.isLoading}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
import { Link, createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Boxes, Plus, RefreshCw } from 'lucide-react'
|
||||
import { Plus, RefreshCw } from 'lucide-react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { ModulesListGrid } from '@/components/modules/modules-list-grid'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { TruncatedText } from '@/components/truncated-text'
|
||||
import { modulesListQueryOptions } from '@/queries/modules'
|
||||
import type { ModuleRow } from '@/types/api'
|
||||
|
||||
export const Route = createFileRoute('/_auth/modules/')({
|
||||
component: ModulesListComponent,
|
||||
@@ -48,76 +38,34 @@ function ModulesListComponent() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between border-b py-3">
|
||||
<CardTitle className="text-base">Все модули</CardTitle>
|
||||
<DataGridCard
|
||||
title="Все модули"
|
||||
actions={
|
||||
<Button size="sm" render={<Link to="/modules/new" />}>
|
||||
<Plus />
|
||||
Создать
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={query.data?.items}
|
||||
isLoading={query.isLoading}
|
||||
isError={query.isError}
|
||||
error={query.error}
|
||||
empty={query.data?.items?.length === 0}
|
||||
emptyTitle="Нет модулей"
|
||||
emptyDescription="Создайте первый модуль (AS, CDN, домены, IP)."
|
||||
skeleton={<TableSkeleton rows={6} cols={5} />}
|
||||
onRetry={() => query.refetch()}
|
||||
>
|
||||
{(items) => <ModulesTable items={items} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={query.data?.items}
|
||||
isLoading={query.isLoading}
|
||||
isError={query.isError}
|
||||
error={query.error}
|
||||
empty={query.data?.items?.length === 0}
|
||||
emptyTitle="Нет модулей"
|
||||
emptyDescription="Создайте первый модуль (AS, CDN, домены, IP)."
|
||||
skeleton={<TableSkeleton rows={6} cols={5} />}
|
||||
onRetry={() => query.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<ModulesListGrid
|
||||
items={items}
|
||||
isLoading={query.isFetching && !query.isLoading}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModulesTable({ items }: { items: ModuleRow[] }) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Название</TableHead>
|
||||
<TableHead>Тип</TableHead>
|
||||
<TableHead>Приоритет</TableHead>
|
||||
<TableHead>Состояние</TableHead>
|
||||
<TableHead>Обновлено</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((m) => (
|
||||
<TableRow
|
||||
key={m.id}
|
||||
className="cursor-pointer hover:bg-muted/40"
|
||||
onClick={() => (window.location.href = `/modules/${m.id}`)}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Boxes className="size-4 text-muted-foreground" />
|
||||
<TruncatedText className="max-w-[280px]">{m.name}</TruncatedText>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{m.type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm tabular-nums">{m.priority}</TableCell>
|
||||
<TableCell>
|
||||
{m.enabled ? (
|
||||
<Badge variant="success">включён</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">выключен</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{m.last_refreshed_at ? new Date(m.last_refreshed_at).toLocaleString('ru-RU') : '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Activity, AlertTriangle, Bird, Database, Gauge, HardDrive, HeartPulse, Info, ListTodo, RefreshCw, ShieldCheck } from 'lucide-react'
|
||||
import { Activity, AlertTriangle, Bird, Database, Gauge, HeartPulse, Info, ListTodo, RefreshCw } from 'lucide-react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Badge } from '@evobgp/ui/components/badge'
|
||||
@@ -8,15 +8,8 @@ import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { Separator } from '@evobgp/ui/components/separator'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
|
||||
import { MonitoringReadyGrid } from '@/components/monitoring/monitoring-ready-grid'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||
@@ -147,7 +140,7 @@ function MonitoringComponent() {
|
||||
skeleton={<div className="h-40" />}
|
||||
onRetry={() => readyQ.refetch()}
|
||||
>
|
||||
{(ready) => <ReadyTable health={healthQ.data} ready={ready} />}
|
||||
{(ready) => <MonitoringReadyGrid health={healthQ.data} ready={ready} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -342,84 +335,6 @@ function overallHint(input: OverallInput): string {
|
||||
return 'Все системы работают в штатном режиме'
|
||||
}
|
||||
|
||||
function ReadyTable({
|
||||
health,
|
||||
ready,
|
||||
}: {
|
||||
health?: { ok?: boolean; status?: string; error?: string } | null
|
||||
ready: ReadyStatus
|
||||
}) {
|
||||
const checks = ready.checks ?? {}
|
||||
const iconByKey: Record<string, typeof Database> = {
|
||||
postgres: Database,
|
||||
store: HardDrive,
|
||||
jobs: ListTodo,
|
||||
}
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[55%]">Проверка</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<HeartPulse className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Liveness</p>
|
||||
<p className="text-xs text-muted-foreground">/v1/health</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={health?.ok ? 'default' : 'destructive'}>
|
||||
{health?.ok ? 'OK' : 'Ошибка'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Readiness</p>
|
||||
<p className="text-xs text-muted-foreground">/v1/ready</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ready.status === 'ok' ? 'default' : 'secondary'}>
|
||||
{ready.status ?? '—'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{Object.entries(checks).map(([key, value]) => {
|
||||
const ok = typeof value === 'boolean' ? value : value?.ok !== false
|
||||
const Icon = iconByKey[key] ?? ListTodo
|
||||
return (
|
||||
<TableRow key={key}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{key}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ok ? 'default' : 'destructive'}>{ok ? 'OK' : 'Ошибка'}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
|
||||
if (!bird.birdc_configured) {
|
||||
return (
|
||||
|
||||
@@ -5,17 +5,10 @@ import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { Info, RefreshCw } from 'lucide-react'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { NetworkPeersGrid } from '@/components/network/network-peers-grid'
|
||||
import { NetworkSpeakersGrid } from '@/components/network/network-speakers-grid'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
@@ -115,47 +108,47 @@ function NetworkComponent() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="peers" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Пиры</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={peers}
|
||||
isLoading={peersQ.isLoading}
|
||||
isError={peersQ.isError}
|
||||
error={peersQ.error}
|
||||
empty={peers.length === 0}
|
||||
emptyTitle="Нет пиров"
|
||||
skeleton={<TableSkeleton rows={6} cols={4} />}
|
||||
onRetry={() => peersQ.refetch()}
|
||||
>
|
||||
{(items) => <PeersTable items={items} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DataGridCard title="Пиры">
|
||||
<QueryState
|
||||
data={peers}
|
||||
isLoading={peersQ.isLoading}
|
||||
isError={peersQ.isError}
|
||||
error={peersQ.error}
|
||||
empty={peers.length === 0}
|
||||
emptyTitle="Нет пиров"
|
||||
skeleton={<TableSkeleton rows={6} cols={4} />}
|
||||
onRetry={() => peersQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<NetworkPeersGrid
|
||||
items={items}
|
||||
isLoading={peersQ.isFetching && !peersQ.isLoading}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="speakers" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Спикеры</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={speakers}
|
||||
isLoading={speakersQ.isLoading}
|
||||
isError={speakersQ.isError}
|
||||
error={speakersQ.error}
|
||||
empty={speakers.length === 0}
|
||||
emptyTitle="Нет спикеров"
|
||||
skeleton={<TableSkeleton rows={6} cols={4} />}
|
||||
onRetry={() => speakersQ.refetch()}
|
||||
>
|
||||
{(items) => <SpeakersTable items={items} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DataGridCard title="Спикеры">
|
||||
<QueryState
|
||||
data={speakers}
|
||||
isLoading={speakersQ.isLoading}
|
||||
isError={speakersQ.isError}
|
||||
error={speakersQ.error}
|
||||
empty={speakers.length === 0}
|
||||
emptyTitle="Нет спикеров"
|
||||
skeleton={<TableSkeleton rows={6} cols={4} />}
|
||||
onRetry={() => speakersQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<NetworkSpeakersGrid
|
||||
items={items}
|
||||
isLoading={speakersQ.isFetching && !speakersQ.isLoading}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="control-plane" className="mt-4">
|
||||
@@ -196,78 +189,3 @@ function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PeersTable({ items }: { items: import('@/types/api').PeerRow[] }) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Имя</TableHead>
|
||||
<TableHead>Neighbor</TableHead>
|
||||
<TableHead>ASN</TableHead>
|
||||
<TableHead>Состояние</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium">{p.name ?? p.neighbor}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{p.neighbor}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{p.remote_asn ?? '—'}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={p.session_state} />
|
||||
{p.session_mismatch ? (
|
||||
<Badge variant="warning" className="ml-1">
|
||||
mismatch
|
||||
</Badge>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
function SpeakersTable({ items }: { items: import('@/types/api').SpeakerRow[] }) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Endpoint</TableHead>
|
||||
<TableHead>Роль</TableHead>
|
||||
<TableHead>Agent</TableHead>
|
||||
<TableHead>BGP</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="font-mono text-xs">{s.endpoint}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{s.role}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{s.live?.agent_ok === true ? (
|
||||
<StatusBadge status="ok" label="online" />
|
||||
) : s.live?.agent_ok === false ? (
|
||||
<StatusBadge status="error" label="offline" />
|
||||
) : (
|
||||
<Badge variant="outline">—</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{s.live ? (
|
||||
<span className="text-xs">
|
||||
{s.live.bgp_established ?? 0} / {s.live.bgp_sessions_total ?? 0}
|
||||
</span>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,15 +15,10 @@ import {
|
||||
SelectValue,
|
||||
} from '@evobgp/ui/components/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid'
|
||||
import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||
@@ -33,7 +28,6 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { operationsJobsQueryOptions, operationsRevisionsQueryOptions, operationsDiffQueryOptions } from '@/queries/operations'
|
||||
import { moduleNameById, overviewModulesQueryOptions } from '@/queries/overview'
|
||||
import { apiMutate, waitForJob } from '@/lib/api-client'
|
||||
import type { JobRow } from '@/types/api'
|
||||
|
||||
export const Route = createFileRoute('/_auth/operations')({
|
||||
component: OperationsComponent,
|
||||
@@ -177,24 +171,25 @@ function OperationsComponent() {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="revisions" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">История ревизий</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={revisions}
|
||||
isLoading={revisionsQ.isLoading}
|
||||
isError={revisionsQ.isError}
|
||||
error={revisionsQ.error}
|
||||
empty={revisions.length === 0}
|
||||
emptyTitle="Нет ревизий"
|
||||
onRetry={() => revisionsQ.refetch()}
|
||||
>
|
||||
{(items) => <RevisionsTable items={items} qc={qc} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DataGridCard title="История ревизий">
|
||||
<QueryState
|
||||
data={revisions}
|
||||
isLoading={revisionsQ.isLoading}
|
||||
isError={revisionsQ.isError}
|
||||
error={revisionsQ.error}
|
||||
empty={revisions.length === 0}
|
||||
emptyTitle="Нет ревизий"
|
||||
onRetry={() => revisionsQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<OperationsRevisionsGrid
|
||||
items={items}
|
||||
qc={qc}
|
||||
isLoading={revisionsQ.isFetching && !revisionsQ.isLoading}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="diff" className="mt-4">
|
||||
@@ -202,168 +197,32 @@ function OperationsComponent() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="jobs" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Задачи</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={jobs}
|
||||
isLoading={jobsQ.isLoading}
|
||||
isError={jobsQ.isError}
|
||||
error={jobsQ.error}
|
||||
empty={jobs.length === 0}
|
||||
emptyTitle="Нет задач"
|
||||
onRetry={() => jobsQ.refetch()}
|
||||
>
|
||||
{(items) => <JobsTable items={items} nameById={nameById} qc={qc} />}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DataGridCard title="Задачи">
|
||||
<QueryState
|
||||
data={jobs}
|
||||
isLoading={jobsQ.isLoading}
|
||||
isError={jobsQ.isError}
|
||||
error={jobsQ.error}
|
||||
empty={jobs.length === 0}
|
||||
emptyTitle="Нет задач"
|
||||
onRetry={() => jobsQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<OperationsJobsGrid
|
||||
items={items}
|
||||
nameById={nameById}
|
||||
qc={qc}
|
||||
isLoading={jobsQ.isFetching && !jobsQ.isLoading}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RevisionsTable({
|
||||
items,
|
||||
qc,
|
||||
}: {
|
||||
items: import('@/types/api').RevisionRow[]
|
||||
qc: import('@tanstack/react-query').QueryClient
|
||||
}) {
|
||||
const rollbackMutation = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiMutate(`/v1/revisions/${id}/rollback`, 'POST', {}).then(() => id),
|
||||
onSuccess: () => {
|
||||
toast.success('Откат выполнен')
|
||||
void qc.invalidateQueries({ queryKey: ['operations'] })
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось откатить'),
|
||||
})
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Создана</TableHead>
|
||||
<TableHead>Префиксов</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-mono text-xs">{r.id.slice(0, 12)}…</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{new Date(r.created_at).toLocaleString('ru-RU')}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm tabular-nums">
|
||||
{r.materialized_prefix_count}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" className="text-destructive">
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
title={`Откатиться к ревизии ${r.id.slice(0, 8)}…?`}
|
||||
description="Будет создана новая ревизия на основе выбранной. Требуется роль operator."
|
||||
confirmLabel="Откатить"
|
||||
destructive
|
||||
onConfirm={() => rollbackMutation.mutate(r.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
function JobsTable({
|
||||
items,
|
||||
nameById,
|
||||
qc,
|
||||
}: {
|
||||
items: JobRow[]
|
||||
nameById: Map<string, string>
|
||||
qc: import('@tanstack/react-query').QueryClient
|
||||
}) {
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: (jobId: string) => apiMutate(`/v1/jobs/${jobId}/cancel`, 'POST', {}),
|
||||
onSuccess: () => {
|
||||
toast.success('Задача отменена')
|
||||
void qc.invalidateQueries({ queryKey: ['operations'] })
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отменить'),
|
||||
})
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Вид</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Создана</TableHead>
|
||||
<TableHead>Завершена</TableHead>
|
||||
<TableHead className="w-20" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((j) => (
|
||||
<TableRow key={j.job_id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span>{j.kind}</span>
|
||||
{j.meta?.module_id ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{nameById.get(String(j.meta.module_id)) ?? String(j.meta.module_id)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadgeColored status={j.status} />
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{j.created_at ? new Date(j.created_at).toLocaleString('ru-RU') : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{j.finished_at ? new Date(j.finished_at).toLocaleString('ru-RU') : '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{j.status === 'running' || j.status === 'queued' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive"
|
||||
onClick={() => cancelMutation.mutate(j.job_id)}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadgeColored({ status }: { status: string }) {
|
||||
const cls =
|
||||
status === 'succeeded'
|
||||
? 'text-success'
|
||||
: status === 'failed' || status === 'cancelled'
|
||||
? 'text-destructive'
|
||||
: 'text-info'
|
||||
return <span className={`text-sm font-medium ${cls}`}>{status}</span>
|
||||
}
|
||||
|
||||
function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[] }) {
|
||||
const [a, setA] = useState('')
|
||||
const [b, setB] = useState('')
|
||||
|
||||
@@ -5,24 +5,16 @@ import { toast } from 'sonner'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||||
import { Badge } from '@evobgp/ui/components/badge'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { ScheduleJobsGrid } from '@/components/schedule/schedule-jobs-grid'
|
||||
import { ScheduleModulesGrid } from '@/components/schedule/schedule-modules-grid'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
|
||||
import { operationsJobsQueryOptions } from '@/queries/operations'
|
||||
import { modulesListQueryOptions } from '@/queries/modules'
|
||||
@@ -108,82 +100,33 @@ function ScheduleComponent() {
|
||||
|
||||
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Модули</CardTitle>
|
||||
<CardDescription>Расписание обновления и ручной запуск ingest</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={modules}
|
||||
isLoading={modulesQ.isLoading}
|
||||
isError={modulesQ.isError}
|
||||
error={modulesQ.error}
|
||||
empty={modules.length === 0}
|
||||
emptyTitle="Нет модулей"
|
||||
onRetry={() => modulesQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Модуль</TableHead>
|
||||
<TableHead>Тип</TableHead>
|
||||
<TableHead>Расписание</TableHead>
|
||||
<TableHead>Обновлено</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead className="w-32 text-right" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((m) => (
|
||||
<TableRow key={m.id}>
|
||||
<TableCell className="font-medium">{m.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{m.type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{m.cron_expr ?? (m.refresh_interval_sec ? `${m.refresh_interval_sec}s` : '—')}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{m.last_refreshed_at ? new Date(m.last_refreshed_at).toLocaleString('ru-RU') : '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{m.enabled ? (
|
||||
<Badge variant="default">Вкл</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Выкл</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
loading={!!refreshing[m.id]}
|
||||
onClick={() => refreshMutation.mutate(m.id)}
|
||||
>
|
||||
<RefreshCw />
|
||||
Обновить
|
||||
</LoadingButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DataGridCard
|
||||
title="Модули"
|
||||
description="Расписание обновления и ручной запуск ingest"
|
||||
>
|
||||
<QueryState
|
||||
data={modules}
|
||||
isLoading={modulesQ.isLoading}
|
||||
isError={modulesQ.isError}
|
||||
error={modulesQ.error}
|
||||
empty={modules.length === 0}
|
||||
emptyTitle="Нет модулей"
|
||||
onRetry={() => modulesQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<ScheduleModulesGrid
|
||||
items={items}
|
||||
refreshing={refreshing}
|
||||
onRefresh={(id) => refreshMutation.mutate(id)}
|
||||
isLoading={modulesQ.isFetching && !modulesQ.isLoading}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Задачи</CardTitle>
|
||||
<CardDescription>Последние задачи из API</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<JobsTabs jobs={jobs} loading={jobsQ.isLoading} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DataGridCard title="Задачи" description="Последние задачи из API">
|
||||
<JobsTabs jobs={jobs} loading={jobsQ.isLoading} />
|
||||
</DataGridCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -196,68 +139,20 @@ function JobsTabs({ jobs, loading }: { jobs: JobRow[]; loading: boolean }) {
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="all">
|
||||
<TabsList>
|
||||
<TabsList className="m-3 mb-0">
|
||||
<TabsTrigger value="all">Все ({jobs.length})</TabsTrigger>
|
||||
<TabsTrigger value="refresh">Обновление ({refresh.length})</TabsTrigger>
|
||||
<TabsTrigger value="failed">С ошибкой ({failed.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="all" className="mt-0">
|
||||
<JobsTable items={jobs} loading={loading} />
|
||||
<ScheduleJobsGrid items={jobs} isLoading={loading} />
|
||||
</TabsContent>
|
||||
<TabsContent value="refresh" className="mt-0">
|
||||
<JobsTable items={refresh} loading={loading} />
|
||||
<ScheduleJobsGrid items={refresh} isLoading={loading} />
|
||||
</TabsContent>
|
||||
<TabsContent value="failed" className="mt-0">
|
||||
<JobsTable items={failed} loading={loading} />
|
||||
<ScheduleJobsGrid items={failed} isLoading={loading} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
function JobsTable({ items, loading }: { items: JobRow[]; loading: boolean }) {
|
||||
if (loading) return <div className="p-6 text-center text-sm text-muted-foreground">Загрузка…</div>
|
||||
if (items.length === 0)
|
||||
return <div className="p-6 text-center text-sm text-muted-foreground">Нет задач</div>
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Вид</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Создана</TableHead>
|
||||
<TableHead>Завершена</TableHead>
|
||||
<TableHead>Ошибка</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((j) => (
|
||||
<TableRow key={j.job_id}>
|
||||
<TableCell className="font-medium">{j.kind}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
j.status === 'succeeded'
|
||||
? 'default'
|
||||
: j.status === 'failed'
|
||||
? 'destructive'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{j.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{j.created_at ? new Date(j.created_at).toLocaleString('ru-RU') : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{j.finished_at ? new Date(j.finished_at).toLocaleString('ru-RU') : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate text-xs text-destructive">
|
||||
{j.error ?? ''}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,15 +16,9 @@ import {
|
||||
SelectValue,
|
||||
} from '@evobgp/ui/components/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evobgp/ui/components/table'
|
||||
|
||||
|
||||
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
@@ -366,22 +360,10 @@ function TenantSettingsComponent() {
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Ключ</TableHead>
|
||||
<TableHead>Значение</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono text-xs">{row.key}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.value}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<SettingsKvGrid
|
||||
items={items}
|
||||
isLoading={settingsQ.isFetching && !settingsQ.isLoading}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/firewall/firewall-clients-grid.tsx","./src/components/firewall/firewall-rules-grid.tsx","./src/components/layout/app-shell.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/lib/api-client.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/access/api-key-labels.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/data-grid-shell.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-quick-actions.tsx","./src/components/dashboard/dashboard-recent-jobs-grid.tsx","./src/components/dashboard/dashboard-recent-revisions-grid.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/firewall/firewall-clients-grid.tsx","./src/components/firewall/firewall-rules-grid.tsx","./src/components/layout/app-shell.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/frame.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/components/schedule/schedule-jobs-grid.tsx","./src/components/schedule/schedule-modules-grid.tsx","./src/components/settings/settings-kv-grid.tsx","./src/lib/api-client.ts","./src/lib/data-grid-defaults.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/access/api-key-labels.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user