Compare commits

...
15 Commits
Author SHA1 Message Date
Denozordec 940f8892f3 refactor: enhance UI components with PanelCard integration and improved layout consistency
CI / changes (push) Successful in 13s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m6s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m17s
Updated multiple components to utilize the new PanelCard for better organization and presentation of content. Refactored DataGridShell, DataGridCard, and various analytics components to streamline layouts and enhance user experience. Adjusted styles for consistency across components, including pagination and toolbar elements, ensuring a cohesive interface throughout the application.
2026-07-09 17:23:17 +07:00
Denozordec ac727ad1e3 refactor: update dialog components and enhance UI consistency
CI / changes (push) Successful in 13s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m5s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m20s
Refactored ConfirmDialog and FormDrawer components to improve layout and user experience. Integrated new DrawerActionsFooter for better action handling in dialogs. Updated styles for consistency across components, including adjustments to the skeletons and analytics card layouts. Removed deprecated alert components from various routes to streamline the codebase and enhance clarity in the UI.
2026-07-09 16:54:18 +07:00
Denozordec c265c06f93 refactor: replace dialog components with FormDrawer for improved UI consistency
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 50s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m58s
Updated multiple components to utilize the new FormDrawer for modal dialogs, enhancing the user interface and streamlining the layout. This change includes the ConfirmDialog, ApiKeyCreateDialog, FirewallRuleCreateDialog, and others, ensuring a more cohesive and modern design across the application. Additionally, refactored the ConfirmDialog to improve confirmation handling and user feedback during actions.
2026-07-09 14:09:40 +07:00
Denozordec d434eb0d94 feat: enhance UI components with DataGridCard integration and improved error handling
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 50s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m21s
Refactored multiple components to utilize the new DataGridCard for better organization and presentation of data. Updated the FirewallPage and Monitoring components to enhance loading states and error handling using QueryState. Added success and error notifications for firewall rule creation, improving user feedback. This update streamlines the user experience and ensures a more consistent interface across the application.
2026-07-09 13:45:53 +07:00
Denozordec d0bd4d661d feat: refactor components to enhance UI consistency and functionality
CI / changes (push) Successful in 12s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m6s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m27s
Updated multiple components to improve user interface consistency by replacing traditional badge implementations with the new CategoryBadge and DataGridPrimaryCell components. Enhanced the StatusBadge component to support additional status variants and integrated it across various grids, including AccessApiKeysGrid, DashboardRecentJobsGrid, and OperationsJobsGrid. This refactor streamlines the presentation of data and improves the overall user experience across the application.
2026-07-09 13:21:09 +07:00
Denozordec 642db1a83a feat: enhance BadgeTabs and Tabs components with utility class integration
Refactored the BadgeTabs component to utilize the `cn` utility for class name management, improving layout consistency. Updated the Tabs component to conditionally apply flex direction based on orientation, enhancing responsiveness. These changes streamline the styling process and ensure a more cohesive user interface across tabbed components.
2026-07-09 13:04:46 +07:00
Denozordec f66d68d1c7 feat: refactor data grid components to utilize DataGridSection for enhanced functionality
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 52s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m16s
Updated multiple components to replace DataGridShell with DataGridSection, integrating search functionality and improved data handling. This change enhances user experience by providing a consistent interface across various grids, including AccessApiKeysGrid, DashboardRecentJobsGrid, and others. Additionally, introduced global filtering capabilities to streamline data retrieval and presentation, ensuring a more efficient user interaction with the data grids.
2026-07-09 12:48:52 +07:00
Denozordec e04fea657c feat: enhance dashboard and monitoring components with new analytics skeletons
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 49s
CI / go (push) Successful in 1m2s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 3m57s
Added the AnalyticsDashboardSkeleton component to improve loading states in the dashboard and monitoring pages. Refactored the dashboard to utilize the new skeleton for initial loading, replacing the previous SectionCardsSkeleton. Updated the overview queries to increase job limit from 10 to 100 for better data handling. Enhanced the network and operations components to incorporate new analytics cards, streamlining the user experience and improving data presentation.
2026-07-09 12:09:08 +07:00
Denozordec 452f6b2db0 feat: refactor components to utilize SelectField for improved UI consistency
Updated various components to replace traditional select implementations with the new SelectField component. This change enhances the user interface by providing a more consistent layout and improved accessibility. Additionally, refactored the dashboard and operations pages to utilize DataGridCard for better organization of content, streamlining the overall user experience.
2026-07-09 11:53:43 +07:00
Denozordec b321aa5321 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
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.
2026-07-09 02:31:50 +07:00
Denozordec 5d1102b497 feat(firewall): refactor FirewallPage to use new grid components and improve loading states
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 43s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m13s
Updated the FirewallPage component to replace the existing table implementations with FirewallClientsGrid and FirewallRulesGrid for better performance and user experience. Integrated QueryState for handling loading and error states, enhancing the UI responsiveness. Removed deprecated ClientsTable and RulesTable components to streamline the codebase.
2026-07-09 01:41:09 +07:00
Denozordec 5edbd656ba feat(firewall): add packet statistics tracking for firewall clients
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 29s
CI / web (push) Successful in 1m6s
CI / go (push) Successful in 1m23s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 4m42s
Enhanced the firewall client functionality by introducing packet statistics tracking, including the cumulative count of packets dropped and accepted. Updated the API to support these new fields and modified the database schema accordingly. Improved the firewall scripts to collect and report packet statistics, ensuring better visibility into client performance. Adjusted the UI components to display packet counts in the clients table, enhancing user experience and monitoring capabilities.
2026-07-09 00:50:38 +07:00
Denozordec 4a4c11c6bf feat(firewall): rename revoke function to delete and update related UI components
Refactored the revoke functionality for firewall clients to be more accurately represented as a delete operation. Updated the corresponding API call to use the DELETE method and modified the UI components to reflect this change, including confirmation dialogs and success messages. Adjusted tests to ensure the new delete functionality works as intended.
2026-07-09 00:27:50 +07:00
Denozordec e51999c908 feat(firewall): add revoke functionality for firewall clients and enhance status badge
CI / changes (push) Successful in 13s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 30s
CI / web (push) Successful in 56s
CI / go (push) Successful in 1m11s
CI / bird2 (push) Successful in 27s
CI / release (push) Successful in 4m19s
Implemented the ability to revoke approved firewall clients and reject pending requests through new API endpoints. Updated the StatusBadge component to include additional status variants for 'approved', 'revoked', 'pending', and 'block'. Enhanced the FirewallPage UI to support client revocation and rejection actions, integrating confirmation dialogs for user interactions. Updated tests to ensure proper functionality of the new revoke feature.
2026-07-08 23:27:29 +07:00
Denozordec b7f7669685 feat(firewall): improve blocklist parsing and nft element addition
CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 42s
CI / go (push) Successful in 1m1s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m17s
Enhanced the blocklist parsing function to log when the blocklist file is empty. Introduced new helper functions `nft_join_elements` and `nft_add_v4_chunk` to streamline the addition of elements to the nftables, allowing for batch processing and improved error handling. Adjusted the chunk size for element addition to optimize performance. Updated logging to provide better visibility into the blocklist processing and applied prefixes.
2026-07-08 22:02:59 +07:00
120 changed files with 7502 additions and 2812 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"pid": 39884, "pid": 43636,
"version": "0.9.9", "version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da", "socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
"startedAt": 1783489774882 "startedAt": 1783570299043
} }
@@ -1,25 +1,14 @@
import { useState } from 'react' 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 { 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 { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog'
import { ApiKeyTokenDialog } from '@/components/access/api-key-token-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 { QueryState } from '@/components/query-state'
import { StatusBadge } from '@/components/status-badge'
import { TableSkeleton } from '@/components/skeletons' import { TableSkeleton } from '@/components/skeletons'
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
import { useRevokeApiKeyMutation, useRotateApiKeyMutation } from '@/queries/api-keys' import { useRevokeApiKeyMutation, useRotateApiKeyMutation } from '@/queries/api-keys'
import type { ApiKey, ApiKeyCreated } from '@/types/api' import type { ApiKey, ApiKeyCreated } from '@/types/api'
@@ -58,121 +47,45 @@ export function AccessApiKeysCard({
return ( return (
<> <>
<Card> <DataGridCard
<CardHeader className="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"> title="API-ключи"
<div className="min-w-0 flex-1"> description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
<CardTitle className="text-base">API-ключи</CardTitle> actions={
<CardDescription> <>
Управление ключами tenant. Полный токен показывается только при создании и ротации. <Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
</CardDescription>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" variant="outline" onClick={onRetry} disabled={isLoading}>
<RefreshCw className={isLoading ? 'animate-spin' : ''} /> <RefreshCw className={isLoading ? 'animate-spin' : ''} />
Обновить Обновить
</Button> </Button>
<Button size="sm" onClick={() => setCreateOpen(true)}> <Button size="sm" type="button" onClick={() => setCreateOpen(true)}>
<Plus /> <Plus />
Создать Создать
</Button> </Button>
</div> </>
</CardHeader> }
<CardContent className="p-0"> >
<QueryState <QueryState
data={items} data={items}
isLoading={isLoading} isLoading={isLoading}
isError={isError} isError={isError}
error={error} error={error}
empty={items.length === 0} empty={items.length === 0}
emptyTitle="Нет ключей" emptyTitle="Нет ключей"
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа." emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
skeleton={<TableSkeleton rows={4} cols={6} />} skeleton={<TableSkeleton rows={4} cols={6} />}
onRetry={onRetry} onRetry={onRetry}
> >
{(data) => ( {(data) => (
<Table> <AccessApiKeysGrid
<TableHeader> items={data}
<TableRow> isLoading={isLoading}
<TableHead>Имя</TableHead> onRotate={handleRotated}
<TableHead>Роль</TableHead> onRevoke={(id) => revoke.mutate(id)}
<TableHead>Префикс</TableHead> rotatePending={rotate.isPending}
<TableHead>Статус</TableHead> revokePending={revoke.isPending}
<TableHead>Истекает</TableHead> />
<TableHead>Последнее использование</TableHead> )}
<TableHead className="w-24" /> </QueryState>
</TableRow> </DataGridCard>
</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>
<ApiKeyCreateDialog <ApiKeyCreateDialog
open={createOpen} open={createOpen}
@@ -0,0 +1,157 @@
import { ColumnDef } from '@tanstack/react-table'
import { RefreshCw, Trash2 } from 'lucide-react'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { ApiKey } from '@/types/api'
export function AccessApiKeysGrid({
items,
isLoading = false,
onRotate,
onRevoke,
rotatePending = false,
revokePending = false,
}: {
items: ApiKey[]
isLoading?: boolean
onRotate: (id: string) => void
onRevoke: (id: string) => void
rotatePending?: boolean
revokePending?: boolean
}) {
const columns = useMemo<ColumnDef<ApiKey>[]>(
() => [
{
accessorKey: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => <DataGridPrimaryCell title={row.original.name} accent="primary" />,
meta: { headerTitle: 'Имя' },
},
{
accessorKey: 'role',
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
cell: ({ row }) => (
<CategoryBadge className="font-mono text-xs">{row.original.role}</CategoryBadge>
),
meta: { headerTitle: 'Роль' },
},
{
accessorKey: 'prefix',
header: ({ column }) => <DataGridColumnHeader column={column} title="Префикс" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">{row.original.prefix}</span>
),
meta: { headerTitle: 'Префикс' },
},
{
id: 'status',
enableSorting: false,
header: 'Статус',
cell: ({ row }) =>
row.original.revoked_at ? (
<StatusBadge status="error" label="отозван" />
) : (
<StatusBadge status="active" label="активен" />
),
meta: { headerTitle: 'Статус' },
},
{
id: 'expires_at',
accessorFn: (row) => row.expires_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Истекает" />,
cell: ({ row }) => (
<DataGridMutedCell>{formatApiKeyDate(row.original.expires_at)}</DataGridMutedCell>
),
meta: { headerTitle: 'Истекает' },
},
{
id: 'last_used_at',
accessorFn: (row) => row.last_used_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Последнее использование" />,
cell: ({ row }) => (
<DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell>
),
meta: { headerTitle: 'Последнее использование' },
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }) => {
const k = row.original
return (
<div className="flex gap-1">
<ConfirmDialog
trigger={
<Button
variant="ghost"
size="icon-sm"
type="button"
title="Ротировать"
disabled={!!k.revoked_at || rotatePending}
>
<RefreshCw className="size-3.5" />
</Button>
}
title="Ротировать ключ?"
description="Старый токен перестанет работать сразу."
confirmLabel="Ротировать"
onConfirm={() => onRotate(k.id)}
/>
<ConfirmDialog
trigger={
<Button
variant="ghost"
size="icon-sm"
type="button"
className="text-destructive"
disabled={!!k.revoked_at || revokePending}
title="Отозвать"
>
<Trash2 className="size-3.5" />
</Button>
}
title="Отозвать API-ключ?"
description={`${k.name} (${k.prefix}…)`}
confirmLabel="Отозвать"
destructive
onConfirm={() => onRevoke(k.id)}
/>
</div>
)
},
},
],
[onRevoke, onRotate, revokePending, rotatePending],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) =>
`${row.name} ${row.role} ${row.prefix} ${row.revoked_at ? 'отозван' : 'активен'}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет ключей"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск API-ключей…"
/>
)
}
@@ -2,24 +2,12 @@ import { useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input' import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label' import { Label } from '@evobgp/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { API_KEY_ROLE_ITEMS } from '@/lib/access/api-key-labels' import { API_KEY_ROLE_ITEMS } from '@/lib/access/api-key-labels'
import { useCreateApiKeyMutation } from '@/queries/api-keys' import { useCreateApiKeyMutation } from '@/queries/api-keys'
import type { ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '@/types/api' import type { ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '@/types/api'
@@ -74,59 +62,48 @@ export function ApiKeyCreateDialog({ open, onOpenChange, onCreated }: ApiKeyCrea
} }
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <FormDrawer
<DialogContent className="sm:max-w-sm"> open={open}
<DialogHeader> onOpenChange={handleOpenChange}
<DialogTitle>Новый API-ключ</DialogTitle> title="Новый API-ключ"
</DialogHeader> className="sm:max-w-sm"
<div className="flex flex-col gap-4 py-2"> footer={
<div className="flex flex-col gap-2"> <>
<Label htmlFor="key-name">Имя</Label>
<Input
id="key-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="CI / оператор UI"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="key-role">Роль</Label>
<Select
items={[...API_KEY_ROLE_ITEMS]}
value={role}
onValueChange={(v) => v && setRole(v as ApiKeyRole)}
>
<SelectTrigger id="key-role" className="w-full">
<SelectValue placeholder="Выберите роль" />
</SelectTrigger>
<SelectContent>
{API_KEY_ROLE_ITEMS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="key-expires">Истекает (опционально)</Label>
<Input
id="key-expires"
type="datetime-local"
value={expiresLocal}
onChange={(e) => setExpiresLocal(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}> <Button variant="outline" onClick={() => handleOpenChange(false)}>
Отмена Отмена
</Button> </Button>
<LoadingButton onClick={save} loading={createMutation.isPending}> <LoadingButton onClick={save} loading={createMutation.isPending}>
Создать Создать
</LoadingButton> </LoadingButton>
</DialogFooter> </>
</DialogContent> }
</Dialog> >
<div className="flex flex-col gap-2">
<Label htmlFor="key-name">Имя</Label>
<Input
id="key-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="CI / оператор UI"
/>
</div>
<SelectField
id="key-role"
label="Роль"
items={[...API_KEY_ROLE_ITEMS]}
value={role}
placeholder="Выберите роль"
onValueChange={(v) => v && setRole(v as ApiKeyRole)}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="key-expires">Истекает (опционально)</Label>
<Input
id="key-expires"
type="datetime-local"
value={expiresLocal}
onChange={(e) => setExpiresLocal(e.target.value)}
/>
</div>
</FormDrawer>
) )
} }
@@ -0,0 +1,54 @@
import { AlertTriangle, CheckCircle, Info } from 'lucide-react'
import { cn } from '@evobgp/ui/lib/utils'
import { StatusBadge } from '@/components/status-badge'
import type { PlatformActivityItem } from '@/lib/metrics'
const KIND_ICON = {
job: Info,
revision: CheckCircle,
network: AlertTriangle,
} as const
const KIND_ICON_CLASS = {
job: 'text-info',
revision: 'text-success',
network: 'text-warning',
} as const
export function AnalyticsActivityList({
items,
className,
}: {
items: PlatformActivityItem[]
className?: string
}) {
if (items.length === 0) {
return <p className="text-sm text-muted-foreground">Нет недавних событий</p>
}
return (
<ul className={cn('space-y-3', className)}>
{items.map((item) => {
const Icon = KIND_ICON[item.kind]
return (
<li key={item.id} className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2.5">
<span
className={cn(
'mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-muted/60',
KIND_ICON_CLASS[item.kind],
)}
>
<Icon className="size-3.5" />
</span>
<p className="text-sm leading-snug">{item.message}</p>
</div>
<StatusBadge status={item.status} label={item.statusLabel} />
</li>
)
})}
</ul>
)
}
@@ -0,0 +1,62 @@
import type { ReactNode } from 'react'
import { Info } from 'lucide-react'
import { cn } from '@evobgp/ui/lib/utils'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@evobgp/ui/components/tooltip'
import { PanelCard } from '@/components/panel-card'
export function AnalyticsCardShell({
title,
description,
info,
actions,
footer,
className,
children,
}: {
title: string
description?: string
info?: string
actions?: ReactNode
footer?: ReactNode
className?: string
children: ReactNode
}) {
const titleNode = (
<span className="flex items-center gap-2">
{title}
{info ? (
<Tooltip>
<TooltipTrigger
className="inline-flex text-muted-foreground transition-colors hover:text-foreground"
aria-label="Подробнее"
>
<Info className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-xs">
{info}
</TooltipContent>
</Tooltip>
) : null}
</span>
)
return (
<PanelCard
title={titleNode}
description={description}
actions={actions}
footer={footer}
className={cn('overflow-hidden', className)}
contentClassName="flex flex-col gap-4 py-4"
footerClassName={footer ? 'gap-2 p-3' : undefined}
>
{children}
</PanelCard>
)
}
@@ -0,0 +1,50 @@
import { Minus, TrendingDown, TrendingUp } from 'lucide-react'
import { cn } from '@evobgp/ui/lib/utils'
export type AnalyticsKpiItem = {
label: string
value: string
delta?: {
direction: 'up' | 'down' | 'neutral'
label: string
tone?: 'success' | 'warning' | 'destructive' | 'muted'
}
}
const TONE_CLASS = {
success: 'text-success',
warning: 'text-warning',
destructive: 'text-destructive',
muted: 'text-muted-foreground',
} as const
function DeltaIcon({ direction }: { direction: AnalyticsKpiItem['delta'] extends infer D ? D extends { direction: infer Dir } ? Dir : never : never }) {
if (direction === 'up') return <TrendingUp className="size-3" />
if (direction === 'down') return <TrendingDown className="size-3" />
return <Minus className="size-3" />
}
export function AnalyticsKpiRow({ items, className }: { items: AnalyticsKpiItem[]; className?: string }) {
return (
<div className={cn('grid gap-4 sm:grid-cols-3', className)}>
{items.map((item) => (
<div key={item.label} className="min-w-0 space-y-1">
<p className="text-xs text-muted-foreground">{item.label}</p>
<p className="text-2xl font-semibold tracking-tight tabular-nums">{item.value}</p>
{item.delta ? (
<p
className={cn(
'flex items-center gap-1 text-xs',
TONE_CLASS[item.delta.tone ?? 'muted'],
)}
>
<DeltaIcon direction={item.delta.direction} />
{item.delta.label}
</p>
) : null}
</div>
))}
</div>
)
}
@@ -0,0 +1,34 @@
import {
Progress,
ProgressIndicator,
ProgressTrack,
} from '@evobgp/ui/components/progress'
import { cn } from '@evobgp/ui/lib/utils'
export function AnalyticsProgress({
label,
hint,
value,
className,
}: {
label: string
hint?: string
value: number
className?: string
}) {
const clamped = Math.max(0, Math.min(100, value))
return (
<div className={cn('space-y-2', className)}>
<div className="flex items-center justify-between gap-2 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{clamped}%</span>
</div>
{hint ? <p className="text-xs leading-snug text-muted-foreground">{hint}</p> : null}
<Progress value={clamped} className="w-full gap-0">
<ProgressTrack className="h-2">
<ProgressIndicator />
</ProgressTrack>
</Progress>
</div>
)
}
@@ -0,0 +1,35 @@
import { Button } from '@evobgp/ui/components/button'
import { ButtonGroup } from '@evobgp/ui/components/button-group'
import { cn } from '@evobgp/ui/lib/utils'
export function AnalyticsSegmentControl<T extends string>({
value,
onChange,
options,
className,
}: {
value: T
onChange: (value: T) => void
options: { value: T; label: string }[]
className?: string
}) {
return (
<ButtonGroup className={cn('rounded-lg bg-muted/50 p-0.5', className)}>
{options.map((option) => (
<Button
key={option.value}
type="button"
size="sm"
variant={value === option.value ? 'secondary' : 'ghost'}
className={cn(
'h-7 rounded-md px-2.5 text-xs',
value === option.value && 'bg-background shadow-sm',
)}
onClick={() => onChange(option.value)}
>
{option.label}
</Button>
))}
</ButtonGroup>
)
}
@@ -0,0 +1,41 @@
import { Bar, BarChart, XAxis } from 'recharts'
import { ChartContainer, type ChartConfig } from '@evobgp/ui/components/chart'
import { cn } from '@evobgp/ui/lib/utils'
import type { CapacityBar } from '@/lib/metrics'
const chartConfig = {
value: { label: 'Загрузка', color: 'var(--color-chart-2)' },
} satisfies ChartConfig
export function ChartBarStrip({
bars,
className,
}: {
bars: CapacityBar[]
className?: string
}) {
if (bars.length === 0) {
return (
<div className={cn('flex h-36 items-center justify-center text-sm text-muted-foreground', className)}>
Нет данных для графика
</div>
)
}
const data = bars.map((bar, index) => ({
...bar,
slot: index + 1,
fill: bar.value >= 80 ? 'var(--color-chart-2)' : 'var(--color-chart-3)',
}))
return (
<ChartContainer config={chartConfig} className={cn('aspect-auto h-36 w-full', className)}>
<BarChart data={data} margin={{ top: 4, right: 0, left: 0, bottom: 0 }}>
<XAxis dataKey="slot" hide />
<Bar dataKey="value" radius={[3, 3, 0, 0]} maxBarSize={10} />
</BarChart>
</ChartContainer>
)
}
@@ -0,0 +1,100 @@
import { Cell, Label, Pie, PieChart } from 'recharts'
import {
ChartContainer,
type ChartConfig,
} from '@evobgp/ui/components/chart'
import { cn } from '@evobgp/ui/lib/utils'
import type { BreakdownSlice } from '@/lib/metrics'
export function ChartDonutMetric({
slices,
centerLabel,
centerValue,
className,
}: {
slices: BreakdownSlice[]
centerLabel: string
centerValue: string | number
className?: string
}) {
const chartConfig = slices.reduce<ChartConfig>((acc, slice) => {
acc[slice.key] = { label: slice.label, color: slice.color }
return acc
}, {})
const data = slices.map((slice) => ({
...slice,
fill: slice.color,
}))
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
if (total === 0) {
return (
<div className={cn('flex h-48 items-center justify-center text-sm text-muted-foreground', className)}>
Нет данных
</div>
)
}
return (
<div className={cn('flex items-center gap-6', className)}>
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
<PieChart>
<Pie
data={data}
dataKey="count"
nameKey="label"
innerRadius={52}
outerRadius={72}
strokeWidth={2}
stroke="var(--color-card)"
>
{data.map((entry) => (
<Cell key={entry.key} fill={entry.fill} />
))}
<Label
content={({ viewBox }) => {
if (!viewBox || !('cx' in viewBox) || !('cy' in viewBox)) return null
const { cx, cy } = viewBox
return (
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle">
<tspan x={cx} y={(cy ?? 0) - 6} className="fill-muted-foreground text-xs">
{centerLabel}
</tspan>
<tspan x={cx} y={(cy ?? 0) + 14} className="fill-foreground text-xl font-semibold">
{centerValue}
</tspan>
</text>
)
}}
/>
</Pie>
</PieChart>
</ChartContainer>
<ul className="min-w-0 flex-1 space-y-3">
{slices.map((slice) => {
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
return (
<li key={slice.key} className="flex items-center justify-between gap-3 text-sm">
<div className="flex min-w-0 items-center gap-2">
<span
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: slice.color }}
/>
<span className="truncate text-muted-foreground">{slice.label}</span>
</div>
<div className="shrink-0 text-right tabular-nums">
<span className="font-semibold">{slice.count}</span>
<span className="ml-2 text-muted-foreground">{pct}%</span>
</div>
</li>
)
})}
</ul>
</div>
)
}
@@ -0,0 +1,107 @@
import { useMemo, useState } from 'react'
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
} from '@evobgp/ui/components/avatar'
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
import { ChartBarStrip } from '@/components/analytics/chart-bar-strip'
import {
capacityUtilization,
peerCapacityBars,
speakerCapacityBars,
} from '@/lib/metrics'
import { runningJobCount } from '@/queries/overview'
import type { JobRow, PeerRow, SpeakerRow } from '@/types/api'
type CapacityMode = 'peers' | 'speakers'
function speakerInitials(speaker: SpeakerRow): string {
const label = speaker.live?.label ?? speaker.agent_domain ?? speaker.endpoint ?? speaker.id
const parts = label.split(/[.\-_@/]/).filter(Boolean)
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
return label.slice(0, 2).toUpperCase()
}
export function DashboardNetworkCapacityCard({
peers,
speakers,
jobs,
loading,
}: {
peers: PeerRow[]
speakers: SpeakerRow[]
jobs: JobRow[]
loading?: boolean
}) {
const [mode, setMode] = useState<CapacityMode>('peers')
const bars = useMemo(
() => (mode === 'peers' ? peerCapacityBars(peers) : speakerCapacityBars(speakers)),
[mode, peers, speakers],
)
const utilization = capacityUtilization(bars)
const queued = runningJobCount(jobs)
const previewSpeakers = speakers.slice(0, 3)
const deltaLabel =
mode === 'peers'
? `${peers.filter((p) => p.enabled !== false && p.session_state === 'Established').length} установлено`
: `${speakers.filter((s) => s.live?.agent_ok).length} в сети`
return (
<AnalyticsCardShell
title="Загрузка BGP"
description="Текущая утилизация сессий по пирам и спикерам"
info="Каждый столбец — включённый пир или спикер. Высота отражает установленную сессию или доступность."
actions={
<AnalyticsSegmentControl
value={mode}
onChange={setMode}
options={[
{ value: 'peers', label: 'Пиры' },
{ value: 'speakers', label: 'Спикеры' },
]}
/>
}
>
<div className="space-y-1">
<p className="text-3xl font-semibold tracking-tight tabular-nums">
{loading ? '—' : `${utilization}%`}
</p>
<p className="text-sm text-success">{loading ? '…' : `${deltaLabel} · снимок live`}</p>
</div>
{loading ? (
<div className="flex h-36 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartBarStrip bars={bars} />
)}
<div className="flex items-center justify-between gap-3 text-sm">
<p className="text-muted-foreground">
Активных задач: <span className="font-medium text-foreground">{loading ? '—' : queued}</span>
</p>
<div className="flex items-center gap-2">
<AvatarGroup>
{previewSpeakers.map((speaker) => (
<Avatar key={speaker.id} size="sm">
<AvatarFallback>{speakerInitials(speaker)}</AvatarFallback>
</Avatar>
))}
{speakers.length > 3 ? (
<AvatarGroupCount>+{speakers.length - 3}</AvatarGroupCount>
) : null}
</AvatarGroup>
<span className="text-muted-foreground">{speakers.length} спикеров</span>
</div>
</div>
</AnalyticsCardShell>
)
}
@@ -0,0 +1,59 @@
import { useMemo, useState } from 'react'
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
import type { JobRow, ModuleRow } from '@/types/api'
type FlowMode = 'jobs' | 'modules'
export function DashboardOperationsFlowCard({
jobs,
modules,
loading,
}: {
jobs: JobRow[]
modules: ModuleRow[]
loading?: boolean
}) {
const [mode, setMode] = useState<FlowMode>('jobs')
const slices = useMemo(
() => (mode === 'jobs' ? jobStatusBreakdown(jobs) : moduleTypeBreakdown(modules)),
[mode, jobs, modules],
)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
const centerLabel = mode === 'jobs' ? 'Задачи' : 'Модули'
return (
<AnalyticsCardShell
title="Поток операций"
description="Распределение фоновых задач и типов модулей"
info="Donut строится по текущей выборке API (до 100 последних задач)."
actions={
<AnalyticsSegmentControl
value={mode}
onChange={setMode}
options={[
{ value: 'jobs', label: 'Задачи' },
{ value: 'modules', label: 'Модули' },
]}
/>
}
>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric
slices={slices}
centerLabel={centerLabel}
centerValue={total}
/>
)}
</AnalyticsCardShell>
)
}
@@ -0,0 +1,145 @@
import { useNavigate } from '@tanstack/react-router'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { AnalyticsActivityList } from '@/components/analytics/analytics-activity-list'
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
import { AnalyticsProgress } from '@/components/analytics/analytics-progress'
import {
deploymentProgress,
deploymentProgressMeta,
recentPlatformActivity,
} from '@/lib/metrics'
import { runningJobCount } from '@/queries/overview'
import type { JobRow, ModuleRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
export function DashboardPlatformCard({
modules,
peers,
speakers,
jobs,
revisions,
loading,
}: {
modules: ModuleRow[]
peers: PeerRow[]
speakers: SpeakerRow[]
jobs: JobRow[]
revisions: RevisionRow[]
loading?: boolean
}) {
const navigate = useNavigate()
const enabledModules = modules.filter((m) => m.enabled !== false).length
const peersEnabled = peers.filter((p) => p.enabled !== false).length
const peersEstablished = peers.filter(
(p) => p.enabled !== false && p.session_state === 'Established',
).length
const peersMismatch = peers.filter((p) => p.session_mismatch).length
const speakersOnline = speakers.filter((s) => s.live?.agent_ok).length
const failedJobs = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const running = runningJobCount(jobs)
const riskCount = peersMismatch + failedJobs + Math.max(0, speakers.length - speakersOnline)
const bgpPct =
peersEnabled > 0 ? Math.round((peersEstablished / peersEnabled) * 100) : null
const deploy = useMemo(() => deploymentProgress(speakers), [speakers])
const deployMeta = useMemo(() => deploymentProgressMeta(deploy), [deploy])
const activity = useMemo(
() => recentPlatformActivity(jobs, revisions, peers, speakers),
[jobs, revisions, peers, speakers],
)
const kpis = [
{
label: 'Модули активны',
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
delta: {
direction: 'neutral' as const,
label: `${modules.length} всего`,
tone: 'muted' as const,
},
},
{
label: 'BGP готовность',
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
delta: {
direction: (bgpPct !== null && bgpPct >= 90 ? 'up' : bgpPct !== null && bgpPct < 70 ? 'down' : 'neutral') as
| 'up'
| 'down'
| 'neutral',
label:
bgpPct === null
? 'нет включённых пиров'
: `${peersEstablished} установлено`,
tone: (bgpPct !== null && bgpPct >= 90
? 'success'
: bgpPct !== null && bgpPct < 70
? 'warning'
: 'muted') as 'success' | 'warning' | 'muted',
},
},
{
label: 'Риски',
value: loading ? '—' : String(riskCount),
delta: {
direction: (riskCount > 0 ? 'down' : 'up') as 'up' | 'down',
label: riskCount > 0 ? `${failedJobs} задач, ${peersMismatch} расхождений` : 'в норме',
tone: (riskCount > 0 ? 'destructive' : 'success') as 'destructive' | 'success',
},
},
]
const progressLabel = deployMeta.label
const progressHint = deployMeta.hint
return (
<AnalyticsCardShell
title="Состояние платформы"
description="Сводка модулей, BGP и фоновых задач"
info="Актуальный снимок без исторических трендов. Обновите данные кнопкой «Обновить» на странице."
footer={
<>
<Button
variant="outline"
className="flex-1"
onClick={() => navigate({ to: '/schedule' })}
>
Расписание
</Button>
<Button
className="flex-1"
onClick={() => navigate({ to: '/monitoring', search: { tab: 'system' } })}
>
Мониторинг
</Button>
</>
}
>
<AnalyticsKpiRow items={kpis} />
<AnalyticsProgress
label={progressLabel}
hint={progressHint}
value={loading ? 0 : deploy.percent}
/>
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Недавняя активность</span>
{!loading ? (
<span className="text-xs text-muted-foreground">{running} активных задач</span>
) : null}
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Загрузка</p>
) : (
<AnalyticsActivityList items={activity} />
)}
</div>
</AnalyticsCardShell>
)
}
@@ -0,0 +1,13 @@
export { AnalyticsActivityList } from './analytics-activity-list'
export { AnalyticsCardShell } from './analytics-card-shell'
export { AnalyticsKpiRow, type AnalyticsKpiItem } from './analytics-kpi-row'
export { AnalyticsProgress } from './analytics-progress'
export { AnalyticsSegmentControl } from './analytics-segment-control'
export { ChartBarStrip } from './chart-bar-strip'
export { ChartDonutMetric } from './chart-donut-metric'
export { DashboardNetworkCapacityCard } from './dashboard-network-capacity-card'
export { DashboardOperationsFlowCard } from './dashboard-operations-flow-card'
export { DashboardPlatformCard } from './dashboard-platform-card'
export { MonitoringHealthCard } from './monitoring-health-card'
export { NetworkOverviewAnalyticsCard } from './network-overview-analytics-card'
export { OperationsAnalyticsCard } from './operations-analytics-card'
@@ -0,0 +1,33 @@
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { readinessBreakdown } from '@/lib/metrics'
import type { ReadyStatus } from '@/queries/monitoring'
export function MonitoringHealthCard({
healthOk,
ready,
loading,
}: {
healthOk: boolean
ready: ReadyStatus | null | undefined
loading?: boolean
}) {
const slices = readinessBreakdown(ready, healthOk)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
return (
<AnalyticsCardShell
title="Доступность системы"
description="Проверки живучести и готовности"
info="Диаграмма отражает результат GET /v1/health и проверок из GET /v1/ready."
>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel="Проверки" centerValue={total} />
)}
</AnalyticsCardShell>
)
}
@@ -0,0 +1,66 @@
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { peerSessionBreakdown } from '@/lib/metrics'
import { aggregateNetworkMetrics } from '@/queries/overview'
import type { PeerRow, SpeakerRow } from '@/types/api'
export function NetworkOverviewAnalyticsCard({
peers,
speakers,
loading,
}: {
peers: PeerRow[]
speakers: SpeakerRow[]
loading?: boolean
}) {
const net = aggregateNetworkMetrics(peers, speakers)
const slices = peerSessionBreakdown(peers)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
return (
<AnalyticsCardShell
title="Сводка BGP"
description="Установленные сессии, доступность спикеров и расхождения по live-данным"
info="Снимок текущего состояния пиров и спикеров."
>
<AnalyticsKpiRow
items={[
{
label: 'Пиры с установленной сессией',
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
delta: {
direction: net.peersMismatch > 0 ? 'down' : 'up',
label: net.peersMismatch > 0 ? `${net.peersMismatch} расхождений` : 'сессии в норме',
tone: net.peersMismatch > 0 ? 'warning' : 'success',
},
},
{
label: 'Спикеры в сети',
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
delta: {
direction: net.speakersOnline < net.speakersTotal ? 'down' : 'up',
label:
net.speakersOnline < net.speakersTotal
? `${net.speakersTotal - net.speakersOnline} не в сети`
: 'все в сети',
tone: net.speakersOnline < net.speakersTotal ? 'warning' : 'success',
},
},
{
label: 'Пиры всего',
value: loading ? '—' : String(net.peersTotal),
delta: { direction: 'neutral', label: 'в каталоге', tone: 'muted' },
},
]}
/>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel="Пиры" centerValue={total} />
)}
</AnalyticsCardShell>
)
}
@@ -0,0 +1,65 @@
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { jobStatusBreakdown } from '@/lib/metrics'
import type { JobRow, RevisionRow } from '@/types/api'
export function OperationsAnalyticsCard({
jobs,
revisions,
loading,
}: {
jobs: JobRow[]
revisions: RevisionRow[]
loading?: boolean
}) {
const running = jobs.filter((j) => ['running', 'queued'].includes(j.status.toLowerCase())).length
const failed = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const slices = jobStatusBreakdown(jobs)
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
return (
<AnalyticsCardShell
title="Операции и задачи"
description="Статистика ревизий и фоновых jobs"
info="Данные из GET /v1/jobs и /v1/revisions."
>
<AnalyticsKpiRow
items={[
{
label: 'Ревизий',
value: loading ? '—' : String(revisions.length),
delta: { direction: 'neutral', label: 'в выборке', tone: 'muted' },
},
{
label: 'Активных задач',
value: loading ? '—' : String(running),
delta: {
direction: running > 0 ? 'up' : 'neutral',
label: running > 0 ? 'выполняются' : 'очередь пуста',
tone: (running > 0 ? 'warning' : 'muted') as 'warning' | 'muted',
},
},
{
label: 'С ошибкой',
value: loading ? '—' : String(failed),
delta: {
direction: failed > 0 ? 'down' : 'up',
label: failed > 0 ? 'требуют внимания' : 'в норме',
tone: failed > 0 ? 'destructive' : 'success',
},
},
]}
/>
{loading ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Загрузка
</div>
) : (
<ChartDonutMetric slices={slices} centerLabel="Задачи" centerValue={total} />
)}
</AnalyticsCardShell>
)
}
+69
View File
@@ -0,0 +1,69 @@
import type { ComponentProps, ReactNode } from 'react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { cn } from '@evobgp/ui/lib/utils'
import { Badge } from '@/components/reui/badge'
export type BadgeTabItem = {
value: string
label: string
count?: number
badgeVariant?: ComponentProps<typeof Badge>['variant']
icon?: ReactNode
}
interface BadgeTabsProps {
items: BadgeTabItem[]
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
children: ReactNode
className?: string
listClassName?: string
contentClassName?: string
}
/** Underline tabs with optional badge counts (ReUI c-tabs-7 pattern). */
export function BadgeTabs({
items,
value,
defaultValue,
onValueChange,
children,
className,
listClassName,
contentClassName,
}: BadgeTabsProps) {
return (
<Tabs
value={value}
defaultValue={defaultValue}
onValueChange={onValueChange}
className={cn('w-full', className)}
>
<TabsList
variant="line"
className={cn(
'mb-4 w-full justify-start gap-6',
listClassName,
)}
>
{items.map((item) => (
<TabsTrigger key={item.value} value={item.value} className="gap-2">
{item.icon}
{item.label}
{item.count !== undefined ? (
<Badge variant={item.badgeVariant ?? 'primary-light'} size="sm">
{item.count}
</Badge>
) : null}
</TabsTrigger>
))}
</TabsList>
<div className={cn('w-full min-w-0', contentClassName)}>{children}</div>
</Tabs>
)
}
export { TabsContent }
@@ -0,0 +1,50 @@
import type { ComponentProps, ReactNode } from 'react'
import { Badge } from '@/components/reui/badge'
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
const TONE_VARIANT: Record<string, BadgeVariant> = {
neutral: 'outline',
info: 'info-light',
warning: 'warning-light',
success: 'success-light',
}
export function ModeBadge({
enabled,
onLabel = 'включён',
offLabel = 'выключен',
className,
}: {
enabled: boolean
onLabel?: string
offLabel?: string
className?: string
}) {
return enabled ? (
<Badge variant="success-light" size="sm" radius="full" className={className}>
{onLabel}
</Badge>
) : (
<Badge variant="secondary" size="sm" radius="full" className={className}>
{offLabel}
</Badge>
)
}
export function CategoryBadge({
children,
tone = 'neutral',
className,
}: {
children: ReactNode
tone?: keyof typeof TONE_VARIANT
className?: string
}) {
return (
<Badge variant={TONE_VARIANT[tone]} size="sm" radius="full" className={className}>
{children}
</Badge>
)
}
+107 -30
View File
@@ -1,53 +1,130 @@
import { Button } from '@evobgp/ui/components/button'
import { import {
AlertDialog, Drawer,
AlertDialogAction, DrawerClose,
AlertDialogCancel, DrawerContent,
AlertDialogContent, DrawerDescription,
AlertDialogDescription, DrawerHeader,
AlertDialogFooter, DrawerTitle,
AlertDialogHeader, DrawerTrigger,
AlertDialogTitle, } from '@evobgp/ui/components/drawer'
AlertDialogTrigger,
} from '@evobgp/ui/components/alert-dialog'
import type { ReactElement, ReactNode } from 'react' import type { ReactElement, ReactNode } from 'react'
interface ConfirmDialogProps { import {
trigger: ReactElement confirmDrawerContentClassName,
DrawerActionsFooter,
} from '@/components/drawer-layout'
type ConfirmDialogBaseProps = {
title: string title: string
description?: ReactNode description?: ReactNode
confirmLabel?: string confirmLabel?: string
cancelLabel?: string cancelLabel?: string
destructive?: boolean destructive?: boolean
onConfirm: () => void onConfirm: () => void
confirmDisabled?: boolean
confirmLoading?: boolean
confirmLoadingLabel?: string
} }
export function ConfirmDialog({ type ConfirmDialogWithTrigger = ConfirmDialogBaseProps & {
trigger, trigger: ReactElement
open?: never
onOpenChange?: never
}
type ConfirmDialogControlled = ConfirmDialogBaseProps & {
trigger?: never
open: boolean
onOpenChange: (open: boolean) => void
}
type ConfirmDialogProps = ConfirmDialogWithTrigger | ConfirmDialogControlled
function ConfirmDrawerBody({
title, title,
description, description,
confirmLabel = 'Подтвердить', confirmLabel = 'Подтвердить',
cancelLabel = 'Отмена', cancelLabel = 'Отмена',
destructive, destructive,
onConfirm, onConfirm,
}: ConfirmDialogProps) { confirmDisabled,
confirmLoading,
confirmLoadingLabel,
controlled,
}: ConfirmDialogBaseProps & { controlled?: boolean }) {
const confirmText =
confirmLoading && confirmLoadingLabel
? confirmLoadingLabel
: confirmLoading
? `${confirmLabel}`
: confirmLabel
return ( return (
<AlertDialog> <>
<AlertDialogTrigger render={trigger} /> <DrawerHeader className="shrink-0 border-b border-border pb-4">
<AlertDialogContent> <DrawerTitle>{title}</DrawerTitle>
<AlertDialogHeader> {description ? <DrawerDescription>{description}</DrawerDescription> : null}
<AlertDialogTitle>{title}</AlertDialogTitle> </DrawerHeader>
{description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null} <DrawerActionsFooter>
</AlertDialogHeader> <DrawerClose render={<Button variant="outline" disabled={confirmLoading} />}>
<AlertDialogFooter> {cancelLabel}
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel> </DrawerClose>
<AlertDialogAction {controlled ? (
<Button
variant={destructive ? 'destructive' : 'default'} variant={destructive ? 'destructive' : 'default'}
disabled={confirmDisabled || confirmLoading}
onClick={onConfirm} onClick={onConfirm}
> >
{confirmLabel} {confirmText}
</AlertDialogAction> </Button>
</AlertDialogFooter> ) : (
</AlertDialogContent> <DrawerClose
</AlertDialog> render={
<Button
variant={destructive ? 'destructive' : 'default'}
disabled={confirmDisabled || confirmLoading}
/>
}
onClick={onConfirm}
>
{confirmText}
</DrawerClose>
)}
</DrawerActionsFooter>
</>
)
}
export function ConfirmDialog(props: ConfirmDialogProps) {
const bodyProps: ConfirmDialogBaseProps = {
title: props.title,
description: props.description,
confirmLabel: props.confirmLabel,
cancelLabel: props.cancelLabel,
destructive: props.destructive,
onConfirm: props.onConfirm,
confirmDisabled: props.confirmDisabled,
confirmLoading: props.confirmLoading,
confirmLoadingLabel: props.confirmLoadingLabel,
}
if (props.trigger) {
return (
<Drawer swipeDirection="right">
<DrawerTrigger render={props.trigger} />
<DrawerContent className={confirmDrawerContentClassName}>
<ConfirmDrawerBody {...bodyProps} />
</DrawerContent>
</Drawer>
)
}
return (
<Drawer open={props.open} onOpenChange={props.onOpenChange} swipeDirection="right">
<DrawerContent className={confirmDrawerContentClassName}>
<ConfirmDrawerBody {...bodyProps} controlled />
</DrawerContent>
</Drawer>
) )
} }
@@ -0,0 +1,38 @@
import { aggregateNetworkMetrics } from '@/queries/overview'
import type { PeerRow, SpeakerRow } from '@/types/api'
function Row({ label, value, variant = 'default' }: { label: string; value: string; variant?: 'default' | 'warning' }) {
return (
<div className="flex items-center justify-between gap-2 px-1 text-sm">
<span className="text-muted-foreground">{label}</span>
<span
className={
variant === 'warning'
? 'font-medium text-warning-foreground tabular-nums'
: 'font-medium tabular-nums'
}
>
{value}
</span>
</div>
)
}
export function DashboardNetworkPanel({
peers,
speakers,
}: {
peers: PeerRow[]
speakers: SpeakerRow[]
}) {
const m = aggregateNetworkMetrics(peers, speakers)
return (
<div className="flex flex-col gap-2 p-3">
<Row label="Пиры с установленной сессией" value={`${m.peersEstablished} / ${m.peersEnabled}`} />
<Row label="Спикеры в сети" value={`${m.speakersOnline} / ${m.speakersTotal}`} />
{m.peersMismatch > 0 ? (
<Row label="Расхождения сессий" value={String(m.peersMismatch)} variant="warning" />
) : null}
</div>
)
}
@@ -0,0 +1,40 @@
import { Link } from '@tanstack/react-router'
import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
export function DashboardQuickActions() {
return (
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" type="button" render={<Link to="/modules" />}>
<Plus className="size-4" />
Создать модуль
</Button>
<Button variant="outline" size="sm" type="button" render={<Link to="/directories" />}>
<Tags className="size-4" />
Добавить BGP-сообщество
</Button>
<Button variant="outline" size="sm" type="button" render={<Link to="/network" search={{ tab: 'overview' }} />}>
<Network className="size-4" />
Сеть
</Button>
<Button variant="outline" size="sm" type="button" render={<Link to="/network" search={{ tab: 'peers' }} />}>
<Share2 className="size-4" />
Добавить пира
</Button>
<Button
variant="outline"
size="sm"
type="button"
render={<Link to="/operations" search={{ tab: 'revisions' }} />}
>
<Play className="size-4" />
Деплой
</Button>
<Button variant="outline" size="sm" type="button" render={<Link to="/monitoring" search={{ tab: 'system' }} />}>
<Gauge className="size-4" />
Мониторинг
</Button>
</div>
)
}
@@ -0,0 +1,78 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
import { jobKindRu } from '@/lib/ui-labels'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { JobRow } from '@/types/api'
export function DashboardRecentJobsGrid({
jobs,
nameById,
isLoading = false,
}: {
jobs: JobRow[]
nameById: Map<string, string>
isLoading?: boolean
}) {
const data = useMemo(() => jobs.slice(0, 8), [jobs])
const columns = useMemo<ColumnDef<JobRow>[]>(
() => [
{
accessorKey: 'kind',
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={jobKindRu(row.original.kind)}
accent="mono"
subtitle={
row.original.meta?.module_id
? (nameById.get(String(row.original.meta.module_id)) ?? undefined)
: undefined
}
/>
),
meta: { headerTitle: 'Вид' },
},
{
accessorKey: 'status',
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
cell: ({ row }) => <StatusBadge status={row.original.status} />,
meta: { headerTitle: 'Статус' },
},
],
[nameById],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data,
columns,
getSearchText: (row) => {
const moduleName = row.meta?.module_id
? (nameById.get(String(row.meta.module_id)) ?? '')
: ''
return `${jobKindRu(row.kind)} ${row.status} ${moduleName}`
},
getRowId: (row) => row.job_id,
pageSize: 8,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет задач"
showPagination={false}
tableLayout={DATA_GRID_DENSE_LAYOUT}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск задач…"
/>
)
}
@@ -0,0 +1,66 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { RevisionRow } from '@/types/api'
export function DashboardRecentRevisionsGrid({
revisions,
isLoading = false,
}: {
revisions: RevisionRow[]
isLoading?: boolean
}) {
const data = useMemo(() => revisions.slice(0, 8), [revisions])
const columns = useMemo<ColumnDef<RevisionRow>[]>(
() => [
{
id: 'id',
accessorFn: (row) => row.id,
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={`${row.original.id.slice(0, 10)}`} accent="mono" />
),
meta: { headerTitle: 'ID' },
},
{
accessorKey: 'created_at',
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
cell: ({ row }) => (
<DataGridMutedCell>
{new Date(row.original.created_at).toLocaleString('ru-RU')}
</DataGridMutedCell>
),
meta: { headerTitle: 'Создана' },
},
],
[],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data,
columns,
getSearchText: (row) => row.id,
getRowId: (row) => row.id,
pageSize: 8,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет ревизий"
showPagination={false}
tableLayout={DATA_GRID_DENSE_LAYOUT}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск ревизий…"
/>
)
}
@@ -0,0 +1,44 @@
import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
const ACCENT_CLASS = {
primary: 'font-medium text-primary',
default: 'font-medium text-foreground',
mono: 'font-mono text-sm text-primary',
} as const
export function DataGridPrimaryCell({
title,
subtitle,
accent = 'default',
className,
}: {
title: ReactNode
subtitle?: ReactNode
accent?: keyof typeof ACCENT_CLASS
className?: string
}) {
return (
<div className={cn('flex min-w-0 flex-col gap-0.5', className)}>
<span className={cn('truncate', ACCENT_CLASS[accent])}>{title}</span>
{subtitle ? (
<span className="truncate text-xs text-muted-foreground">{subtitle}</span>
) : null}
</div>
)
}
export function DataGridMutedCell({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<span className={cn('whitespace-nowrap text-xs text-muted-foreground', className)}>
{children}
</span>
)
}
+114
View File
@@ -0,0 +1,114 @@
import type { ReactNode } from 'react'
import type { Table } from '@tanstack/react-table'
import { cn } from '@evobgp/ui/lib/utils'
import { DataGridToolbar } from '@/components/data-grid-toolbar'
import { PanelCard, panelCardContentFlushClassName, panelCardFooterClassName } from '@/components/panel-card'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import {
DATA_GRID_MESSAGES_RU,
DATA_GRID_PAGINATION_RU,
DATA_GRID_TABLE_LAYOUT,
} from '@/lib/data-grid-defaults'
interface DataGridShellProps<TData extends object> {
table: Table<TData>
recordCount: number
isLoading?: boolean
emptyMessage?: ReactNode
showPagination?: boolean
tableLayout?: typeof DATA_GRID_TABLE_LAYOUT
className?: string
onRowClick?: (row: TData) => void
}
export function DataGridShell<TData extends object>({
table,
recordCount,
isLoading = false,
emptyMessage,
showPagination = true,
tableLayout = DATA_GRID_TABLE_LAYOUT,
className,
onRowClick,
}: DataGridShellProps<TData>) {
return (
<DataGrid
table={table}
recordCount={recordCount}
isLoading={isLoading}
emptyMessage={emptyMessage ?? DATA_GRID_MESSAGES_RU.emptyMessage}
loadingMessage={DATA_GRID_MESSAGES_RU.loadingMessage}
tableLayout={tableLayout}
className={className}
onRowClick={onRowClick}
>
<DataGridContainer>
<DataGridTable />
</DataGridContainer>
{showPagination ? (
<div className={cn(panelCardFooterClassName, 'px-3 py-2')}>
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
</div>
) : null}
</DataGrid>
)
}
interface DataGridCardProps {
title?: string
description?: string
actions?: ReactNode
children: ReactNode
className?: string
}
export function DataGridCard({ title, description, actions, children, className }: DataGridCardProps) {
return (
<PanelCard
title={title}
description={description}
actions={actions}
className={className}
contentClassName={panelCardContentFlushClassName}
>
{children}
</PanelCard>
)
}
interface DataGridSectionProps<TData extends object> extends DataGridShellProps<TData> {
searchValue: string
onSearchChange: (value: string) => void
searchPlaceholder?: string
toolbarFilters?: ReactNode
toolbarActions?: ReactNode
beforeGrid?: ReactNode
}
export function DataGridSection<TData extends object>({
searchValue,
onSearchChange,
searchPlaceholder,
toolbarFilters,
toolbarActions,
beforeGrid,
...shellProps
}: DataGridSectionProps<TData>) {
return (
<>
<DataGridToolbar
searchValue={searchValue}
onSearchChange={onSearchChange}
searchPlaceholder={searchPlaceholder}
filters={toolbarFilters}
actions={toolbarActions}
/>
{beforeGrid}
<DataGridShell {...shellProps} />
</>
)
}
@@ -0,0 +1,73 @@
import type { ReactNode } from 'react'
import { Field } from '@evobgp/ui/components/field'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from '@evobgp/ui/components/input-group'
import { ListFilterIcon, SearchIcon, XIcon } from 'lucide-react'
interface DataGridToolbarProps {
searchValue: string
onSearchChange: (value: string) => void
searchPlaceholder?: string
filters?: ReactNode
actions?: ReactNode
className?: string
}
/** Search row for data grids (ReUI c-input-group-37 pattern). */
export function DataGridToolbar({
searchValue,
onSearchChange,
searchPlaceholder = 'Поиск…',
filters,
actions,
className,
}: DataGridToolbarProps) {
return (
<div className={`flex flex-wrap items-center gap-2 border-b px-3 py-2 ${className ?? ''}`}>
<Field className="min-w-[200px] flex-1">
<InputGroup>
<InputGroupAddon align="inline-start">
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
placeholder={searchPlaceholder}
value={searchValue}
onChange={(event) => onSearchChange(event.target.value)}
aria-label={searchPlaceholder}
/>
<InputGroupAddon align="inline-end" className="gap-1">
{searchValue.length > 0 ? (
<InputGroupButton
aria-label="Очистить поиск"
size="icon-xs"
variant="ghost"
onClick={() => onSearchChange('')}
>
<XIcon aria-hidden="true" />
</InputGroupButton>
) : null}
{filters ? (
filters
) : (
<InputGroupButton
variant="ghost"
size="xs"
className="pointer-events-none gap-1.5 opacity-0"
aria-hidden="true"
tabIndex={-1}
>
<ListFilterIcon className="size-3.5" />
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
</Field>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
</div>
)
}
@@ -0,0 +1,63 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { BgpCommunity } from '@/types/api'
export function DirectoriesCommunitiesGrid({
items,
isLoading = false,
}: {
items: BgpCommunity[]
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<BgpCommunity>[]>(
() => [
{
accessorKey: 'title',
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
cell: ({ row }) => <DataGridPrimaryCell title={row.original.title} accent="primary" />,
meta: { headerTitle: 'Название' },
},
{
accessorKey: 'community',
header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.community} accent="mono" />
),
meta: { headerTitle: 'Значение' },
},
{
id: 'type',
enableSorting: false,
header: 'Тип',
cell: () => <CategoryBadge>community</CategoryBadge>,
meta: { headerTitle: 'Тип' },
},
],
[],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) => `${row.title} ${row.community}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет сообществ"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск сообществ…"
/>
)
}
@@ -0,0 +1,68 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { DohProfile } from '@/types/api'
export function DirectoriesDohGrid({
items,
isLoading = false,
}: {
items: DohProfile[]
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<DohProfile>[]>(
() => [
{
id: 'name',
accessorFn: (row) => row.name ?? row.url,
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.name ?? row.original.url}
subtitle={row.original.name ? row.original.url : undefined}
accent="primary"
/>
),
meta: { headerTitle: 'Название' },
},
{
accessorKey: 'url',
header: ({ column }) => <DataGridColumnHeader column={column} title="URL" />,
cell: ({ row }) => <DataGridPrimaryCell title={row.original.url} accent="mono" />,
meta: { headerTitle: 'URL' },
},
{
id: 'default',
enableSorting: false,
header: 'По умолчанию',
cell: () => <CategoryBadge></CategoryBadge>,
meta: { headerTitle: 'По умолчанию' },
},
],
[],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) => `${row.name ?? ''} ${row.url}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет DoH профилей"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск DoH профилей…"
/>
)
}
+31
View File
@@ -0,0 +1,31 @@
import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import { DrawerFooter } from '@evobgp/ui/components/drawer'
/** Shared footer layout for right-side form and confirm drawers. */
export function DrawerActionsFooter({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<DrawerFooter
className={cn(
'mt-0 shrink-0 border-t border-border bg-muted/50 p-4',
className,
)}
>
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
{children}
</div>
</DrawerFooter>
)
}
export const formDrawerContentClassName =
'flex h-full max-h-dvh flex-col sm:max-w-lg'
export const confirmDrawerContentClassName = 'flex h-auto max-h-dvh flex-col sm:max-w-sm'
@@ -0,0 +1,107 @@
"use client"
import { useState } from "react"
import { Checkbox } from "@evobgp/ui/components/checkbox"
import { Field } from "@evobgp/ui/components/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@evobgp/ui/components/input-group"
import { Label } from "@evobgp/ui/components/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@evobgp/ui/components/popover"
import { SearchIcon, XIcon, ListFilterIcon } from "lucide-react"
const statuses = ["Pending", "Shipped", "Cancelled"] as const
type Status = (typeof statuses)[number]
function toggleStatus(values: Status[], value: Status) {
return values.includes(value)
? values.filter((item) => item !== value)
: [...values, value]
}
export function Pattern() {
const [searchQuery, setSearchQuery] = useState("")
const [selectedStatuses, setSelectedStatuses] = useState<Status[]>([])
return (
<Field className="max-w-sm">
<InputGroup>
<InputGroupAddon align="inline-start">
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search orders..."
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
<InputGroupAddon align="inline-end" className="gap-1">
{searchQuery.length > 0 ? (
<InputGroupButton
aria-label="Clear search"
size="icon-xs"
variant="ghost"
onClick={() => setSearchQuery("")}
>
<XIcon aria-hidden="true" />
</InputGroupButton>
) : null}
<Popover>
<PopoverTrigger
render={
<InputGroupButton
variant="ghost"
size="xs"
className="gap-1.5"
aria-label="Filter order status"
/>
}
>
<ListFilterIcon className="size-3.5" aria-hidden="true" />
Status
{selectedStatuses.length > 0 ? (
<span className="text-muted-foreground tabular-nums">
{selectedStatuses.length}
</span>
) : null}
</PopoverTrigger>
<PopoverContent align="end" className="w-40 p-3">
<div className="flex flex-col gap-2">
{statuses.map((status) => (
<div key={status} className="flex items-center gap-2.5">
<Checkbox
id={`order-status-${status}`}
checked={selectedStatuses.includes(status)}
onCheckedChange={() =>
setSelectedStatuses((previous) =>
toggleStatus(previous, status)
)
}
/>
<Label
htmlFor={`order-status-${status}`}
className="text-sm font-normal"
>
{status}
</Label>
</div>
))}
</div>
</PopoverContent>
</Popover>
</InputGroupAddon>
</InputGroup>
</Field>
)
}
@@ -0,0 +1,19 @@
import { Field } from '@evobgp/ui/components/field'
import { SelectMenu } from '@/components/select-field'
const items = [
{ label: 'Select an item', value: 'placeholder' as const },
...Array.from({ length: 100 }).map((_, i) => ({
label: `Item ${i}`,
value: `item-${i}` as const,
})),
]
export function Pattern() {
return (
<Field className="max-w-xs">
<SelectMenu items={items} placeholder="Select an item" />
</Field>
)
}
@@ -0,0 +1,46 @@
import { Card, CardContent } from "@evobgp/ui/components/card"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
import { LayoutDashboardIcon, BarChart3Icon, SettingsIcon } from "lucide-react"
export function Pattern() {
return (
<div className="flex w-full max-w-md flex-col gap-6">
<Tabs defaultValue="overview">
<TabsList className="w-full">
<TabsTrigger value="overview">
<LayoutDashboardIcon className="size-4" />
Overview
</TabsTrigger>
<TabsTrigger value="analytics">
<BarChart3Icon className="size-4" />
Analytics
</TabsTrigger>
<TabsTrigger value="settings">
<SettingsIcon className="size-4" />
Settings
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<Card>
<CardContent>Overview dashboard content goes here.</CardContent>
</Card>
</TabsContent>
<TabsContent value="analytics">
<Card>
<CardContent>Analytics charts and metrics.</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings">
<Card>
<CardContent>Application settings and preferences.</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
@@ -0,0 +1,61 @@
import { Badge } from "@/components/reui/badge"
import { Card, CardContent } from "@evobgp/ui/components/card"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@evobgp/ui/components/tabs"
export function Pattern() {
return (
<div className="flex w-full max-w-md flex-col gap-6">
<Tabs defaultValue="inbox">
<TabsList variant="line" className="mb-3.5 w-full">
<TabsTrigger value="inbox" className="gap-2">
Inbox
<Badge variant="primary-light" size="sm">
12
</Badge>
</TabsTrigger>
<TabsTrigger value="drafts" className="gap-2">
Drafts
<Badge variant="info-light" size="sm">
3
</Badge>
</TabsTrigger>
<TabsTrigger value="sent" className="gap-2">
Sent
</TabsTrigger>
<TabsTrigger value="spam" className="gap-2">
Spam
<Badge variant="destructive-light" size="sm">
24
</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value="inbox">
<Card>
<CardContent>12 unread messages in your inbox.</CardContent>
</Card>
</TabsContent>
<TabsContent value="drafts">
<Card>
<CardContent>3 drafts waiting to be sent.</CardContent>
</Card>
</TabsContent>
<TabsContent value="sent">
<Card>
<CardContent>All sent messages appear here.</CardContent>
</Card>
</TabsContent>
<TabsContent value="spam">
<Card>
<CardContent>24 spam messages detected.</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
@@ -0,0 +1,195 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { FirewallClient } from '@/types/api'
function formatPacketCount(value?: number | null): string | null {
if (value == null || value <= 0) return null
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`
return String(value)
}
export interface FirewallClientsGridProps {
clients: FirewallClient[]
isLoading?: boolean
onApprove: (id: string) => void
onReject: (id: string) => void
approvePending?: boolean
rejectPending?: boolean
emptyTitle?: string
}
export function FirewallClientsGrid({
clients,
isLoading = false,
onApprove,
onReject,
approvePending = false,
rejectPending = false,
emptyTitle = 'Нет клиентов',
}: FirewallClientsGridProps) {
const columns = useMemo<ColumnDef<FirewallClient>[]>(
() => [
{
accessorKey: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.name}
subtitle={row.original.hostname || row.original.token_prefix}
accent="primary"
/>
),
meta: { headerTitle: 'Имя' },
},
{
accessorKey: 'status',
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
cell: ({ row }) => <StatusBadge status={row.original.status} />,
meta: { headerTitle: 'Статус' },
},
{
id: 'last_seen_at',
accessorFn: (row) => row.last_seen_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Последняя активность" />,
cell: ({ row }) => (
<DataGridMutedCell>{row.original.last_seen_at?.slice(0, 19) ?? '—'}</DataGridMutedCell>
),
sortingFn: (a, b) => {
const av = a.original.last_seen_at ?? ''
const bv = b.original.last_seen_at ?? ''
return av.localeCompare(bv)
},
meta: { headerTitle: 'Последняя активность' },
},
{
id: 'apply',
enableSorting: false,
header: 'Применение',
cell: ({ row }) => {
const c = row.original
return (
<span className="text-xs">
{c.last_apply_status ?? '—'}
{c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''}
</span>
)
},
meta: { headerTitle: 'Применение' },
},
{
id: 'packets',
enableSorting: false,
header: 'Пакеты',
cell: ({ row }) => {
const dropped = formatPacketCount(row.original.last_apply_packets_dropped)
const accepted = formatPacketCount(row.original.last_apply_packets_accepted)
if (!dropped && !accepted) {
return <span className="text-muted-foreground text-xs"></span>
}
return (
<span className="text-muted-foreground text-xs">
{dropped ? <span className="text-destructive">{dropped}</span> : null}
{dropped && accepted ? ' · ' : null}
{accepted ? <span className="text-success">{accepted}</span> : null}
</span>
)
},
meta: { headerTitle: 'Пакеты' },
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }) => {
const c = row.original
return (
<div className="flex justify-end gap-2">
{c.status === 'pending' ? (
<>
<Button
size="sm"
variant="outline"
type="button"
disabled={approvePending}
onClick={() => onApprove(c.id)}
>
Одобрить
</Button>
<ConfirmDialog
trigger={
<Button
size="sm"
variant="outline"
type="button"
className="text-destructive"
disabled={rejectPending}
>
Отклонить
</Button>
}
title="Отклонить запрос?"
description={`${c.name}${c.hostname ? ` (${c.hostname})` : ''} — запись будет удалена, токен перестанет работать.`}
confirmLabel="Отклонить"
destructive
onConfirm={() => onReject(c.id)}
/>
</>
) : null}
{c.status === 'approved' ? (
<ConfirmDialog
trigger={
<Button
size="sm"
variant="ghost"
type="button"
className="text-destructive"
disabled={rejectPending}
>
Удалить
</Button>
}
title="Удалить клиент?"
description={`${c.name} — запись будет удалена, blocklist и токен перестанут работать.`}
confirmLabel="Удалить"
destructive
onConfirm={() => onReject(c.id)}
/>
) : null}
</div>
)
},
},
],
[approvePending, onApprove, onReject, rejectPending],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: clients,
columns,
getSearchText: (row) =>
`${row.name} ${row.hostname ?? ''} ${row.token_prefix} ${row.status ?? ''}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage={emptyTitle}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск клиентов…"
/>
)
}
@@ -0,0 +1,101 @@
import { useEffect, useState } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { SelectField } from '@/components/select-field'
import { useCreateFirewallRule } from '@/queries/firewall'
import type { BgpCommunity } from '@/types/api'
const FIREWALL_ACTION_ITEMS = [
{ value: 'block', label: 'block' },
{ value: 'accept', label: 'accept' },
] as const
interface FirewallRuleCreateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
communities: BgpCommunity[]
}
export function FirewallRuleCreateDialog({
open,
onOpenChange,
communities,
}: FirewallRuleCreateDialogProps) {
const createMutation = useCreateFirewallRule()
const [action, setAction] = useState<'block' | 'accept'>('block')
const [communityId, setCommunityId] = useState<string | null>(null)
const [comment, setComment] = useState('')
useEffect(() => {
if (!open) return
setAction('block')
setCommunityId(null)
setComment('')
}, [open])
async function save() {
try {
await createMutation.mutateAsync({
scope: 'tenant',
action,
community_id: communityId,
comment: comment.trim(),
})
onOpenChange(false)
} catch {
// toast handled in mutation
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title="Новое правило"
className="sm:max-w-md"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" onClick={save} loading={createMutation.isPending}>
Добавить
</LoadingButton>
</>
}
>
<SelectField
id="fw-rule-action"
label="Действие"
items={[...FIREWALL_ACTION_ITEMS]}
value={action}
placeholder="Выберите действие"
onValueChange={(v) => v && setAction(v as 'block' | 'accept')}
/>
<CommunitySelect
id="fw-rule-community"
label="Community"
value={communityId}
onValueChange={setCommunityId}
communities={communities}
nullable
placeholder="Все communities"
/>
<div className="flex flex-col gap-2">
<Label htmlFor="fw-rule-comment">Комментарий</Label>
<Input
id="fw-rule-comment"
placeholder="Комментарий"
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
</div>
</FormDrawer>
)
}
@@ -0,0 +1,122 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridSection } from '@/components/data-grid-shell'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { communityLabel } from '@/lib/modules/helpers'
import type { BgpCommunity, FirewallRule } from '@/types/api'
export interface FirewallRulesGridProps {
rules: FirewallRule[]
communities: BgpCommunity[]
isLoading?: boolean
onDelete: (id: string) => void
deletePending?: boolean
emptyTitle?: string
}
export function FirewallRulesGrid({
rules,
communities,
isLoading = false,
onDelete,
deletePending = false,
emptyTitle = 'Нет правил — blocklist пуст (default accept).',
}: FirewallRulesGridProps) {
const columns = useMemo<ColumnDef<FirewallRule>[]>(
() => [
{
accessorKey: 'priority',
header: ({ column }) => <DataGridColumnHeader column={column} title="#" />,
cell: ({ row }) => row.original.priority,
meta: { headerTitle: '#' },
},
{
accessorKey: 'action',
header: ({ column }) => <DataGridColumnHeader column={column} title="Действие" />,
cell: ({ row }) => (
<StatusBadge status={row.original.action} label={row.original.action} />
),
meta: { headerTitle: 'Действие' },
},
{
id: 'community',
enableSorting: false,
header: 'Community',
cell: ({ row }) => (
<span className="text-sm">
{row.original.community_id
? communityLabel(row.original.community_id, communities)
: 'Все'}
</span>
),
meta: { headerTitle: 'Community' },
},
{
accessorKey: 'comment',
enableSorting: false,
header: 'Комментарий',
cell: ({ row }) => row.original.comment || '—',
meta: { headerTitle: 'Комментарий' },
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }) => {
const r = row.original
return (
<ConfirmDialog
trigger={
<Button
size="sm"
variant="ghost"
type="button"
className="text-destructive"
disabled={deletePending}
>
Удалить
</Button>
}
title="Удалить правило?"
description={
r.comment
? `Правило #${r.priority} (${r.action}): ${r.comment}`
: `Правило #${r.priority} (${r.action}) будет удалено.`
}
confirmLabel="Удалить"
destructive
onConfirm={() => onDelete(r.id)}
/>
)
},
},
],
[communities, deletePending, onDelete],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: rules,
columns,
getSearchText: (row) =>
`${row.priority} ${row.action} ${row.comment ?? ''} ${communityLabel(row.community_id, communities)}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage={emptyTitle}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск правил…"
/>
)
}
+51
View File
@@ -0,0 +1,51 @@
import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from '@evobgp/ui/components/drawer'
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
import {
DrawerActionsFooter,
formDrawerContentClassName,
} from '@/components/drawer-layout'
interface FormDrawerProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: string
children: ReactNode
footer: ReactNode
className?: string
}
export function FormDrawer({
open,
onOpenChange,
title,
description,
children,
footer,
className,
}: FormDrawerProps) {
return (
<Drawer open={open} onOpenChange={onOpenChange} swipeDirection="right">
<DrawerContent className={cn(formDrawerContentClassName, className)}>
<DrawerHeader className="shrink-0 border-b border-border pb-4">
<DrawerTitle>{title}</DrawerTitle>
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
</DrawerHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-4 px-4 py-4">{children}</div>
</ScrollArea>
<DrawerActionsFooter>{footer}</DrawerActionsFooter>
</DrawerContent>
</Drawer>
)
}
+3 -3
View File
@@ -56,7 +56,7 @@ interface NavGroup {
const NAV_GROUPS: NavGroup[] = [ const NAV_GROUPS: NavGroup[] = [
{ {
label: 'Обзор', label: 'Обзор',
items: [{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }], items: [{ to: '/dashboard', label: 'Панель', icon: LayoutDashboard }],
}, },
{ {
label: 'Маршрутизация', label: 'Маршрутизация',
@@ -70,7 +70,7 @@ const NAV_GROUPS: NavGroup[] = [
label: 'Операции', label: 'Операции',
items: [ items: [
{ to: '/operations', label: 'Операции', icon: Cog }, { to: '/operations', label: 'Операции', icon: Cog },
{ to: '/firewall', label: 'Firewall', icon: Shield }, { to: '/firewall', label: 'Файрвол', icon: Shield },
{ to: '/schedule', label: 'Задачи', icon: ListChecks }, { to: '/schedule', label: 'Задачи', icon: ListChecks },
{ to: '/monitoring', label: 'Мониторинг', icon: Activity }, { to: '/monitoring', label: 'Мониторинг', icon: Activity },
], ],
@@ -111,7 +111,7 @@ export function AppShell({ children }: { children: ReactNode }) {
</div> </div>
<div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden"> <div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
<span className="truncate text-sm font-semibold">EvoBGP</span> <span className="truncate text-sm font-semibold">EvoBGP</span>
<span className="truncate text-xs text-muted-foreground">Control Plane</span> <span className="truncate text-xs text-muted-foreground">Плоскость управления</span>
</div> </div>
</div> </div>
</SidebarHeader> </SidebarHeader>
@@ -1,14 +1,8 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { Label } from '@evobgp/ui/components/label' import { Field, FieldLabel } from '@evobgp/ui/components/field'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { SelectMenu } from '@/components/select-field'
import { import {
NONE_OPTION, NONE_OPTION,
communityOptionLabel, communityOptionLabel,
@@ -49,28 +43,27 @@ export function CommunitySelect({
const selectValue = nullable ? nullableSelectValue(value) : (value ?? '') const selectValue = nullable ? nullableSelectValue(value) : (value ?? '')
const select = (
<SelectMenu
id={id}
items={items}
value={selectValue}
placeholder={placeholder}
onValueChange={(v) => {
if (!v) return
onValueChange(nullable ? fromNullableSelect(v) : v)
}}
/>
)
if (!label) {
return select
}
return ( return (
<div className="flex flex-col gap-1.5"> <Field>
{label ? <Label htmlFor={id}>{label}</Label> : null} <FieldLabel htmlFor={id}>{label}</FieldLabel>
<Select {select}
items={items} </Field>
value={selectValue}
onValueChange={(v) => {
if (!v) return
onValueChange(nullable ? fromNullableSelect(v) : v)
}}
>
<SelectTrigger id={id} className="w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) )
} }
@@ -1,17 +1,10 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input' import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label' import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select' import { CommunitySelect } from '@/components/modules/community-select'
import { ApiError, apiMutate } from '@/lib/api-client' import { ApiError, apiMutate } from '@/lib/api-client'
@@ -72,45 +65,43 @@ export function ModuleAsEntryDialog({
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <FormDrawer
<DialogContent className="sm:max-w-sm"> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle>{edit ? 'Редактировать запись' : 'Новая AS-запись'}</DialogTitle> title={edit ? 'Редактировать запись' : 'Новая AS-запись'}
<DialogDescription> description="Номер автономной системы и community для политики анонса."
Номер автономной системы и community для политики анонса. className="sm:max-w-sm"
</DialogDescription> footer={
</DialogHeader> <>
<div className="space-y-4 py-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="as-asn">ASN</Label>
<Input
id="as-asn"
type="number"
placeholder="12345"
value={form.asn || ''}
min={1}
max={4294967295}
onChange={(e) => setForm((s) => ({ ...s, asn: Number(e.target.value) }))}
/>
</div>
<CommunitySelect
id="as-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}> <LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена Отмена
</LoadingButton> </LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}> <LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'} {edit ? 'Сохранить' : 'Добавить'}
</LoadingButton> </LoadingButton>
</DialogFooter> </>
</DialogContent> }
</Dialog> >
<div className="flex flex-col gap-1.5">
<Label htmlFor="as-asn">ASN</Label>
<Input
id="as-asn"
type="number"
placeholder="12345"
value={form.asn || ''}
min={1}
max={4294967295}
onChange={(e) => setForm((s) => ({ ...s, asn: Number(e.target.value) }))}
/>
</div>
<CommunitySelect
id="as-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
</FormDrawer>
) )
} }
@@ -1,26 +1,14 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { import { Button } from '@evobgp/ui/components/button'
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input' import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label' import { Label } from '@evobgp/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { Button } from '@evobgp/ui/components/button'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select' import { CommunitySelect } from '@/components/modules/community-select'
import { SelectField } from '@/components/select-field'
import { ApiError, apiMutate } from '@/lib/api-client' import { ApiError, apiMutate } from '@/lib/api-client'
import { normalizeCdnSourceKind } from '@/lib/modules/helpers' import { normalizeCdnSourceKind } from '@/lib/modules/helpers'
import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api' import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
@@ -159,119 +147,107 @@ export function ModuleCdnSourceDialog({
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <FormDrawer
<DialogContent className="sm:max-w-lg"> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle>{edit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle> title={edit ? 'Редактировать источник' : 'Новый CDN-источник'}
</DialogHeader> className="sm:max-w-lg"
<div className="space-y-4 py-2"> footer={
<div className="flex flex-col gap-1.5"> <>
<Label htmlFor="cdn-url">URL</Label>
<Input
id="cdn-url"
placeholder="https://example.com/list.txt"
value={form.url}
onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-kind">Тип источника</Label>
<Select
items={kindItems}
value={form.source_kind}
onValueChange={(v) => v && setForm((s) => ({ ...s, source_kind: v }))}
>
<SelectTrigger id="cdn-kind" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{kindItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-prefix-path">JSON path (prefix_path)</Label>
<Input
id="cdn-prefix-path"
placeholder="напр. prefixes[] или data.items[].cidr"
value={form.prefix_path ?? ''}
onChange={(e) => setForm((s) => ({ ...s, prefix_path: e.target.value }))}
/>
{form.source_kind === 'json' && !form.prefix_path?.trim() ? (
<p className="text-xs text-muted-foreground">
Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов.
</p>
) : null}
</div>
<CommunitySelect
id="cdn-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-interval">Интервал обновления (сек)</Label>
<Input
id="cdn-interval"
type="number"
placeholder="3600"
value={form.refresh_interval_sec ?? ''}
onChange={(e) =>
setForm((s) => ({
...s,
refresh_interval_sec: e.target.value ? Number(e.target.value) : null,
}))
}
/>
</div>
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void previewCdn()}
disabled={previewLoading}
>
{previewLoading ? 'Загрузка…' : 'Предпросмотр'}
</Button>
{previewError ? (
<span className="text-sm text-destructive">{previewError}</span>
) : previewOk ? (
<span className="text-sm text-muted-foreground">
Всего: {previewTotal}
{previewTruncated ? (
<span className="text-amber-600 dark:text-amber-500"> (обрезано)</span>
) : null}
</span>
) : null}
</div>
{previewItems.length > 0 ? (
<ul className="max-h-48 overflow-y-auto rounded-md border bg-muted/40 p-2 font-mono text-xs">
{previewItems.map((item, i) => (
<li key={`${i}-${item}`} className="py-0.5">
{item}
</li>
))}
</ul>
) : null}
</div>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}> <LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена Отмена
</LoadingButton> </LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}> <LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'} {edit ? 'Сохранить' : 'Добавить'}
</LoadingButton> </LoadingButton>
</DialogFooter> </>
</DialogContent> }
</Dialog> >
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-url">URL</Label>
<Input
id="cdn-url"
placeholder="https://example.com/list.txt"
value={form.url}
onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))}
/>
</div>
<SelectField
id="cdn-kind"
label="Тип источника"
items={kindItems}
value={form.source_kind}
onValueChange={(v) => v && setForm((s) => ({ ...s, source_kind: v }))}
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-prefix-path">JSON path (prefix_path)</Label>
<Input
id="cdn-prefix-path"
placeholder="напр. prefixes[] или data.items[].cidr"
value={form.prefix_path ?? ''}
onChange={(e) => setForm((s) => ({ ...s, prefix_path: e.target.value }))}
/>
{form.source_kind === 'json' && !form.prefix_path?.trim() ? (
<p className="text-xs text-muted-foreground">
Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов.
</p>
) : null}
</div>
<CommunitySelect
id="cdn-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-interval">Интервал обновления (сек)</Label>
<Input
id="cdn-interval"
type="number"
placeholder="3600"
value={form.refresh_interval_sec ?? ''}
onChange={(e) =>
setForm((s) => ({
...s,
refresh_interval_sec: e.target.value ? Number(e.target.value) : null,
}))
}
/>
</div>
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void previewCdn()}
disabled={previewLoading}
>
{previewLoading ? 'Загрузка…' : 'Предпросмотр'}
</Button>
{previewError ? (
<span className="text-sm text-destructive">{previewError}</span>
) : previewOk ? (
<span className="text-sm text-muted-foreground">
Всего: {previewTotal}
{previewTruncated ? (
<span className="text-amber-600 dark:text-amber-500"> (обрезано)</span>
) : null}
</span>
) : null}
</div>
{previewItems.length > 0 ? (
<ul className="max-h-48 overflow-y-auto rounded-md border bg-muted/40 p-2 font-mono text-xs">
{previewItems.map((item, i) => (
<li key={`${i}-${item}`} className="py-0.5">
{item}
</li>
))}
</ul>
) : null}
</div>
</FormDrawer>
) )
} }
@@ -1,16 +1,10 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input' import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label' import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select' import { CommunitySelect } from '@/components/modules/community-select'
import { ApiError, apiMutate } from '@/lib/api-client' import { ApiError, apiMutate } from '@/lib/api-client'
@@ -70,39 +64,39 @@ export function ModuleDomainEntryDialog({
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <FormDrawer
<DialogContent className="sm:max-w-sm"> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle>{edit ? 'Редактировать домен' : 'Новый домен'}</DialogTitle> title={edit ? 'Редактировать домен' : 'Новый домен'}
</DialogHeader> className="sm:max-w-sm"
<div className="space-y-4 py-2"> footer={
<div className="flex flex-col gap-1.5"> <>
<Label htmlFor="dom-fqdn">FQDN</Label>
<Input
id="dom-fqdn"
placeholder="example.com"
value={form.fqdn}
onChange={(e) => setForm((s) => ({ ...s, fqdn: e.target.value }))}
/>
</div>
<CommunitySelect
id="dom-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}> <LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена Отмена
</LoadingButton> </LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}> <LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'} {edit ? 'Сохранить' : 'Добавить'}
</LoadingButton> </LoadingButton>
</DialogFooter> </>
</DialogContent> }
</Dialog> >
<div className="flex flex-col gap-1.5">
<Label htmlFor="dom-fqdn">FQDN</Label>
<Input
id="dom-fqdn"
placeholder="example.com"
value={form.fqdn}
onChange={(e) => setForm((s) => ({ ...s, fqdn: e.target.value }))}
/>
</div>
<CommunitySelect
id="dom-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
</FormDrawer>
) )
} }
@@ -0,0 +1,259 @@
import { ColumnDef } from '@tanstack/react-table'
import { Pencil, Trash2 } from 'lucide-react'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { formatDateTime } from '@/lib/modules/display'
import { communityLabel } from '@/lib/modules/helpers'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type {
AsEntry,
BgpCommunity,
CdnSource,
DomainEntry,
IpRangeEntry,
ModuleRow,
} from '@/types/api'
type DeleteTarget =
| { kind: 'domain'; entry: DomainEntry }
| { kind: 'ip-range'; entry: IpRangeEntry }
| { kind: 'cdn'; entry: CdnSource }
| { kind: 'as'; entry: AsEntry }
function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) {
return (
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon-sm" type="button" onClick={onEdit} aria-label="Редактировать">
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
type="button"
className="text-destructive"
onClick={onDelete}
aria-label="Удалить"
>
<Trash2 className="size-3.5" />
</Button>
</div>
)
}
export function ModuleEntriesGrid({
mod,
rows,
communities,
onEdit,
onDelete,
isLoading = false,
}: {
mod: ModuleRow
rows: Record<string, unknown>[]
communities: BgpCommunity[]
onEdit: (target: DeleteTarget) => void
onDelete: (target: DeleteTarget) => void
isLoading?: boolean
}) {
const columns = useMemo(() => {
if (mod.type === 'DOMAINS') {
return [
{
accessorKey: 'fqdn',
header: ({ column }: { column: { id: string } }) => (
<DataGridColumnHeader column={column as never} title="FQDN" />
),
cell: ({ row }: { row: { original: DomainEntry } }) => (
<DataGridPrimaryCell title={row.original.fqdn} accent="mono" />
),
},
{
id: 'community',
header: 'Community',
cell: ({ row }: { row: { original: DomainEntry } }) => (
<span className="text-sm text-muted-foreground">
{communityLabel(row.original.community_id, communities)}
</span>
),
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }: { row: { original: DomainEntry } }) => (
<RowActions
onEdit={() => onEdit({ kind: 'domain', entry: row.original })}
onDelete={() => onDelete({ kind: 'domain', entry: row.original })}
/>
),
},
] as ColumnDef<DomainEntry>[]
}
if (mod.type === 'IP_RANGES') {
return [
{
accessorKey: 'prefix',
header: ({ column }: { column: { id: string } }) => (
<DataGridColumnHeader column={column as never} title="Префикс (CIDR)" />
),
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
<DataGridPrimaryCell title={row.original.prefix} accent="mono" />
),
},
{
id: 'community',
header: 'Community',
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
<span className="text-sm text-muted-foreground">
{communityLabel(row.original.community_id, communities)}
</span>
),
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }: { row: { original: IpRangeEntry } }) => (
<RowActions
onEdit={() => onEdit({ kind: 'ip-range', entry: row.original })}
onDelete={() => onDelete({ kind: 'ip-range', entry: row.original })}
/>
),
},
] as ColumnDef<IpRangeEntry>[]
}
if (mod.type === 'CDN_CIDRS') {
return [
{
accessorKey: 'url',
header: ({ column }: { column: { id: string } }) => (
<DataGridColumnHeader column={column as never} title="URL" />
),
cell: ({ row }: { row: { original: CdnSource } }) => (
<DataGridPrimaryCell title={row.original.url} accent="mono" className="max-w-xs" />
),
},
{
accessorKey: 'source_kind',
header: 'Тип',
cell: ({ row }: { row: { original: CdnSource } }) => (
<CategoryBadge>{row.original.source_kind}</CategoryBadge>
),
},
{
id: 'community',
header: 'Community',
cell: ({ row }: { row: { original: CdnSource } }) => (
<span className="text-sm text-muted-foreground">
{communityLabel(row.original.community_id, communities)}
</span>
),
},
{
id: 'last_refreshed_at',
accessorFn: (row: CdnSource) => row.last_refreshed_at ?? '',
header: ({ column }: { column: { id: string } }) => (
<DataGridColumnHeader column={column as never} title="Обновлено" />
),
cell: ({ row }: { row: { original: CdnSource } }) => (
<DataGridMutedCell>{formatDateTime(row.original.last_refreshed_at)}</DataGridMutedCell>
),
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }: { row: { original: CdnSource } }) => (
<RowActions
onEdit={() => onEdit({ kind: 'cdn', entry: row.original })}
onDelete={() => onDelete({ kind: 'cdn', entry: row.original })}
/>
),
},
] as ColumnDef<CdnSource>[]
}
return [
{
accessorKey: 'asn',
header: ({ column }: { column: { id: string } }) => (
<DataGridColumnHeader column={column as never} title="ASN" />
),
cell: ({ row }: { row: { original: AsEntry } }) => (
<span className="font-mono text-sm">{row.original.asn}</span>
),
},
{
accessorKey: 'asn_name',
header: 'Имя',
cell: ({ row }: { row: { original: AsEntry } }) => (
<span className="text-sm">{row.original.asn_name ?? '—'}</span>
),
},
{
accessorKey: 'prefix_count',
header: 'Префиксов',
cell: ({ row }: { row: { original: AsEntry } }) => (
<span className="font-mono text-sm">{row.original.prefix_count ?? '—'}</span>
),
},
{
id: 'community',
header: 'Community',
cell: ({ row }: { row: { original: AsEntry } }) => (
<span className="text-sm text-muted-foreground">
{communityLabel(row.original.community_id, communities)}
</span>
),
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }: { row: { original: AsEntry } }) => (
<RowActions
onEdit={() => onEdit({ kind: 'as', entry: row.original })}
onDelete={() => onDelete({ kind: 'as', entry: row.original })}
/>
),
},
] as ColumnDef<AsEntry>[]
}, [communities, mod.type, onDelete, onEdit])
type RowType = DomainEntry | IpRangeEntry | CdnSource | AsEntry
const data = rows as unknown as RowType[]
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data,
columns: columns as ColumnDef<RowType>[],
getSearchText: (row) => {
const r = row as Record<string, unknown>
return Object.values(r)
.filter((v) => typeof v === 'string' || typeof v === 'number')
.join(' ')
},
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет записей"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск записей…"
/>
)
}
export type { DeleteTarget as ModuleEntryDeleteTarget }
@@ -1,37 +1,19 @@
import { useState } from 'react' import { useState } from 'react'
import { Pencil, Plus, Trash2 } from 'lucide-react' import { Plus } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@evobgp/ui/components/alert-dialog'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataGridCard } from '@/components/data-grid-shell'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons' 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 { ModuleAsEntryDialog } from '@/components/modules/module-as-entry-dialog'
import { ModuleCdnSourceDialog } from '@/components/modules/module-cdn-source-dialog' import { ModuleCdnSourceDialog } from '@/components/modules/module-cdn-source-dialog'
import { ModuleDomainEntryDialog } from '@/components/modules/module-domain-entry-dialog' import { ModuleDomainEntryDialog } from '@/components/modules/module-domain-entry-dialog'
import { ModuleIpRangeEntryDialog } from '@/components/modules/module-ip-range-entry-dialog' import { ModuleIpRangeEntryDialog } from '@/components/modules/module-ip-range-entry-dialog'
import { ApiError, apiMutate } from '@/lib/api-client' import { ApiError, apiMutate } from '@/lib/api-client'
import { communityLabel } from '@/lib/modules/helpers'
import { formatDateTime } from '@/lib/modules/display'
import type { import type {
AsEntry, AsEntry,
BgpCommunity, BgpCommunity,
@@ -53,11 +35,7 @@ interface ModuleEntriesSectionProps {
onChanged: () => void | Promise<void> onChanged: () => void | Promise<void>
} }
type DeleteTarget = type DeleteTarget = ModuleEntryDeleteTarget
| { kind: 'domain'; entry: DomainEntry }
| { kind: 'ip-range'; entry: IpRangeEntry }
| { kind: 'cdn'; entry: CdnSource }
| { kind: 'as'; entry: AsEntry }
const CARD_META: Record< const CARD_META: Record<
ModuleRow['type'], ModuleRow['type'],
@@ -150,49 +128,45 @@ export function ModuleEntriesSection({
return ( return (
<> <>
<Card> <DataGridCard
<CardHeader className="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"> title={meta.title}
<div className="min-w-0 flex-1"> description={meta.description}
<CardTitle className="text-base">{meta.title}</CardTitle> actions={
<CardDescription>{meta.description}</CardDescription> <Button size="sm" type="button" onClick={openCreate}>
</div> <Plus />
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto"> Добавить
<Button size="sm" onClick={openCreate}> </Button>
<Plus /> }
Добавить >
</Button> <QueryState
</div> data={items}
</CardHeader> isLoading={isLoading}
<CardContent className="p-0"> isError={isError}
<QueryState error={error}
data={items} empty={items.length === 0}
isLoading={isLoading} emptyTitle={meta.emptyTitle}
isError={isError} emptyDescription={meta.emptyDescription}
error={error} skeleton={<TableSkeleton rows={5} cols={3} />}
empty={items.length === 0} onRetry={onRetry}
emptyTitle={meta.emptyTitle} >
emptyDescription={meta.emptyDescription} {(rows) => (
skeleton={<TableSkeleton rows={5} cols={3} />} <ModuleEntriesGrid
onRetry={onRetry} mod={mod}
> rows={rows}
{(rows) => ( communities={communities}
<EntriesTable isLoading={isLoading}
mod={mod} onEdit={(target) => {
rows={rows} if (target.kind === 'domain') setEditDomain(target.entry)
communities={communities} if (target.kind === 'ip-range') setEditIpRange(target.entry)
onEdit={(target) => { if (target.kind === 'cdn') setEditCdn(target.entry)
if (target.kind === 'domain') setEditDomain(target.entry) if (target.kind === 'as') setEditAs(target.entry)
if (target.kind === 'ip-range') setEditIpRange(target.entry) setDialogOpen(true)
if (target.kind === 'cdn') setEditCdn(target.entry) }}
if (target.kind === 'as') setEditAs(target.entry) onDelete={setDeleteTarget}
setDialogOpen(true) />
}} )}
onDelete={setDeleteTarget} </QueryState>
/> </DataGridCard>
)}
</QueryState>
</CardContent>
</Card>
{mod.type === 'DOMAINS' ? ( {mod.type === 'DOMAINS' ? (
<ModuleDomainEntryDialog <ModuleDomainEntryDialog
@@ -235,24 +209,17 @@ export function ModuleEntriesSection({
/> />
) : null} ) : null}
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}> <ConfirmDialog
<AlertDialogContent> open={deleteTarget !== null}
<AlertDialogHeader> onOpenChange={(open) => !open && setDeleteTarget(null)}
<AlertDialogTitle>Удалить запись?</AlertDialogTitle> title="Удалить запись?"
<AlertDialogDescription>{deleteDescription(deleteTarget)}</AlertDialogDescription> description={deleteDescription(deleteTarget)}
</AlertDialogHeader> confirmLabel="Удалить"
<AlertDialogFooter> confirmLoadingLabel="Удаление…"
<AlertDialogCancel disabled={deleting}>Отмена</AlertDialogCancel> destructive
<AlertDialogAction confirmLoading={deleting}
variant="destructive" onConfirm={() => void confirmDelete()}
disabled={deleting} />
onClick={() => void confirmDelete()}
>
{deleting ? 'Удаление…' : 'Удалить'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</> </>
) )
} }
@@ -270,168 +237,3 @@ function deleteDescription(target: DeleteTarget | null): string {
return `AS${target.entry.asn}` return `AS${target.entry.asn}`
} }
} }
function EntriesTable({
mod,
rows,
communities,
onEdit,
onDelete,
}: {
mod: ModuleRow
rows: Record<string, unknown>[]
communities: BgpCommunity[]
onEdit: (target: DeleteTarget) => void
onDelete: (target: DeleteTarget) => void
}) {
if (mod.type === 'DOMAINS') {
const entries = rows as unknown as DomainEntry[]
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>FQDN</TableHead>
<TableHead>Community</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="font-mono text-sm">{entry.fqdn}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</TableCell>
<TableCell>
<RowActions
onEdit={() => onEdit({ kind: 'domain', entry })}
onDelete={() => onDelete({ kind: 'domain', entry })}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
if (mod.type === 'IP_RANGES') {
const entries = rows as unknown as IpRangeEntry[]
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Префикс (CIDR)</TableHead>
<TableHead>Community</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="font-mono text-sm">{entry.prefix}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</TableCell>
<TableCell>
<RowActions
onEdit={() => onEdit({ kind: 'ip-range', entry })}
onDelete={() => onDelete({ kind: 'ip-range', entry })}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
if (mod.type === 'CDN_CIDRS') {
const entries = rows as unknown as CdnSource[]
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>URL</TableHead>
<TableHead>Тип</TableHead>
<TableHead>Community</TableHead>
<TableHead>Обновлено</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="max-w-xs truncate font-mono text-xs">{entry.url}</TableCell>
<TableCell className="text-sm">{entry.source_kind}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDateTime(entry.last_refreshed_at)}
</TableCell>
<TableCell>
<RowActions
onEdit={() => onEdit({ kind: 'cdn', entry })}
onDelete={() => onDelete({ kind: 'cdn', entry })}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
const entries = rows as unknown as AsEntry[]
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>ASN</TableHead>
<TableHead>Имя</TableHead>
<TableHead>Префиксов</TableHead>
<TableHead>Community</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="font-mono text-sm">{entry.asn}</TableCell>
<TableCell className="text-sm">{entry.asn_name ?? '—'}</TableCell>
<TableCell className="font-mono text-sm">{entry.prefix_count ?? '—'}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</TableCell>
<TableCell>
<RowActions
onEdit={() => onEdit({ kind: 'as', entry })}
onDelete={() => onDelete({ kind: 'as', entry })}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) {
return (
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon-sm" onClick={onEdit} aria-label="Редактировать">
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-destructive"
onClick={onDelete}
aria-label="Удалить"
>
<Trash2 className="size-3.5" />
</Button>
</div>
)
}
@@ -1,16 +1,10 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input' import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label' import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select' import { CommunitySelect } from '@/components/modules/community-select'
import { ApiError, apiMutate } from '@/lib/api-client' import { ApiError, apiMutate } from '@/lib/api-client'
@@ -70,39 +64,39 @@ export function ModuleIpRangeEntryDialog({
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <FormDrawer
<DialogContent className="sm:max-w-sm"> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle>{edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}</DialogTitle> title={edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}
</DialogHeader> className="sm:max-w-sm"
<div className="space-y-4 py-2"> footer={
<div className="flex flex-col gap-1.5"> <>
<Label htmlFor="ip-prefix">Префикс (CIDR)</Label>
<Input
id="ip-prefix"
placeholder="203.0.113.0/24"
value={form.prefix}
onChange={(e) => setForm((s) => ({ ...s, prefix: e.target.value }))}
/>
</div>
<CommunitySelect
id="ip-comm"
label="Community (обязательно)"
value={form.community_id || null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v ?? '' }))}
communities={communities}
placeholder="Выберите community"
/>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}> <LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена Отмена
</LoadingButton> </LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}> <LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'} {edit ? 'Сохранить' : 'Добавить'}
</LoadingButton> </LoadingButton>
</DialogFooter> </>
</DialogContent> }
</Dialog> >
<div className="flex flex-col gap-1.5">
<Label htmlFor="ip-prefix">Префикс (CIDR)</Label>
<Input
id="ip-prefix"
placeholder="203.0.113.0/24"
value={form.prefix}
onChange={(e) => setForm((s) => ({ ...s, prefix: e.target.value }))}
/>
</div>
<CommunitySelect
id="ip-comm"
label="Community (обязательно)"
value={form.community_id || null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v ?? '' }))}
communities={communities}
placeholder="Выберите community"
/>
</FormDrawer>
) )
} }
@@ -0,0 +1,98 @@
import { ColumnDef } from '@tanstack/react-table'
import { useNavigate } from '@tanstack/react-router'
import { Boxes } from 'lucide-react'
import { useMemo } from 'react'
import { CategoryBadge, ModeBadge } from '@/components/category-badge'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { moduleTypeRu } from '@/lib/ui-labels'
import type { ModuleRow } from '@/types/api'
export function ModulesListGrid({
items,
isLoading = false,
}: {
items: ModuleRow[]
isLoading?: boolean
}) {
const navigate = useNavigate()
const columns = useMemo<ColumnDef<ModuleRow>[]>(
() => [
{
accessorKey: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Boxes className="size-4 shrink-0 text-muted-foreground" />
<DataGridPrimaryCell title={row.original.name} accent="primary" className="max-w-[280px]" />
</div>
),
meta: { headerTitle: 'Название' },
},
{
accessorKey: 'type',
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
cell: ({ row }) => <CategoryBadge>{moduleTypeRu(row.original.type)}</CategoryBadge>,
meta: { headerTitle: 'Тип' },
},
{
accessorKey: 'priority',
header: ({ column }) => <DataGridColumnHeader column={column} title="Приоритет" />,
cell: ({ row }) => (
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
),
meta: { headerTitle: 'Приоритет' },
},
{
id: 'enabled',
accessorFn: (row) => (row.enabled ? 'enabled' : 'disabled'),
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => <ModeBadge enabled={row.original.enabled} />,
meta: { headerTitle: 'Состояние' },
},
{
id: 'last_refreshed_at',
accessorFn: (row) => row.last_refreshed_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
cell: ({ row }) => (
<DataGridMutedCell>
{row.original.last_refreshed_at
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
: '—'}
</DataGridMutedCell>
),
sortingFn: (a, b) => {
const av = a.original.last_refreshed_at ?? ''
const bv = b.original.last_refreshed_at ?? ''
return av.localeCompare(bv)
},
meta: { headerTitle: 'Обновлено' },
},
],
[],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) => `${row.name} ${row.type} ${row.enabled ? 'включён' : 'выключен'}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет модулей"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск модулей…"
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
/>
)
}
@@ -0,0 +1,120 @@
import { ColumnDef } from '@tanstack/react-table'
import { Database, HardDrive, HeartPulse, ListTodo, ShieldCheck } from 'lucide-react'
import { useMemo } from 'react'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { jobStatusRu, readyCheckRu } from '@/lib/ui-labels'
import type { ReadyStatus } from '@/queries/monitoring'
const READY_CHECK_ICONS: Record<string, typeof Database> = {
postgres: Database,
store: HardDrive,
jobs: ListTodo,
}
interface ReadyCheckRow {
id: string
label: string
subtitle?: string
icon: typeof Database
status: string
statusLabel: string
}
export function MonitoringReadyGrid({
health,
ready,
}: {
health?: { ok?: boolean; status?: string; error?: string } | null
ready: ReadyStatus
}) {
const data = useMemo<ReadyCheckRow[]>(() => {
const checks = ready.checks ?? {}
const rows: ReadyCheckRow[] = [
{
id: 'liveness',
label: 'Живучесть',
subtitle: '/v1/health',
icon: HeartPulse,
status: health?.ok ? 'ok' : 'error',
statusLabel: health?.ok ? 'В норме' : 'Ошибка',
},
{
id: 'readiness',
label: 'Готовность',
subtitle: '/v1/ready',
icon: ShieldCheck,
status: ready.status === 'ok' ? 'ok' : 'warning',
statusLabel: ready.status === 'ok' ? 'Готов' : jobStatusRu(ready.status ?? 'pending'),
},
]
for (const key of Object.keys(checks)) {
const value = checks[key]
const ok = typeof value === 'boolean' ? value : value?.ok !== false
rows.push({
id: key,
label: readyCheckRu(key),
icon: READY_CHECK_ICONS[key] ?? ListTodo,
status: ok ? 'ok' : 'error',
statusLabel: ok ? 'В норме' : 'Ошибка',
})
}
return rows
}, [health?.ok, ready.checks, ready.status])
const columns = useMemo<ColumnDef<ReadyCheckRow>[]>(
() => [
{
accessorKey: 'label',
header: ({ column }) => <DataGridColumnHeader column={column} title="Проверка" />,
cell: ({ row }) => {
const Icon = row.original.icon
return (
<div className="flex items-center gap-2">
<Icon className="size-4 shrink-0 text-muted-foreground" />
<DataGridPrimaryCell
title={row.original.label}
subtitle={row.original.subtitle}
/>
</div>
)
},
meta: { headerTitle: 'Проверка' },
},
{
id: 'status',
enableSorting: false,
header: 'Статус',
cell: ({ row }) => (
<StatusBadge status={row.original.status} label={row.original.statusLabel} />
),
meta: { headerTitle: 'Статус' },
},
],
[],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data,
columns,
getSearchText: (row) => `${row.label} ${row.subtitle ?? ''} ${row.statusLabel}`,
getRowId: (row) => row.id,
pageSize: 20,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
showPagination={false}
emptyMessage="Нет проверок"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск проверок…"
/>
)
}
@@ -0,0 +1,68 @@
import { useState } from 'react'
import { Plus } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridCard } from '@/components/data-grid-shell'
import { NetworkPeersGrid } from '@/components/network/network-peers-grid'
import { PeerFormDialog } from '@/components/network/peer-form-dialog'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import type { PeerRow, SpeakerRow } from '@/types/api'
interface NetworkPeersCardProps {
items: PeerRow[]
speakers: SpeakerRow[]
isLoading: boolean
isError: boolean
error: unknown
onRetry: () => void
}
export function NetworkPeersCard({
items,
speakers,
isLoading,
isError,
error,
onRetry,
}: NetworkPeersCardProps) {
const [dialogOpen, setDialogOpen] = useState(false)
return (
<>
<DataGridCard
title="Пиры"
description="BGP-соседи и привязка к спикерам"
actions={
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить пира
</Button>
}
>
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle="Нет пиров"
emptyDescription="Добавьте первого BGP-соседа для установки сессии."
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={onRetry}
>
{(data) => (
<NetworkPeersGrid items={data} isLoading={isLoading && data.length > 0} />
)}
</QueryState>
</DataGridCard>
<PeerFormDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
speakers={speakers}
/>
</>
)
}
@@ -0,0 +1,90 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { bgpSessionStateRu } from '@/lib/ui-labels'
import type { PeerRow } from '@/types/api'
export function NetworkPeersGrid({
items,
isLoading = false,
}: {
items: PeerRow[]
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<PeerRow>[]>(
() => [
{
id: 'name',
accessorFn: (row) => row.name ?? row.neighbor,
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.name ?? row.original.neighbor}
subtitle={row.original.name ? row.original.neighbor : undefined}
accent="primary"
/>
),
meta: { headerTitle: 'Имя' },
},
{
accessorKey: 'neighbor',
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
),
meta: { headerTitle: 'Адрес соседа' },
},
{
accessorKey: 'remote_asn',
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span>
),
meta: { headerTitle: 'ASN' },
},
{
accessorKey: 'session_state',
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => (
<div className="flex items-center gap-1">
<StatusBadge
status={row.original.session_state ?? '—'}
label={bgpSessionStateRu(row.original.session_state)}
/>
{row.original.session_mismatch ? (
<CategoryBadge tone="warning">расхождение</CategoryBadge>
) : null}
</div>
),
meta: { headerTitle: 'Состояние' },
},
],
[],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) =>
`${row.name ?? ''} ${row.neighbor} ${row.remote_asn ?? ''} ${row.session_state ?? ''}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет пиров"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск пиров…"
/>
)
}
@@ -0,0 +1,62 @@
import { useState } from 'react'
import { Plus } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridCard } from '@/components/data-grid-shell'
import { NetworkSpeakersGrid } from '@/components/network/network-speakers-grid'
import { SpeakerFormDialog } from '@/components/network/speaker-form-dialog'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import type { SpeakerRow } from '@/types/api'
interface NetworkSpeakersCardProps {
items: SpeakerRow[]
isLoading: boolean
isError: boolean
error: unknown
onRetry: () => void
}
export function NetworkSpeakersCard({
items,
isLoading,
isError,
error,
onRetry,
}: NetworkSpeakersCardProps) {
const [dialogOpen, setDialogOpen] = useState(false)
return (
<>
<DataGridCard
title="Спикеры"
description="BIRD-агенты на нодах tenant"
actions={
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить спикера
</Button>
}
>
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle="Нет спикеров"
emptyDescription="Добавьте первого BIRD-агента на ноде."
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={onRetry}
>
{(data) => (
<NetworkSpeakersGrid items={data} isLoading={isLoading && data.length > 0} />
)}
</QueryState>
</DataGridCard>
<SpeakerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</>
)
}
@@ -0,0 +1,87 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { speakerOnlineLabel } from '@/lib/ui-labels'
import type { SpeakerRow } from '@/types/api'
export function NetworkSpeakersGrid({
items,
isLoading = false,
}: {
items: SpeakerRow[]
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<SpeakerRow>[]>(
() => [
{
accessorKey: 'endpoint',
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
),
meta: { headerTitle: 'Конечная точка' },
},
{
accessorKey: 'role',
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
meta: { headerTitle: 'Роль' },
},
{
id: 'agent',
enableSorting: false,
header: 'Агент',
cell: ({ row }) => {
const live = row.original.live
if (live?.agent_ok === true) return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
if (live?.agent_ok === false) return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
return <Badge variant="outline" size="sm" radius="full"></Badge>
},
meta: { headerTitle: 'Агент' },
},
{
id: 'bgp',
enableSorting: false,
header: 'BGP',
cell: ({ row }) => {
const live = row.original.live
if (!live) return '—'
return (
<span className="text-xs">
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
</span>
)
},
meta: { headerTitle: 'BGP' },
},
],
[],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) =>
`${row.endpoint} ${row.role} ${row.agent_domain ?? ''} ${row.node_ipv4 ?? ''}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет спикеров"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск спикеров…"
/>
)
}
@@ -0,0 +1,168 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Checkbox } from '@evobgp/ui/components/checkbox'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { useCreatePeerMutation, useUpdatePeerMutation } from '@/queries/network'
import type { BgpPeerCreate, PeerRow, SpeakerRow } from '@/types/api'
interface PeerFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
speakers: SpeakerRow[]
editTarget?: PeerRow | null
}
function speakerLabel(s: SpeakerRow): string {
if (s.role === 'master') {
const host = s.agent_domain ?? s.endpoint
return host ? `CP · ${host}` : 'CP (master)'
}
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}`
}
export function PeerFormDialog({
open,
onOpenChange,
speakers,
editTarget = null,
}: PeerFormDialogProps) {
const createMutation = useCreatePeerMutation()
const updateMutation = useUpdatePeerMutation()
const saving = createMutation.isPending || updateMutation.isPending
const [name, setName] = useState('')
const [neighbor, setNeighbor] = useState('')
const [remoteAsn, setRemoteAsn] = useState('')
const [bgpSpeakerId, setBgpSpeakerId] = useState<string | null>(null)
const [enabled, setEnabled] = useState(true)
useEffect(() => {
if (!open) return
if (editTarget) {
setName(editTarget.name ?? '')
setNeighbor(editTarget.neighbor)
setRemoteAsn(String(editTarget.remote_asn ?? ''))
setBgpSpeakerId(editTarget.bgp_speaker_id ?? null)
setEnabled(editTarget.enabled !== false)
} else {
setName('')
setNeighbor('')
setRemoteAsn('')
setBgpSpeakerId(null)
setEnabled(true)
}
}, [editTarget, open])
const speakerItems = [
{ value: '', label: 'Все спикеры' },
...speakers.map((s) => ({ value: s.id, label: speakerLabel(s) })),
]
async function save() {
if (!neighbor.trim()) {
toast.error('Укажите адрес соседа')
return
}
const asn = Number(remoteAsn)
if (!asn || asn <= 0) {
toast.error('Remote ASN должен быть больше 0')
return
}
const body: BgpPeerCreate = {
name: name.trim() || undefined,
neighbor: neighbor.trim(),
remote_asn: asn,
bgp_speaker_id: bgpSpeakerId || null,
enabled,
}
try {
if (editTarget) {
await updateMutation.mutateAsync({ id: editTarget.id, body })
} else {
await createMutation.mutateAsync(body)
}
onOpenChange(false)
} catch {
// toast in mutation
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title={editTarget ? 'Редактировать пира' : 'Новый пир'}
description="BGP-сосед для установки сессии"
className="sm:max-w-sm"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={saving} onClick={save}>
{editTarget ? 'Сохранить' : 'Создать'}
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-2">
<Label htmlFor="peer-name">Имя пира (опционально)</Label>
<Input
id="peer-name"
placeholder="Core-RTR-1"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="peer-neighbor">Адрес соседа</Label>
<Input
id="peer-neighbor"
placeholder="192.0.2.1"
value={neighbor}
onChange={(e) => setNeighbor(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="peer-asn">Remote ASN</Label>
<Input
id="peer-asn"
type="number"
placeholder="65000"
value={remoteAsn}
onChange={(e) => setRemoteAsn(e.target.value)}
required
/>
</div>
<SelectField
id="peer-speaker"
label="Спикер (опционально)"
items={speakerItems}
value={bgpSpeakerId ?? ''}
onValueChange={(v) => setBgpSpeakerId(v || null)}
placeholder="Все спикеры"
/>
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3">
<div className="grid min-w-0 flex-1 gap-1 pr-2">
<Label htmlFor="peer-enabled">Включён</Label>
<p className="text-xs text-muted-foreground">
Выключенный пир не попадает в конфиг BIRD до следующей ревизии.
</p>
</div>
<Checkbox
id="peer-enabled"
checked={enabled}
onCheckedChange={(v) => setEnabled(v === true)}
/>
</div>
</FormDrawer>
)
}
@@ -0,0 +1,162 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { useCreateSpeakerMutation } from '@/queries/network'
import type { BgpSpeakerCreate } from '@/types/api'
interface SpeakerFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
function parseIpv4FromEndpoint(ep: string): string {
try {
const u = ep.includes('://') ? new URL(ep) : new URL(`https://${ep}`)
const host = u.hostname
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return host
} catch {
/* ignore */
}
return ''
}
function buildMetaJson(agentDomain: string, nodeIpv4: string, bgpSource: string): string {
const meta: Record<string, string> = {}
if (agentDomain.trim()) meta.agent_domain = agentDomain.trim()
if (nodeIpv4.trim()) meta.node_ipv4 = nodeIpv4.trim()
if (bgpSource.trim()) meta.bird_bgp_source_ipv4 = bgpSource.trim()
return JSON.stringify(meta)
}
export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps) {
const createMutation = useCreateSpeakerMutation()
const [endpoint, setEndpoint] = useState('')
const [role, setRole] = useState('replica')
const [agentDomain, setAgentDomain] = useState('')
const [nodeIpv4, setNodeIpv4] = useState('')
const [bgpSourceIpv4, setBgpSourceIpv4] = useState('')
const [bgpSourceManual, setBgpSourceManual] = useState(false)
useEffect(() => {
if (!open) return
setEndpoint('')
setRole('replica')
setAgentDomain('')
setNodeIpv4('')
setBgpSourceIpv4('')
setBgpSourceManual(false)
}, [open])
function handleEndpointChange(value: string) {
setEndpoint(value)
const ip = parseIpv4FromEndpoint(value)
if (ip && !nodeIpv4) {
handleNodeIpv4Change(ip)
}
}
function handleNodeIpv4Change(value: string) {
setNodeIpv4(value)
if (!bgpSourceManual) {
setBgpSourceIpv4(value)
}
}
async function save() {
const ep =
endpoint.trim() || (agentDomain.trim() ? `https://${agentDomain.trim()}` : '')
if (!ep) {
toast.error('Укажите endpoint или agent domain')
return
}
const body: BgpSpeakerCreate = {
endpoint: ep,
role: role.trim() || 'replica',
meta_json: buildMetaJson(agentDomain, nodeIpv4, bgpSourceIpv4),
}
try {
await createMutation.mutateAsync(body)
onOpenChange(false)
} catch {
// toast in mutation
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title="Новый спикер"
description="BIRD-агент на ноде реплики или control plane"
className="sm:max-w-md"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={createMutation.isPending} onClick={save}>
Создать
</LoadingButton>
</>
}
>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-endpoint">Конечная точка</Label>
<Input
id="speaker-endpoint"
placeholder="https://node.example.com:8443"
value={endpoint}
onChange={(e) => handleEndpointChange(e.target.value)}
/>
</div>
<SelectField
id="speaker-role"
label="Роль"
items={[
{ value: 'replica', label: 'Реплика' },
{ value: 'master', label: 'Мастер (CP)' },
]}
value={role}
onValueChange={(v) => setRole(v ?? 'replica')}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-agent-domain">Домен агента</Label>
<Input
id="speaker-agent-domain"
placeholder="bird-agent.example.com"
value={agentDomain}
onChange={(e) => setAgentDomain(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-node-ipv4">IPv4 ноды</Label>
<Input
id="speaker-node-ipv4"
placeholder="203.0.113.10"
value={nodeIpv4}
onChange={(e) => handleNodeIpv4Change(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="speaker-bgp-source">BGP source IPv4</Label>
<Input
id="speaker-bgp-source"
placeholder="203.0.113.10"
value={bgpSourceIpv4}
onChange={(e) => {
setBgpSourceManual(true)
setBgpSourceIpv4(e.target.value)
}}
/>
</div>
</FormDrawer>
)
}
@@ -0,0 +1,139 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMutation } from '@tanstack/react-query'
import { useMemo } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { apiMutate } from '@/lib/api-client'
import { jobKindRu } from '@/lib/ui-labels'
import type { JobRow } from '@/types/api'
import type { QueryClient } from '@tanstack/react-query'
export function OperationsJobsGrid({
items,
nameById,
qc,
isLoading = false,
}: {
items: JobRow[]
nameById: Map<string, string>
qc: QueryClient
isLoading?: boolean
}) {
const cancelMutation = useMutation({
mutationFn: (jobId: string) => apiMutate(`/v1/jobs/${jobId}/cancel`, 'POST', {}),
onSuccess: () => {
toast.success('Задача отменена')
void qc.invalidateQueries({ queryKey: ['operations'] })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отменить'),
})
const columns = useMemo<ColumnDef<JobRow>[]>(
() => [
{
accessorKey: 'kind',
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={jobKindRu(row.original.kind)}
accent="mono"
subtitle={
row.original.meta?.module_id
? (nameById.get(String(row.original.meta.module_id)) ??
String(row.original.meta.module_id))
: undefined
}
/>
),
meta: { headerTitle: 'Вид' },
},
{
accessorKey: 'status',
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
cell: ({ row }) => {
const finished = row.original.finished_at
const hint = finished
? new Date(finished).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
: undefined
return <StatusBadge status={row.original.status} hint={hint} />
},
meta: { headerTitle: 'Статус' },
},
{
id: 'created_at',
accessorFn: (row) => row.created_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
cell: ({ row }) => (
<DataGridMutedCell>
{row.original.created_at
? new Date(row.original.created_at).toLocaleString('ru-RU')
: '—'}
</DataGridMutedCell>
),
meta: { headerTitle: 'Создана' },
},
{
id: 'finished_at',
accessorFn: (row) => row.finished_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Завершена" />,
cell: ({ row }) => (
<DataGridMutedCell>
{row.original.finished_at
? new Date(row.original.finished_at).toLocaleString('ru-RU')
: '—'}
</DataGridMutedCell>
),
meta: { headerTitle: 'Завершена' },
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }) =>
row.original.status === 'running' || row.original.status === 'queued' ? (
<Button
variant="ghost"
size="icon-sm"
type="button"
className="text-destructive"
onClick={() => cancelMutation.mutate(row.original.job_id)}
>
</Button>
) : null,
},
],
[cancelMutation, nameById],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) => {
const moduleName = row.meta?.module_id
? (nameById.get(String(row.meta.module_id)) ?? String(row.meta.module_id))
: ''
return `${jobKindRu(row.kind)} ${row.status} ${row.job_id} ${moduleName}`
},
getRowId: (row) => row.job_id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет задач"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск задач…"
/>
)
}
@@ -0,0 +1,109 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMutation } from '@tanstack/react-query'
import { RefreshCw } from 'lucide-react'
import { useMemo } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { apiMutate } from '@/lib/api-client'
import type { RevisionRow } from '@/types/api'
import type { QueryClient } from '@tanstack/react-query'
export function OperationsRevisionsGrid({
items,
qc,
isLoading = false,
}: {
items: RevisionRow[]
qc: QueryClient
isLoading?: boolean
}) {
const rollbackMutation = useMutation({
mutationFn: (id: string) =>
apiMutate(`/v1/revisions/${id}/rollback`, 'POST', {}).then(() => id),
onSuccess: () => {
toast.success('Откат выполнен')
void qc.invalidateQueries({ queryKey: ['operations'] })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось откатить'),
})
const columns = useMemo<ColumnDef<RevisionRow>[]>(
() => [
{
id: 'id',
accessorFn: (row) => row.id,
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={`${row.original.id.slice(0, 12)}`} accent="mono" />
),
meta: { headerTitle: 'ID' },
},
{
accessorKey: 'created_at',
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
cell: ({ row }) => (
<DataGridMutedCell>
{new Date(row.original.created_at).toLocaleString('ru-RU')}
</DataGridMutedCell>
),
meta: { headerTitle: 'Создана' },
},
{
accessorKey: 'materialized_prefix_count',
header: ({ column }) => <DataGridColumnHeader column={column} title="Префиксов" />,
cell: ({ row }) => (
<span className="font-mono text-sm tabular-nums">
{row.original.materialized_prefix_count}
</span>
),
meta: { headerTitle: 'Префиксов' },
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }) => (
<ConfirmDialog
trigger={
<Button variant="ghost" size="icon-sm" type="button" className="text-destructive">
<RefreshCw className="size-3.5" />
</Button>
}
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
description="Будет создана новая ревизия на основе выбранной. Требуется роль operator."
confirmLabel="Откатить"
destructive
onConfirm={() => rollbackMutation.mutate(row.original.id)}
/>
),
},
],
[rollbackMutation],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) => `${row.id} ${row.materialized_prefix_count}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет ревизий"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск ревизий…"
/>
)
}
+64
View File
@@ -0,0 +1,64 @@
import type { ReactNode } from 'react'
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@evobgp/ui/components/card'
import { cn } from '@evobgp/ui/lib/utils'
/** REUI c-data-grid-19: flush card shell with compact header spacing. */
export const panelCardClassName = 'gap-0 py-0'
export const panelCardHeaderClassName = 'border-b [.border-b]:pb-3'
export const panelCardContentFlushClassName = 'px-0'
export const panelCardFooterClassName = 'border-t bg-transparent py-0'
interface PanelCardProps {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
footer?: ReactNode
children?: ReactNode
className?: string
headerClassName?: string
contentClassName?: string
footerClassName?: string
size?: 'default' | 'sm'
}
export function PanelCard({
title,
description,
actions,
footer,
children,
className,
headerClassName,
contentClassName,
footerClassName,
size = 'sm',
}: PanelCardProps) {
const hasHeader = Boolean(title || description || actions)
return (
<Card size={size} className={cn(panelCardClassName, className)}>
{hasHeader ? (
<CardHeader className={cn(panelCardHeaderClassName, headerClassName)}>
{title ? <CardTitle>{title}</CardTitle> : null}
{description ? <CardDescription>{description}</CardDescription> : null}
{actions ? <CardAction>{actions}</CardAction> : null}
</CardHeader>
) : null}
{children != null && children !== false ? (
<CardContent className={contentClassName}>{children}</CardContent>
) : null}
{footer ? (
<CardFooter className={cn(panelCardFooterClassName, footerClassName)}>{footer}</CardFooter>
) : null}
</Card>
)
}
+13 -9
View File
@@ -5,7 +5,11 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@evobgp/ui/lib/utils" import { cn } from "@evobgp/ui/lib/utils"
const badgeVariants = cva( const badgeVariants = cva(
"relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3", [
"relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3",
],
{ {
variants: { variants: {
variant: { variant: {
@@ -19,19 +23,19 @@ const badgeVariants = cva(
focus: "bg-focus text-focus-foreground", focus: "bg-focus text-focus-foreground",
invert: "bg-invert text-invert-foreground", invert: "bg-invert text-invert-foreground",
"primary-light": "primary-light":
"bg-primary/10 border-none text-primary dark:bg-primary/20", "border-primary/10 bg-primary/10 text-primary dark:border-primary/25 dark:bg-primary/15 dark:text-primary",
"warning-light": "warning-light":
"bg-warning/10 border-none text-warning-foreground dark:bg-warning/20", "border-warning/15 bg-warning/10 text-warning-foreground dark:border-warning/25 dark:bg-warning/15 dark:text-warning",
"success-light": "success-light":
"bg-success/10 border-none text-success-foreground dark:bg-success/20", "border-success/15 bg-success/10 text-success-foreground dark:border-success/25 dark:bg-success/15 dark:text-success",
"info-light": "info-light":
"bg-info/10 border-none text-info-foreground dark:bg-info/20", "border-info/15 bg-info/10 text-info-foreground dark:border-info/25 dark:bg-info/15 dark:text-info",
"destructive-light": "destructive-light":
"bg-destructive/10 border-none text-destructive-foreground dark:bg-destructive/20", "border-destructive/15 bg-destructive/10 text-destructive-foreground dark:border-destructive/25 dark:bg-destructive/15 dark:text-destructive",
"invert-light": "invert-light":
"bg-invert/10 border-none text-foreground dark:bg-invert/20", "border-invert/15 bg-invert/10 text-foreground dark:border-invert/45 dark:bg-invert/35 dark:text-invert-foreground",
"focus-light": "focus-light":
"bg-focus/10 border-none text-focus-foreground dark:bg-focus/20", "border-focus/15 bg-focus/10 text-focus-foreground dark:border-focus/25 dark:bg-focus/15 dark:text-focus",
"primary-outline": "primary-outline":
"bg-background border-border text-primary dark:bg-input/30", "bg-background border-border text-primary dark:bg-input/30",
"warning-outline": "warning-outline":
@@ -54,7 +58,7 @@ const badgeVariants = cva(
lg: "px-1.5 py-0.5 text-xs h-5.5 min-w-5.5 gap-1", lg: "px-1.5 py-0.5 text-xs h-5.5 min-w-5.5 gap-1",
xl: "px-2 py-0.75 text-sm h-6 min-w-6 gap-1.5", xl: "px-2 py-0.75 text-sm h-6 min-w-6 gap-1.5",
}, },
/** `default`: per-theme radius. `full`: max radius per theme (Lyra stays `rounded-none`). */ /** `default`: active style radius. `full`: pill radius. */
radius: { radius: {
default: default:
"rounded-sm", "rounded-sm",
@@ -4,12 +4,8 @@ import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { cn } from "@evobgp/ui/lib/utils" import { cn } from "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button" import { Button } from "@evobgp/ui/components/button"
import { import {
Select, SelectMenu,
SelectContent, } from "@/components/select-field"
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import { Skeleton } from "@evobgp/ui/components/skeleton" import { Skeleton } from "@evobgp/ui/components/skeleton"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react" import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
@@ -144,36 +140,31 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
mergedProps?.className mergedProps?.className
)} )}
> >
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0"> <div className="order-2 flex flex-wrap items-center gap-2 pb-2.5 sm:order-1 sm:pb-0">
{isLoading ? ( {isLoading ? (
mergedProps?.sizesSkeleton mergedProps?.sizesSkeleton
) : ( ) : (
<> <>
<div className="text-muted-foreground text-sm"> <div className="shrink-0 text-sm text-muted-foreground whitespace-nowrap">
{mergedProps.rowsPerPageLabel} {mergedProps.rowsPerPageLabel}
</div> </div>
<Select <SelectMenu
items={mergedProps?.sizes?.map((size: number) => ({ items={
value: `${size}`, mergedProps?.sizes?.map((size: number) => ({
label: `${size}`, value: `${size}`,
}))} label: `${size}`,
})) ?? []
}
value={`${pageSize}`} value={`${pageSize}`}
triggerClassName="min-w-20 w-auto tabular-nums"
size="sm"
side="top"
contentClassName="min-w-20"
onValueChange={(value) => { onValueChange={(value) => {
const newPageSize = Number(value) if (!value) return
table.setPageSize(newPageSize) table.setPageSize(Number(value))
}} }}
> />
<SelectTrigger className="w-14" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent side="top" className="min-w-18">
{mergedProps?.sizes?.map((size: number) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</> </>
)} )}
</div> </div>
@@ -21,6 +21,7 @@ import {
DataGridTableViewport, DataGridTableViewport,
getDataGridTableRowSections, getDataGridTableRowSections,
} from "@/components/reui/data-grid/data-grid-table" } from "@/components/reui/data-grid/data-grid-table"
import { DATA_GRID_MESSAGES_RU } from "@/lib/data-grid-defaults"
import { flexRender, HeaderGroup, Row, Table } from "@tanstack/react-table" import { flexRender, HeaderGroup, Row, Table } from "@tanstack/react-table"
import { import {
useVirtualizer, useVirtualizer,
@@ -304,9 +305,9 @@ function DataGridTableVirtual<TData>({
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
const loadingMoreMessage = const loadingMoreMessage =
props.fetchingMoreMessage || props.loadingMessage || "Loading..." props.fetchingMoreMessage || props.loadingMessage || DATA_GRID_MESSAGES_RU.loadingMessage
const allRowsLoadedMessage = const allRowsLoadedMessage =
props.allRowsLoadedMessage || "All records loaded" props.allRowsLoadedMessage || DATA_GRID_MESSAGES_RU.allRecordsLoadedMessage
const handleViewportRef = useCallback((node: HTMLDivElement | null) => { const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
setViewportElements({ setViewportElements({
@@ -28,6 +28,7 @@ import { cva } from "class-variance-authority"
import { cn } from "@evobgp/ui/lib/utils" import { cn } from "@evobgp/ui/lib/utils"
import { Checkbox } from "@evobgp/ui/components/checkbox" import { Checkbox } from "@evobgp/ui/components/checkbox"
import { Spinner } from "@evobgp/ui/components/spinner" import { Spinner } from "@evobgp/ui/components/spinner"
import { DATA_GRID_MESSAGES_RU } from "@/lib/data-grid-defaults"
const headerCellSpacingVariants = cva("", { const headerCellSpacingVariants = cva("", {
variants: { variants: {
@@ -1098,7 +1099,7 @@ function DataGridTableEmpty() {
colSpan={Math.max(visibleColumnCount, 1)} colSpan={Math.max(visibleColumnCount, 1)}
className="text-muted-foreground text-sm py-6 text-center" className="text-muted-foreground text-sm py-6 text-center"
> >
{props.emptyMessage || "No data available"} {props.emptyMessage || DATA_GRID_MESSAGES_RU.emptyMessage}
</td> </td>
</tr> </tr>
) )
@@ -1111,7 +1112,7 @@ function DataGridTableLoader() {
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"> <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="text-muted-foreground bg-card rounded-lg text-sm flex items-center gap-2 border px-4 py-2 leading-none font-medium"> <div className="text-muted-foreground bg-card rounded-lg text-sm flex items-center gap-2 border px-4 py-2 leading-none font-medium">
<Spinner className="size-5 opacity-60" /> <Spinner className="size-5 opacity-60" />
{props.loadingMessage || "Loading..."} {props.loadingMessage || DATA_GRID_MESSAGES_RU.loadingMessage}
</div> </div>
</div> </div>
) )
@@ -1123,7 +1124,7 @@ function DataGridTableRowPin<TData>({ row }: { row: Row<TData> }) {
return ( return (
<button <button
type="button" type="button"
aria-label={isPinned ? "Unpin row" : "Pin row"} aria-label={isPinned ? DATA_GRID_MESSAGES_RU.unpinRowLabel : DATA_GRID_MESSAGES_RU.pinRowLabel}
onClick={() => { onClick={() => {
if (isPinned) { if (isPinned) {
row.pin(false) row.pin(false)
@@ -1179,7 +1180,7 @@ function DataGridTableRowSelect<TData>({ row }: { row: Row<TData> }) {
<Checkbox <Checkbox
checked={row.getIsSelected()} checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)} onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row" aria-label={DATA_GRID_MESSAGES_RU.selectRowLabel}
className="align-[inherit]" className="align-[inherit]"
/> />
</> </>
@@ -1198,7 +1199,7 @@ function DataGridTableRowSelectAll() {
indeterminate={isSomeSelected && !isAllSelected} indeterminate={isSomeSelected && !isAllSelected}
disabled={isLoading || recordCount === 0} disabled={isLoading || recordCount === 0}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all" aria-label={DATA_GRID_MESSAGES_RU.selectAllLabel}
className="align-[inherit]" className="align-[inherit]"
/> />
) )
@@ -1249,7 +1250,7 @@ function DataGridTableBodyRows<TData>({ table }: { table: Table<TData> }) {
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path> ></path>
</svg> </svg>
{props.loadingMessage || "Loading..."} {props.loadingMessage || DATA_GRID_MESSAGES_RU.loadingMessage}
</div> </div>
</td> </td>
</tr> </tr>
+175
View File
@@ -0,0 +1,175 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@evobgp/ui/lib/utils"
/**
* CSS variable architecture for FramePanel theming:
*
* The Frame parent sets --frame-panel-bg and --frame-panel-border-color.
* FramePanel consumes them directly via bg-(--frame-panel-bg) and
* border-(--frame-panel-border-color). This means:
*
* - variant="inverse" overrides those vars on Frame → all panels pick it up
* - <FramePanel className="bg-blue-50"> adds a direct utility on the element
* which wins over bg-(--frame-panel-bg) by Tailwind source order — no
* :not() or !important needed
*/
const frameVariants = cva(
[
"relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)",
"(--radius-xl)] [--frame-radius:var(--radius-xl)]",
"(--radius-none)] (--radius-2xl)] (--radius-lg)] (--radius-none)]",
"[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]",
"[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]",
"[--frame-panel-px:calc(var(--frame-panel-px-base)_+_var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)_+_var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)_+_var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)_+_var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)_+_var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)_+_var(--frame-panel-footer-py-adjust))]",
"(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]",
// Default panel token values — overridden per-variant below
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
],
{
variants: {
variant: {
default: "border border-[var(--frame-border-color)] bg-clip-padding",
inverse:
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
ghost: "",
},
spacing: {
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(1)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(1)] (3)] (1)] (3)] (3)]",
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(2.5)] (2)] (2)] (2)]",
default:
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(3)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(3)] (2)] (2)] (2)]",
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(4)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(4)] (2)] (2)] (2)]",
},
stacked: {
true: [
"gap-0 *:has-[+[data-slot=frame-panel]]:rounded-b-none",
"*:has-[+[data-slot=frame-panel]]:before:hidden",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:rounded-t-none",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:border-t-0",
],
false: [
"data-[spacing=sm]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-0.5",
"data-[spacing=default]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-1",
"data-[spacing=lg]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-2",
],
},
dense: {
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars
true: "p-0 gap-0 border-[var(--frame-border-color)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
false: "",
},
},
defaultVariants: {
variant: "default",
spacing: "default",
stacked: false,
dense: false,
},
}
)
function Frame({
className,
variant,
spacing,
stacked,
dense,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof frameVariants>) {
return (
<div
className={cn(
frameVariants({ variant, spacing, stacked, dense }),
className
)}
data-slot="frame"
data-spacing={spacing}
{...props}
/>
)
}
function FramePanel({
className,
fit,
...props
}: React.ComponentProps<"div"> & { fit?: boolean }) {
return (
<div
className={cn(
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
// via className overrides these by Tailwind source order - no ! needed.
"relative overflow-hidden rounded-(--frame-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
// `fit` sizes the panel to its content; otherwise it grows to fill the frame.
!fit && "grow",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-radius)-1px)] before:shadow-black/5",
"dark:bg-clip-border dark:before:shadow-white/5",
"px-(--frame-panel-px) py-(--frame-panel-py)",
className
)}
data-slot="frame-panel"
{...props}
/>
)
}
function FrameHeader({ className, ...props }: React.ComponentProps<"header">) {
return (
<header
className={cn(
"flex flex-col gap-(--frame-panel-header-gap) px-(--frame-panel-header-px) py-(--frame-panel-header-py)",
className
)}
data-slot="frame-panel-header"
{...props}
/>
)
}
function FrameTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("text-sm font-semibold", className)}
data-slot="frame-panel-title"
{...props}
/>
)
}
function FrameDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn("text-muted-foreground text-sm", className)}
data-slot="frame-panel-description"
{...props}
/>
)
}
function FrameFooter({ className, ...props }: React.ComponentProps<"footer">) {
return (
<footer
className={cn(
"flex flex-col gap-(--frame-panel-footer-gap) px-(--frame-panel-footer-px) py-(--frame-panel-footer-py)",
className
)}
data-slot="frame-panel-footer"
{...props}
/>
)
}
export {
Frame,
FramePanel,
FrameHeader,
FrameTitle,
FrameDescription,
FrameFooter,
frameVariants,
}
@@ -0,0 +1,98 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { jobKindRu } from '@/lib/ui-labels'
import type { JobRow } from '@/types/api'
export function ScheduleJobsGrid({
items,
isLoading = false,
}: {
items: JobRow[]
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<JobRow>[]>(
() => [
{
accessorKey: 'kind',
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
cell: ({ row }) => <DataGridPrimaryCell title={jobKindRu(row.original.kind)} accent="mono" />,
meta: { headerTitle: 'Вид' },
},
{
accessorKey: 'status',
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
cell: ({ row }) => {
const finished = row.original.finished_at
const hint = finished
? new Date(finished).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
: undefined
return <StatusBadge status={row.original.status} hint={hint} />
},
meta: { headerTitle: 'Статус' },
},
{
id: 'created_at',
accessorFn: (row) => row.created_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
cell: ({ row }) => (
<DataGridMutedCell>
{row.original.created_at
? new Date(row.original.created_at).toLocaleString('ru-RU')
: '—'}
</DataGridMutedCell>
),
meta: { headerTitle: 'Создана' },
},
{
id: 'finished_at',
accessorFn: (row) => row.finished_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Завершена" />,
cell: ({ row }) => (
<DataGridMutedCell>
{row.original.finished_at
? new Date(row.original.finished_at).toLocaleString('ru-RU')
: '—'}
</DataGridMutedCell>
),
meta: { headerTitle: 'Завершена' },
},
{
accessorKey: 'error',
enableSorting: false,
header: 'Ошибка',
cell: ({ row }) => (
<span className="max-w-xs truncate text-xs text-destructive">
{row.original.error ?? ''}
</span>
),
meta: { headerTitle: 'Ошибка' },
},
],
[],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) => `${jobKindRu(row.kind)} ${row.status} ${row.error ?? ''}`,
getRowId: (row) => row.job_id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет задач"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск задач…"
/>
)
}
@@ -0,0 +1,114 @@
import { ColumnDef } from '@tanstack/react-table'
import { RefreshCw } from 'lucide-react'
import { useMemo } from 'react'
import { CategoryBadge, ModeBadge } from '@/components/category-badge'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { LoadingButton } from '@/components/loading-button'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { moduleTypeRu } from '@/lib/ui-labels'
import type { ModuleRow } from '@/types/api'
export function ScheduleModulesGrid({
items,
refreshing,
onRefresh,
isLoading = false,
}: {
items: ModuleRow[]
refreshing: Record<string, boolean>
onRefresh: (id: string) => void
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<ModuleRow>[]>(
() => [
{
accessorKey: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
cell: ({ row }) => <DataGridPrimaryCell title={row.original.name} accent="primary" />,
meta: { headerTitle: 'Модуль' },
},
{
accessorKey: 'type',
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
cell: ({ row }) => <CategoryBadge>{moduleTypeRu(row.original.type)}</CategoryBadge>,
meta: { headerTitle: 'Тип' },
},
{
id: 'schedule',
accessorFn: (row) => row.cron_expr ?? String(row.refresh_interval_sec ?? ''),
header: ({ column }) => <DataGridColumnHeader column={column} title="Расписание" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">
{row.original.cron_expr ??
(row.original.refresh_interval_sec ? `${row.original.refresh_interval_sec}s` : '—')}
</span>
),
meta: { headerTitle: 'Расписание' },
},
{
id: 'last_refreshed_at',
accessorFn: (row) => row.last_refreshed_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
cell: ({ row }) => (
<DataGridMutedCell>
{row.original.last_refreshed_at
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
: '—'}
</DataGridMutedCell>
),
meta: { headerTitle: 'Обновлено' },
},
{
id: 'enabled',
accessorFn: (row) => (row.enabled ? 'on' : 'off'),
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
cell: ({ row }) => (
<ModeBadge enabled={row.original.enabled} onLabel="Вкл" offLabel="Выкл" />
),
meta: { headerTitle: 'Статус' },
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }) => (
<div className="text-right">
<LoadingButton
size="sm"
variant="secondary"
type="button"
loading={!!refreshing[row.original.id]}
onClick={() => onRefresh(row.original.id)}
>
<RefreshCw />
Обновить
</LoadingButton>
</div>
),
},
],
[onRefresh, refreshing],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) => `${row.name} ${row.type} ${row.enabled ? 'вкл' : 'выкл'}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет модулей"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск модулей…"
/>
)
}
+3 -2
View File
@@ -42,7 +42,7 @@ export function SectionCards({ items, className }: { items: SectionCardItem[]; c
{items.map((item, idx) => { {items.map((item, idx) => {
const clickable = Boolean(item.onClick) const clickable = Boolean(item.onClick)
const content = ( const content = (
<CardContent className="flex items-start gap-2.5 px-3 py-2.5"> <CardContent className="flex items-start gap-2.5 px-3 py-2">
{item.icon ? ( {item.icon ? (
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground"> <span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground">
{item.icon} {item.icon}
@@ -80,8 +80,9 @@ export function SectionCards({ items, className }: { items: SectionCardItem[]; c
return ( return (
<Card <Card
key={typeof item.label === 'string' ? item.label : idx} key={typeof item.label === 'string' ? item.label : idx}
size="sm"
className={cn( className={cn(
'gap-0', 'gap-0 py-0',
VARIANT_CLASS[item.variant ?? 'default'], VARIANT_CLASS[item.variant ?? 'default'],
item.active && 'border-primary ring-1 ring-primary/30', item.active && 'border-primary ring-1 ring-primary/30',
clickable && 'cursor-pointer transition-colors hover:bg-muted/40', clickable && 'cursor-pointer transition-colors hover:bg-muted/40',
+100
View File
@@ -0,0 +1,100 @@
import type { ComponentProps, ReactNode } from 'react'
import { Field, FieldDescription, FieldLabel } from '@evobgp/ui/components/field'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { cn } from '@evobgp/ui/lib/utils'
export type SelectMenuItem<T extends string = string> = {
value: T
label: ReactNode
disabled?: boolean
}
type SelectRootProps = ComponentProps<typeof Select>
export interface SelectMenuProps<T extends string = string>
extends Omit<SelectRootProps, 'children' | 'onValueChange' | 'value' | 'defaultValue'> {
items: ReadonlyArray<SelectMenuItem<T>>
value?: T | null
defaultValue?: T | null
onValueChange?: (value: T | null) => void
placeholder?: string
id?: string
triggerClassName?: string
contentClassName?: string
size?: 'sm' | 'default'
side?: ComponentProps<typeof SelectContent>['side']
}
/** ReUI c-select-4: `items` на Root, опции в `SelectGroup`. */
export function SelectMenu<T extends string = string>({
items,
placeholder,
id,
triggerClassName,
contentClassName,
size = 'default',
side,
value,
defaultValue,
onValueChange,
...selectProps
}: SelectMenuProps<T>) {
return (
<Select
items={items}
value={value}
defaultValue={defaultValue}
onValueChange={(next) => onValueChange?.(next as T | null)}
{...selectProps}
>
<SelectTrigger id={id} className={cn('w-full', triggerClassName)} size={size}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent side={side} className={contentClassName}>
<SelectGroup>
{items.map((item) => (
<SelectItem key={item.value} value={item.value} disabled={item.disabled}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}
export interface SelectFieldProps<T extends string = string> extends SelectMenuProps<T> {
label?: ReactNode
description?: ReactNode
fieldClassName?: string
}
export function SelectField<T extends string = string>({
label,
description,
fieldClassName,
id,
...menuProps
}: SelectFieldProps<T>) {
return (
<Field className={fieldClassName}>
{label ? <FieldLabel htmlFor={id}>{label}</FieldLabel> : null}
<SelectMenu id={id} {...menuProps} />
{description ? (
typeof description === 'string' ? (
<FieldDescription className="font-mono text-xs">{description}</FieldDescription>
) : (
description
)
) : null}
</Field>
)
}
@@ -0,0 +1,60 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
export interface SettingsKvRow {
id: string | number
key: string
value: string
}
export function SettingsKvGrid({
items,
isLoading = false,
}: {
items: SettingsKvRow[]
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<SettingsKvRow>[]>(
() => [
{
accessorKey: 'key',
header: ({ column }) => <DataGridColumnHeader column={column} title="Ключ" />,
cell: ({ row }) => <DataGridPrimaryCell title={row.original.key} accent="mono" />,
meta: { headerTitle: 'Ключ' },
},
{
accessorKey: 'value',
header: ({ column }) => <DataGridColumnHeader column={column} title="Значение" />,
cell: ({ row }) => <DataGridPrimaryCell title={row.original.value} accent="mono" />,
meta: { headerTitle: 'Значение' },
},
],
[],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) => `${row.key} ${row.value}`,
getRowId: (row) => String(row.id),
pageSize: 25,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет дополнительных настроек"
showPagination={items.length > 10}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск настроек…"
/>
)
}
+38 -5
View File
@@ -1,6 +1,7 @@
import { SectionCards } from './section-cards'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { Card, CardContent } from '@evobgp/ui/components/card' import { Card, CardContent } from '@evobgp/ui/components/card'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { SectionCards } from './section-cards'
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) { export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
return ( return (
@@ -14,18 +15,50 @@ export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
) )
} }
export function AnalyticsDashboardSkeleton() {
return (
<div className="grid gap-4 lg:grid-cols-3 lg:items-start">
<Card size="sm" className="gap-0 py-0">
<CardContent className="space-y-4 py-4">
<Skeleton className="h-4 w-40" />
<div className="grid gap-4 sm:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
<Skeleton className="h-2 w-full" />
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
<Card size="sm" className="gap-0 py-0">
<CardContent className="space-y-4 py-4">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-10 w-24" />
<Skeleton className="h-36 w-full" />
</CardContent>
</Card>
<Card size="sm" className="gap-0 py-0">
<CardContent className="space-y-4 py-4">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-44 w-full" />
</CardContent>
</Card>
</div>
)
}
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) { export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
return ( return (
<Card className="gap-0"> <Card size="sm" className="gap-0 py-0">
<CardContent className="p-0"> <CardContent className="p-0">
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex gap-2 border-b p-3"> <div className="flex gap-2 border-b px-3 py-2">
{Array.from({ length: cols }).map((_, i) => ( {Array.from({ length: cols }).map((_, i) => (
<Skeleton className="h-4 flex-1" key={`h-${i}`} /> <Skeleton className="h-4 flex-1" key={`h-${i}`} />
))} ))}
</div> </div>
{Array.from({ length: rows }).map((_, r) => ( {Array.from({ length: rows }).map((_, r) => (
<div className="flex gap-2 border-b p-3" key={`r-${r}`}> <div className="flex gap-2 border-b px-3 py-2" key={`r-${r}`}>
{Array.from({ length: cols }).map((_, c) => ( {Array.from({ length: cols }).map((_, c) => (
<Skeleton className="h-4 flex-1" key={`c-${r}-${c}`} /> <Skeleton className="h-4 flex-1" key={`c-${r}-${c}`} />
))} ))}
+61 -19
View File
@@ -1,30 +1,72 @@
import type { ComponentProps } from 'react' import type { ComponentProps } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { jobStatusRu } from '@/lib/ui-labels'
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']> type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
const STATUS_VARIANT: Record<string, BadgeVariant> = { const STATUS_VARIANT: Record<string, BadgeVariant> = {
active: 'success', active: 'success-light',
ok: 'success', ok: 'success-light',
paid: 'success', paid: 'success-light',
established: 'success', established: 'success-light',
succeeded: 'success', succeeded: 'success-light',
healthy: 'success', healthy: 'success-light',
paused: 'secondary', approved: 'success-light',
disabled: 'secondary', accept: 'success-light',
paused: 'invert-light',
disabled: 'invert-light',
archived: 'outline', archived: 'outline',
error: 'destructive', error: 'destructive-light',
failed: 'destructive', failed: 'destructive-light',
running: 'info', revoked: 'destructive-light',
queued: 'info', block: 'destructive-light',
overdue: 'warning', cancelled: 'destructive-light',
stale: 'warning', running: 'info-light',
warning: 'warning', queued: 'info-light',
mismatch: 'warning', overdue: 'warning-light',
stale: 'warning-light',
warning: 'warning-light',
mismatch: 'warning-light',
pending: 'warning-light',
} }
export function StatusBadge({ status, label }: { status: string; label?: string }) { const DOT_COLOR: Record<string, string> = {
const variant = STATUS_VARIANT[status.toLowerCase()] ?? 'outline' 'success-light': 'bg-success',
return <Badge variant={variant}>{label ?? status}</Badge> 'info-light': 'bg-info',
'warning-light': 'bg-warning',
'destructive-light': 'bg-destructive',
'invert-light': 'bg-muted-foreground',
outline: 'bg-muted-foreground',
}
export function jobStatusVariant(status: string): BadgeVariant {
return STATUS_VARIANT[status.toLowerCase()] ?? 'outline'
}
export function StatusBadge({
status,
label,
hint,
className,
}: {
status: string
label?: string
hint?: string
className?: string
}) {
const variant = jobStatusVariant(status)
const dotColor = DOT_COLOR[variant] ?? 'bg-muted-foreground'
return (
<div className={cn('flex flex-col gap-0.5', className)}>
<Badge variant={variant} size="sm" radius="full" className="gap-1.5">
<span className={cn('size-1.5 shrink-0 rounded-full', dotColor)} aria-hidden />
{label ?? jobStatusRu(status)}
</Badge>
{hint ? <span className="text-xs text-muted-foreground">{hint}</span> : null}
</div>
)
} }
@@ -0,0 +1,61 @@
import { useMemo, useState } from 'react'
import {
type ColumnDef,
type FilterFn,
type TableOptions,
useReactTable,
} from '@tanstack/react-table'
import {
createClientDataGridOptions,
createTextGlobalFilter,
} from '@/lib/data-grid-defaults'
interface UseClientDataGridOptions<TData extends object> {
data: TData[]
columns: ColumnDef<TData>[]
getSearchText: (row: TData) => string
getRowId: (row: TData) => string
pageSize?: number
tableOptions?: Partial<TableOptions<TData>>
globalFilterFn?: FilterFn<TData>
}
export function useClientDataGrid<TData extends object>({
data,
columns,
getSearchText,
getRowId,
pageSize = 10,
tableOptions,
globalFilterFn,
}: UseClientDataGridOptions<TData>) {
const [globalFilter, setGlobalFilter] = useState('')
const filterFn = useMemo(
() => globalFilterFn ?? createTextGlobalFilter(getSearchText),
[getSearchText, globalFilterFn],
)
const table = useReactTable({
data,
columns,
state: { globalFilter },
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: filterFn,
...createClientDataGridOptions<TData>({
initialState: { pagination: { pageSize } },
...tableOptions,
}),
getRowId,
})
const filteredCount = table.getFilteredRowModel().rows.length
return {
table,
globalFilter,
setGlobalFilter,
filteredCount,
}
}
+6 -4
View File
@@ -1,10 +1,12 @@
import type { ApiKeyRole } from '@/types/api' import type { ApiKeyRole } from '@/types/api'
import { apiKeyRoleRu } from '@/lib/ui-labels'
export const API_KEY_ROLE_ITEMS: ReadonlyArray<{ value: ApiKeyRole; label: string }> = [ export const API_KEY_ROLE_ITEMS: ReadonlyArray<{ value: ApiKeyRole; label: string }> = [
{ value: 'viewer', label: 'viewer — только чтение' }, { value: 'viewer', label: `${apiKeyRoleRu('viewer')} — только чтение` },
{ value: 'editor', label: 'editor — CRUD без apply' }, { value: 'editor', label: `${apiKeyRoleRu('editor')} — CRUD без применения` },
{ value: 'operator', label: 'operator — полный доступ' }, { value: 'operator', label: `${apiKeyRoleRu('operator')} — полный доступ` },
{ value: 'node', label: 'node — только API ноды' }, { value: 'node', label: `${apiKeyRoleRu('node')} — только API ноды` },
] ]
export function apiKeyRoleLabel(role: ApiKeyRole): string { export function apiKeyRoleLabel(role: ApiKeyRole): string {
+74
View File
@@ -0,0 +1,74 @@
import {
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type FilterFn,
type TableOptions,
} from '@tanstack/react-table'
import type { DataGridProps } from '@/components/reui/data-grid/data-grid'
export const DATA_GRID_TABLE_LAYOUT: NonNullable<DataGridProps<object>['tableLayout']> = {
dense: true,
headerSticky: true,
rowBorder: true,
}
export const DATA_GRID_PAGINATION_RU = {
sizes: [10, 25, 50] as number[],
sizesLabel: 'Показать',
sizesDescription: 'на странице',
info: '{from}{to} из {count}',
rowsPerPageLabel: 'Строк на странице',
previousPageLabel: 'Предыдущая страница',
nextPageLabel: 'Следующая страница',
}
export const DATA_GRID_MESSAGES_RU = {
emptyMessage: 'Нет данных',
loadingMessage: 'Загрузка…',
fetchingMoreMessage: 'Загрузка…',
selectAllLabel: 'Выбрать все',
selectRowLabel: 'Выбрать строку',
pinRowLabel: 'Закрепить строку',
unpinRowLabel: 'Открепить строку',
allRecordsLoadedMessage: 'Все записи загружены',
}
export const DATA_GRID_DENSE_LAYOUT: NonNullable<DataGridProps<object>['tableLayout']> = {
...DATA_GRID_TABLE_LAYOUT,
dense: true,
}
export function createTextGlobalFilter<TData extends object>(
getSearchText: (row: TData) => string,
): FilterFn<TData> {
return (row, _columnId, filterValue) => {
const query = String(filterValue ?? '')
.toLowerCase()
.trim()
if (!query) return true
return getSearchText(row.original).toLowerCase().includes(query)
}
}
export function createClientDataGridOptions<TData extends object>(
overrides?: Partial<TableOptions<TData>>,
): Pick<
TableOptions<TData>,
| 'getCoreRowModel'
| 'getFilteredRowModel'
| 'getSortedRowModel'
| 'getPaginationRowModel'
| 'initialState'
> {
return {
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 10 } },
...overrides,
}
}
@@ -0,0 +1,54 @@
import type { SpeakerRow } from '@/types/api'
import type { DeploymentProgress } from './types'
export function deploymentProgress(speakers: SpeakerRow[]): DeploymentProgress {
if (speakers.length === 0) {
return { percent: 0, synced: 0, total: 0, mode: 'online' }
}
const withRevision = speakers.filter(
(s) => s.published_revision_id && s.last_applied_revision_id,
)
if (withRevision.length > 0) {
const synced = withRevision.filter(
(s) => s.published_revision_id === s.last_applied_revision_id,
).length
return {
percent: Math.round((synced / withRevision.length) * 100),
synced,
total: withRevision.length,
mode: 'revision',
}
}
const online = speakers.filter((s) => s.live?.agent_ok).length
return {
percent: Math.round((online / speakers.length) * 100),
synced: online,
total: speakers.length,
mode: 'online',
}
}
export function deploymentProgressMeta(deploy: DeploymentProgress): {
label: string
hint: string
} {
if (deploy.total === 0) {
return {
label: 'Деплой на спикерах',
hint: 'Нет зарегистрированных BIRD-спикеров',
}
}
if (deploy.mode === 'revision') {
return {
label: `Применена ревизия (${deploy.synced} из ${deploy.total} спикеров)`,
hint: 'Доля спикеров, на которых последняя опубликованная ревизия уже применена',
}
}
return {
label: `Спикеры в сети (${deploy.synced} из ${deploy.total})`,
hint: 'Ревизии ещё не публиковались — показана доступность агента на нодах',
}
}
+8
View File
@@ -0,0 +1,8 @@
export * from './types'
export * from './job-status-breakdown'
export * from './module-type-breakdown'
export * from './peer-capacity-bars'
export * from './deployment-progress'
export * from './readiness-breakdown'
export * from './peer-session-breakdown'
export * from './recent-platform-activity'
@@ -0,0 +1,52 @@
import type { JobRow } from '@/types/api'
import type { BreakdownSlice } from './types'
const STATUS_BUCKETS: { keys: string[]; label: string; color: string }[] = [
{ keys: ['succeeded', 'success'], label: 'Успешно', color: 'var(--color-chart-2)' },
{ keys: ['running', 'queued'], label: 'Активные', color: 'var(--color-chart-1)' },
{ keys: ['failed', 'error', 'cancelled'], label: 'Ошибки', color: 'var(--color-destructive)' },
]
function bucketForStatus(status: string): string {
const s = status.toLowerCase()
for (const bucket of STATUS_BUCKETS) {
if (bucket.keys.includes(s)) return bucket.label
}
return 'Прочее'
}
export function jobStatusBreakdown(jobs: JobRow[]): BreakdownSlice[] {
const counts = new Map<string, number>()
for (const job of jobs) {
const label = bucketForStatus(job.status)
counts.set(label, (counts.get(label) ?? 0) + 1)
}
const slices: BreakdownSlice[] = []
for (const bucket of STATUS_BUCKETS) {
const count = counts.get(bucket.label) ?? 0
if (count > 0) {
slices.push({
key: bucket.label,
label: bucket.label,
count,
color: bucket.color,
})
}
}
const other = counts.get('Прочее') ?? 0
if (other > 0) {
slices.push({
key: 'other',
label: 'Прочее',
count: other,
color: 'var(--color-chart-4)',
})
}
return slices
}
export function jobStatusTotal(jobs: JobRow[]): number {
return jobs.length
}
@@ -0,0 +1,26 @@
import type { ModuleRow, ModuleType } from '@/types/api'
import type { BreakdownSlice } from './types'
const TYPE_META: Record<ModuleType, { label: string; color: string }> = {
AS_PREFIXES: { label: 'AS / префиксы', color: 'var(--color-chart-1)' },
DOMAINS: { label: 'Домены', color: 'var(--color-chart-2)' },
CDN_CIDRS: { label: 'CDN', color: 'var(--color-chart-3)' },
IP_RANGES: { label: 'IP-диапазоны', color: 'var(--color-chart-4)' },
}
export function moduleTypeBreakdown(modules: ModuleRow[]): BreakdownSlice[] {
const counts = new Map<ModuleType, number>()
for (const mod of modules) {
counts.set(mod.type, (counts.get(mod.type) ?? 0) + 1)
}
return (Object.keys(TYPE_META) as ModuleType[])
.map((type) => ({
key: type,
label: TYPE_META[type].label,
count: counts.get(type) ?? 0,
color: TYPE_META[type].color,
}))
.filter((slice) => slice.count > 0)
}
@@ -0,0 +1,42 @@
import type { PeerRow, SpeakerRow } from '@/types/api'
import type { CapacityBar } from './types'
function peerLabel(peer: PeerRow): string {
return peer.name?.trim() || peer.neighbor || peer.id.slice(0, 8)
}
function speakerLabel(speaker: SpeakerRow): string {
return speaker.live?.label?.trim() || speaker.agent_domain || speaker.endpoint || speaker.id.slice(0, 8)
}
export function peerCapacityBars(peers: PeerRow[], max = 24): CapacityBar[] {
return peers
.filter((p) => p.enabled !== false)
.slice(0, max)
.map((peer) => ({
id: peer.id,
name: peerLabel(peer),
value: peer.session_state === 'Established' ? 100 : peer.session_state ? 40 : 10,
}))
}
export function speakerCapacityBars(speakers: SpeakerRow[], max = 24): CapacityBar[] {
return speakers.slice(0, max).map((speaker) => {
const online = speaker.live?.agent_ok === true
const established = speaker.live?.bgp_established ?? 0
const total = speaker.live?.bgp_sessions_total ?? 0
const ratio = total > 0 ? Math.round((established / total) * 100) : online ? 100 : 15
return {
id: speaker.id,
name: speakerLabel(speaker),
value: online ? ratio : 10,
}
})
}
export function capacityUtilization(bars: CapacityBar[]): number {
if (bars.length === 0) return 0
const sum = bars.reduce((acc, bar) => acc + bar.value, 0)
return Math.round(sum / bars.length)
}
@@ -0,0 +1,41 @@
import type { PeerRow } from '@/types/api'
import type { BreakdownSlice } from './types'
export function peerSessionBreakdown(peers: PeerRow[]): BreakdownSlice[] {
const enabled = peers.filter((p) => p.enabled !== false)
const established = enabled.filter((p) => p.session_state === 'Established').length
const pending = enabled.filter(
(p) => p.session_state && p.session_state !== 'Established',
).length
const disabled = peers.length - enabled.length
const slices: BreakdownSlice[] = []
if (established > 0) {
slices.push({
key: 'established',
label: 'Установлена',
count: established,
color: 'var(--color-chart-2)',
})
}
if (pending > 0) {
slices.push({
key: 'pending',
label: 'Не установлена',
count: pending,
color: 'var(--color-warning)',
})
}
if (disabled > 0) {
slices.push({
key: 'disabled',
label: 'Выключены',
count: disabled,
color: 'var(--color-chart-4)',
})
}
return slices.length > 0
? slices
: [{ key: 'empty', label: 'Нет пиров', count: 1, color: 'var(--color-muted-foreground)' }]
}
@@ -0,0 +1,71 @@
import type { ReadyStatus } from '@/queries/monitoring'
import type { BreakdownSlice } from './types'
function checkOk(value: boolean | { ok?: boolean; error?: string } | undefined): boolean {
if (typeof value === 'boolean') return value
if (value && typeof value === 'object') return value.ok === true
return false
}
export function readinessBreakdown(
ready: ReadyStatus | null | undefined,
healthOk: boolean,
): BreakdownSlice[] {
if (!healthOk) {
return [
{
key: 'health-fail',
label: 'API недоступен',
count: 1,
color: 'var(--color-destructive)',
},
]
}
const checks = ready?.checks ?? {}
let okCount = 0
let failCount = 0
for (const value of Object.values(checks)) {
if (checkOk(value)) okCount += 1
else failCount += 1
}
const slices: BreakdownSlice[] = [
{
key: 'health',
label: 'API доступен',
count: 1,
color: 'var(--color-chart-2)',
},
]
if (okCount > 0) {
slices.push({
key: 'checks-ok',
label: 'Проверки в норме',
count: okCount,
color: 'var(--color-chart-1)',
})
}
if (failCount > 0) {
slices.push({
key: 'checks-fail',
label: 'Ошибки проверок',
count: failCount,
color: 'var(--color-warning)',
})
}
if (slices.length === 1 && okCount === 0 && failCount === 0) {
slices.push({
key: 'ready',
label: ready?.status === 'ok' ? 'Готов' : 'Ожидает готовности',
count: 1,
color: 'var(--color-chart-4)',
})
}
return slices
}
@@ -0,0 +1,58 @@
import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
import { jobKindRu, jobStatusRu } from '@/lib/ui-labels'
import type { PlatformActivityItem } from './types'
function jobMessage(job: JobRow): string {
return `${jobKindRu(job.kind)} · ${jobStatusRu(job.status)}`
}
export function recentPlatformActivity(
jobs: JobRow[],
revisions: RevisionRow[],
peers: PeerRow[],
speakers: SpeakerRow[],
limit = 5,
): PlatformActivityItem[] {
const items: PlatformActivityItem[] = []
for (const job of jobs.slice(0, 3)) {
items.push({
id: `job-${job.job_id}`,
message: jobMessage(job),
status: job.status,
kind: 'job',
})
}
for (const rev of revisions.slice(0, 2)) {
items.push({
id: `rev-${rev.id}`,
message: `Ревизия ${rev.id.slice(0, 8)}… · ${rev.materialized_prefix_count} префиксов`,
status: 'ok',
statusLabel: 'Создана',
kind: 'revision',
})
}
for (const peer of peers.filter((p) => p.session_mismatch).slice(0, 2)) {
items.push({
id: `peer-${peer.id}`,
message: `Расхождение сессии: ${peer.name ?? peer.neighbor}`,
status: 'mismatch',
kind: 'network',
})
}
for (const speaker of speakers.filter((s) => s.live?.bgp_poll_error || s.live?.agent_ok === false).slice(0, 2)) {
items.push({
id: `speaker-${speaker.id}`,
message: `Нода недоступна: ${speaker.live?.label ?? speaker.endpoint}`,
status: speaker.live?.agent_ok === false ? 'error' : 'warning',
kind: 'network',
})
}
return items.slice(0, limit)
}
+28
View File
@@ -0,0 +1,28 @@
export type BreakdownSlice = {
key: string
label: string
count: number
color: string
}
export type CapacityBar = {
id: string
name: string
/** 0100 utilization */
value: number
}
export type PlatformActivityItem = {
id: string
message: string
status: string
statusLabel?: string
kind: 'job' | 'revision' | 'network'
}
export type DeploymentProgress = {
percent: number
synced: number
total: number
mode: 'revision' | 'online'
}
+94
View File
@@ -27,3 +27,97 @@ export function moduleTypeRu(type: string): string {
return type return type
} }
} }
const JOB_KIND_RU: Record<string, string> = {
module_refresh: 'Обновление модуля',
apply: 'Применение конфигурации',
rollback: 'Откат ревизии',
bird_reload: 'Перезагрузка BIRD',
}
export function jobKindRu(kind: string): string {
return JOB_KIND_RU[kind] ?? kind
}
const JOB_STATUS_RU: Record<string, string> = {
queued: 'В очереди',
running: 'Выполняется',
succeeded: 'Успешно',
failed: 'Ошибка',
error: 'Ошибка',
cancelled: 'Отменена',
canceled: 'Отменена',
pending: 'Ожидает',
approved: 'Одобрен',
revoked: 'Отозван',
active: 'Активен',
ok: 'В норме',
mismatch: 'Расхождение',
established: 'Установлена',
healthy: 'В норме',
warning: 'Предупреждение',
stale: 'Устарело',
overdue: 'Просрочено',
paused: 'Приостановлен',
disabled: 'Выключен',
archived: 'В архиве',
block: 'block',
accept: 'accept',
}
export function jobStatusRu(status: string): string {
return JOB_STATUS_RU[status.toLowerCase()] ?? status
}
export function bgpSessionStateRu(state: string | null | undefined): string {
if (!state) return '—'
if (state === 'Established') return 'Установлена'
return state
}
export function speakerOnlineLabel(agentOk: boolean | undefined): string {
if (agentOk === true) return 'В сети'
if (agentOk === false) return 'Не в сети'
return '—'
}
export function firewallClientStatusRu(status: string): string {
switch (status) {
case 'pending':
return 'Ожидает'
case 'approved':
return 'Одобрен'
case 'revoked':
return 'Отозван'
default:
return status
}
}
export function apiKeyRoleRu(role: string): string {
switch (role) {
case 'viewer':
return 'Наблюдатель'
case 'editor':
return 'Редактор'
case 'operator':
return 'Оператор'
case 'node':
return 'Нода'
default:
return role
}
}
export function readyCheckRu(key: string): string {
switch (key) {
case 'postgres':
return 'PostgreSQL'
case 'store':
return 'Хранилище'
case 'jobs':
return 'Очередь задач'
default:
return key
}
}
+19
View File
@@ -1,4 +1,6 @@
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query' import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { apiJSON } from '@/lib/api-client' import { apiJSON } from '@/lib/api-client'
import type { import type {
FirewallClient, FirewallClient,
@@ -51,8 +53,23 @@ export function useApproveFirewallClient() {
mutationFn: (id: string) => mutationFn: (id: string) =>
apiJSON<FirewallClient>(`/v1/firewall/clients/${id}/approve`, { method: 'POST' }), apiJSON<FirewallClient>(`/v1/firewall/clients/${id}/approve`, { method: 'POST' }),
onSuccess: () => { onSuccess: () => {
toast.success('Клиент одобрен')
void qc.invalidateQueries({ queryKey: firewallKeys.clients() }) void qc.invalidateQueries({ queryKey: firewallKeys.clients() })
}, },
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось одобрить'),
})
}
export function useDeleteFirewallClient() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiJSON<void>(`/v1/firewall/clients/${id}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Клиент удалён')
void qc.invalidateQueries({ queryKey: firewallKeys.clients() })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось удалить'),
}) })
} }
@@ -66,8 +83,10 @@ export function useCreateFirewallRule() {
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
onSuccess: () => { onSuccess: () => {
toast.success('Правило добавлено')
void qc.invalidateQueries({ queryKey: firewallKeys.all }) void qc.invalidateQueries({ queryKey: firewallKeys.all })
}, },
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось добавить правило'),
}) })
} }
+98 -3
View File
@@ -1,6 +1,18 @@
import { queryOptions } from '@tanstack/react-query' import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client' import { toast } from 'sonner'
import type { BirdStatus, PeersResponse, SpeakersResponse } from '@/types/api'
import { apiJSON, apiMutate } from '@/lib/api-client'
import type {
BgpPeerCreate,
BgpPeerPatch,
BgpSpeakerCreate,
BgpSpeakerPatch,
BirdStatus,
PeerRow,
PeersResponse,
SpeakerRow,
SpeakersResponse,
} from '@/types/api'
export const NETWORK_AUTO_REFRESH_MS = 30_000 export const NETWORK_AUTO_REFRESH_MS = 30_000
@@ -34,3 +46,86 @@ export function networkBirdQueryOptions() {
staleTime: 15_000, staleTime: 15_000,
}) })
} }
function invalidateNetwork(qc: ReturnType<typeof useQueryClient>) {
void qc.invalidateQueries({ queryKey: networkKeys.all })
void qc.invalidateQueries({ queryKey: ['overview'] })
}
export function useCreatePeerMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: BgpPeerCreate) =>
apiMutate<PeerRow>('/v1/peers', 'POST', body, { idempotent: false }),
onSuccess: () => {
toast.success('Пир создан')
invalidateNetwork(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось создать пира'),
})
}
export function useUpdatePeerMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: string; body: BgpPeerPatch }) =>
apiMutate<PeerRow>(`/v1/peers/${id}`, 'PATCH', body, { idempotent: false }),
onSuccess: () => {
toast.success('Пир обновлён')
invalidateNetwork(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось обновить пира'),
})
}
export function useDeletePeerMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiMutate(`/v1/peers/${id}`, 'DELETE', undefined, { idempotent: false }),
onSuccess: () => {
toast.success('Пир удалён')
invalidateNetwork(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось удалить пира'),
})
}
export function useCreateSpeakerMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: BgpSpeakerCreate) =>
apiMutate<SpeakerRow>('/v1/speakers', 'POST', body, { idempotent: false }),
onSuccess: () => {
toast.success('Спикер создан')
invalidateNetwork(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось создать спикера'),
})
}
export function useUpdateSpeakerMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: string; body: BgpSpeakerPatch }) =>
apiMutate<SpeakerRow>(`/v1/speakers/${id}`, 'PATCH', body, { idempotent: false }),
onSuccess: () => {
toast.success('Спикер обновлён')
invalidateNetwork(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось обновить спикера'),
})
}
export function useDeleteSpeakerMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiMutate(`/v1/speakers/${id}`, 'DELETE', undefined, { idempotent: false }),
onSuccess: () => {
toast.success('Спикер удалён')
invalidateNetwork(qc)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось удалить спикера'),
})
}
+1 -1
View File
@@ -58,7 +58,7 @@ export function overviewRevisionsQueryOptions() {
export function overviewJobsQueryOptions() { export function overviewJobsQueryOptions() {
return queryOptions<JobsResponse>({ return queryOptions<JobsResponse>({
queryKey: overviewKeys.jobs(), queryKey: overviewKeys.jobs(),
queryFn: () => apiJSON<JobsResponse>('/v1/jobs?limit=10'), queryFn: () => apiJSON<JobsResponse>('/v1/jobs?limit=100'),
staleTime: 15_000, staleTime: 15_000,
}) })
} }
+16 -39
View File
@@ -1,11 +1,10 @@
import { createFileRoute, Link } from '@tanstack/react-router' import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Info, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react' import { KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react'
import { useMemo } from 'react' import { useMemo } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { PanelCard } from '@/components/panel-card'
import { AccessApiKeysCard } from '@/components/access/access-api-keys-card' import { AccessApiKeysCard } from '@/components/access/access-api-keys-card'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
@@ -79,29 +78,12 @@ function AccessComponent() {
} }
/> />
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>О API-ключах</AlertTitle>
<AlertDescription>
Роли: <code className="text-xs">viewer</code> (чтение),{' '}
<code className="text-xs">editor</code> (CRUD), <code className="text-xs">operator</code>{' '}
(apply и настройки), <code className="text-xs">node</code> (API ноды). Полный токен
показывается один раз при создании и ротации. Токен браузера в{' '}
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
настройках
</Link>
; для локальной разработки с demo-seed подойдёт <code className="text-xs">dev</code>{' '}
(роль operator).
</AlertDescription>
</Alert>
{session ? ( {session ? (
<Card> <PanelCard
<CardHeader className="border-b py-3"> title="Текущая сессия"
<CardTitle className="text-base">Текущая сессия</CardTitle> description="Tenant и роль ключа, с которым открыта панель."
<CardDescription>Tenant и роль ключа, с которым открыта панель.</CardDescription> contentClassName="grid gap-3 py-4 text-sm sm:grid-cols-2"
</CardHeader> >
<CardContent className="grid gap-3 p-4 text-sm sm:grid-cols-2">
<div> <div>
<p className="text-muted-foreground">Tenant</p> <p className="text-muted-foreground">Tenant</p>
<p className="break-all font-mono text-xs">{session.tenant_id}</p> <p className="break-all font-mono text-xs">{session.tenant_id}</p>
@@ -110,11 +92,9 @@ function AccessComponent() {
<p className="text-muted-foreground">Роль</p> <p className="text-muted-foreground">Роль</p>
<p className="font-mono">{session.role}</p> <p className="font-mono">{session.role}</p>
</div> </div>
</CardContent> </PanelCard>
</Card>
) : ( ) : (
<Card> <PanelCard contentClassName="py-4 text-sm text-muted-foreground">
<CardContent className="py-6 text-sm text-muted-foreground">
Не удалось определить сессию. Укажите токен в{' '} Не удалось определить сессию. Укажите токен в{' '}
<Link to="/settings" className="text-primary underline-offset-4 hover:underline"> <Link to="/settings" className="text-primary underline-offset-4 hover:underline">
настройках настройках
@@ -123,8 +103,7 @@ function AccessComponent() {
{sessionQuery.isError && sessionQuery.error instanceof Error ? ( {sessionQuery.isError && sessionQuery.error instanceof Error ? (
<span className="mt-2 block text-destructive">{sessionQuery.error.message}</span> <span className="mt-2 block text-destructive">{sessionQuery.error.message}</span>
) : null} ) : null}
</CardContent> </PanelCard>
</Card>
)} )}
{isOperator ? ( {isOperator ? (
@@ -143,14 +122,12 @@ function AccessComponent() {
/> />
</> </>
) : session ? ( ) : session ? (
<Card> <PanelCard contentClassName="py-4 text-sm text-muted-foreground">
<CardContent className="py-6 text-sm text-muted-foreground"> Управление API-ключами доступно только роли <strong>operator</strong>. Текущая роль:{' '}
Управление API-ключами доступно только роли <strong>operator</strong>. Текущая роль:{' '} <span className="font-mono">{session.role}</span>. Для выдачи ключей войдите с
<span className="font-mono">{session.role}</span>. Для выдачи ключей войдите с operator-ключом или создайте ключ через API / переменную{' '}
operator-ключом или создайте ключ через API / переменную{' '} <code className="text-xs">EVOBGP_API_KEYS</code>.
<code className="text-xs">EVOBGP_API_KEYS</code>. </PanelCard>
</CardContent>
</Card>
) : null} ) : null}
</div> </div>
) )
+60 -321
View File
@@ -1,44 +1,31 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useQueries } from '@tanstack/react-query' import { useQueries } from '@tanstack/react-query'
import { import { RefreshCw } from 'lucide-react'
Boxes,
CheckCircle,
Clock,
GitBranch,
Info,
Network,
Play,
Plus,
Radio,
RefreshCw,
Activity,
Share2,
Tags,
Gauge,
XCircle,
} from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { useState } from 'react' import { useState } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { PanelCard } from '@/components/panel-card'
import { Skeleton } from '@evobgp/ui/components/skeleton' import { Skeleton } from '@evobgp/ui/components/skeleton'
import {
DashboardNetworkCapacityCard,
DashboardOperationsFlowCard,
DashboardPlatformCard,
} from '@/components/analytics'
import { DataGridCard } from '@/components/data-grid-shell'
import { DashboardQuickActions } from '@/components/dashboard/dashboard-quick-actions'
import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid'
import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { SectionCards, type SectionCardItem } from '@/components/section-cards' import { AnalyticsDashboardSkeleton } from '@/components/skeletons'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { import {
aggregateNetworkMetrics,
moduleNameById, moduleNameById,
overviewHealthQueryOptions,
overviewJobsQueryOptions, overviewJobsQueryOptions,
overviewModulesQueryOptions, overviewModulesQueryOptions,
overviewPeersQueryOptions, overviewPeersQueryOptions,
overviewRevisionsQueryOptions, overviewRevisionsQueryOptions,
overviewSpeakersQueryOptions, overviewSpeakersQueryOptions,
runningJobCount,
} from '@/queries/overview' } from '@/queries/overview'
export const Route = createFileRoute('/_auth/dashboard')({ export const Route = createFileRoute('/_auth/dashboard')({
@@ -50,7 +37,6 @@ function DashboardComponent() {
const results = useQueries({ const results = useQueries({
queries: [ queries: [
overviewHealthQueryOptions(),
overviewModulesQueryOptions(), overviewModulesQueryOptions(),
overviewPeersQueryOptions(), overviewPeersQueryOptions(),
overviewSpeakersQueryOptions(), overviewSpeakersQueryOptions(),
@@ -59,7 +45,7 @@ function DashboardComponent() {
], ],
}) })
const [healthQ, modulesQ, peersQ, speakersQ, revisionsQ, jobsQ] = results const [modulesQ, peersQ, speakersQ, revisionsQ, jobsQ] = results
const initialLoading = const initialLoading =
modulesQ.isLoading || peersQ.isLoading || speakersQ.isLoading || revisionsQ.isLoading || jobsQ.isLoading modulesQ.isLoading || peersQ.isLoading || speakersQ.isLoading || revisionsQ.isLoading || jobsQ.isLoading
const refreshing = results.some((r) => r.isFetching && !r.isLoading) const refreshing = results.some((r) => r.isFetching && !r.isLoading)
@@ -78,60 +64,13 @@ function DashboardComponent() {
const speakers = speakersQ.data?.items ?? [] const speakers = speakersQ.data?.items ?? []
const revisions = revisionsQ.data?.items ?? [] const revisions = revisionsQ.data?.items ?? []
const jobs = jobsQ.data?.items ?? [] const jobs = jobsQ.data?.items ?? []
const modulesHasMore = modulesQ.data?.has_more ?? false
const peersHasMore = peersQ.data?.has_more ?? false
const speakersHasMore = speakersQ.data?.has_more ?? false
const revisionsHasMore = revisionsQ.data?.has_more ?? false
const net = aggregateNetworkMetrics(peers, speakers)
const running = runningJobCount(jobs)
const nameById = moduleNameById(modules) const nameById = moduleNameById(modules)
const countBadge = (n: number, hasMore: boolean, suffix: string) => (hasMore ? '200+' : suffix) const activityLoading =
refreshing && jobs.length === 0 && revisions.length === 0
const items: SectionCardItem[] = [
{
label: 'Модули',
value: initialLoading ? '—' : String(modules.length),
icon: <Boxes className="size-4" />,
hint: countBadge(modules.length, modulesHasMore, 'AS, CDN, домены, IP'),
onClick: () => window.location.assign('/modules'),
},
{
label: 'Пиры',
value: initialLoading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
icon: <GitBranch className="size-4" />,
hint: countBadge(peers.length, peersHasMore, 'Established / включённых'),
badge: net.peersMismatch > 0 ? `mismatch ${net.peersMismatch}` : undefined,
variant: net.peersMismatch > 0 ? 'warning' : 'default',
onClick: () => window.location.assign('/network?tab=peers'),
},
{
label: 'Спикеры',
value: initialLoading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
icon: <Radio className="size-4" />,
hint: countBadge(speakers.length, speakersHasMore, 'online / всего'),
variant: net.speakersOnline < net.speakersTotal ? 'warning' : 'default',
onClick: () => window.location.assign('/network?tab=overview'),
},
{
label: 'Ревизии',
value: initialLoading ? '—' : String(revisions.length),
icon: <Activity className="size-4" />,
hint: countBadge(revisions.length, revisionsHasMore, 'configs'),
onClick: () => window.location.assign('/operations'),
},
{
label: 'Активных задач',
value: initialLoading ? '—' : String(running),
icon: <Clock className="size-4" />,
hint: 'queued и running',
onClick: () => window.location.assign('/operations?tab=jobs'),
},
]
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-5">
<PageHeader <PageHeader
title="Обзор" title="Обзор"
description={ description={
@@ -147,254 +86,54 @@ function DashboardComponent() {
} }
/> />
<Alert className="border-info/30 bg-info/5"> {initialLoading ? (
<Info className="text-info" /> <AnalyticsDashboardSkeleton />
<AlertTitle>Панель управления EvoBGP</AlertTitle> ) : (
<AlertDescription> <div className="grid gap-4 lg:grid-cols-3 lg:items-start">
Сводка по модулям, сети и фоновым задачам. BGP и ноды «Сеть», префиксы «Модули», <DashboardPlatformCard
деплой «Операции», здоровье API «Мониторинг». modules={modules}
</AlertDescription> peers={peers}
</Alert> speakers={speakers}
jobs={jobs}
revisions={revisions}
/>
<DashboardNetworkCapacityCard peers={peers} speakers={speakers} jobs={jobs} />
<DashboardOperationsFlowCard jobs={jobs} modules={modules} />
</div>
)}
<HealthAlert <div className="grid gap-4 lg:grid-cols-2">
loading={healthQ.isLoading} <DataGridCard
ok={healthQ.data === true} title="Недавние задачи"
loadError={modulesQ.isError || peersQ.isError ? 'Некоторые данные не загружены' : null} description="Последние фоновые операции"
/> className="h-full"
>
{activityLoading ? (
<Skeleton className="m-3 h-24 w-auto" />
) : (
<DashboardRecentJobsGrid jobs={jobs.slice(0, 10)} nameById={nameById} isLoading={refreshing} />
)}
</DataGridCard>
{initialLoading ? <SectionCardsSkeleton count={5} /> : <SectionCards items={items} />} <DataGridCard
title="Последние ревизии"
<div className="grid gap-4 lg:grid-cols-3"> description="История конфигураций"
<RecentJobsCard jobs={jobs} nameById={nameById} loading={refreshing} /> className="h-full"
<RecentRevisionsCard revisions={revisions} loading={refreshing} /> >
<NetworkStatusCard peers={peers} speakers={speakers} loading={refreshing} /> {activityLoading ? (
<Skeleton className="m-3 h-24 w-auto" />
) : (
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
)}
</DataGridCard>
</div> </div>
<Card> <PanelCard
<CardHeader className="border-b py-3"> title="Быстрые действия"
<CardTitle className="text-base">Быстрые действия</CardTitle> description="Частые переходы к настройке и деплою"
<CardDescription>Частые переходы к настройке и деплою</CardDescription> footer={<DashboardQuickActions />}
</CardHeader> footerClassName="gap-2 p-3"
<CardContent className="flex flex-wrap gap-2 p-4"> />
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
<Plus className="size-4" />
Создать модуль
</Button>
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/directories')}>
<Tags className="size-4" />
Добавить community
</Button>
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/network?tab=overview')}>
<Network className="size-4" />
Сеть
</Button>
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/network?tab=peers')}>
<Share2 className="size-4" />
Добавить пира
</Button>
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/operations')}>
<Play className="size-4" />
Деплой (Apply)
</Button>
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/monitoring')}>
<Gauge className="size-4" />
Мониторинг
</Button>
</CardContent>
</Card>
</div>
)
}
function HealthAlert({
loading,
ok,
loadError,
}: {
loading: boolean
ok: boolean | undefined
loadError: string | null
}) {
if (loading) {
return (
<Alert>
<Skeleton className="size-5 rounded-full" />
<AlertTitle>Проверка API</AlertTitle>
<AlertDescription>
Запрос к <code className="text-xs">/v1/health</code>
</AlertDescription>
</Alert>
)
}
if (ok && !loadError) {
return (
<Alert className="border-success/30 bg-success/5">
<CheckCircle className="text-success" />
<AlertTitle>API работает</AlertTitle>
<AlertDescription>Сервер отвечает на запросы health-check.</AlertDescription>
</Alert>
)
}
if (ok && loadError) {
return (
<Alert className="border-warning/30 bg-warning/5">
<Info className="text-warning" />
<AlertTitle>API доступен, данные не загружены</AlertTitle>
<AlertDescription>{loadError}. Проверьте Bearer-токен в «Настройках».</AlertDescription>
</Alert>
)
}
return (
<Alert variant="destructive" className="border-destructive/30 bg-destructive/5">
<XCircle className="text-destructive" />
<AlertTitle>API недоступен</AlertTitle>
<AlertDescription>
Не удалось получить ответ от сервера. Проверьте, что API запущен (порт 8080) и в dev работает
прокси Vite.
</AlertDescription>
</Alert>
)
}
function RecentJobsCard({
jobs,
nameById,
loading,
}: {
jobs: import('@/types/api').JobRow[]
nameById: Map<string, string>
loading: boolean
}) {
return (
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Недавние задачи</CardTitle>
<CardDescription>Последние фоновые операции</CardDescription>
</CardHeader>
<CardContent className="p-3">
{loading && jobs.length === 0 ? (
<Skeleton className="h-24 w-full" />
) : jobs.length === 0 ? (
<p className="px-2 py-6 text-center text-sm text-muted-foreground">Нет задач</p>
) : (
<ul className="flex flex-col gap-1">
{jobs.slice(0, 8).map((j) => (
<li
key={j.job_id}
className="flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/40"
>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate font-mono text-xs text-muted-foreground">{j.kind}</span>
<span className="truncate text-xs">
{j.meta?.module_id ? nameById.get(String(j.meta.module_id)) ?? '' : ''}
</span>
</span>
<span
className={
j.status === 'succeeded'
? 'text-xs text-success'
: j.status === 'failed'
? 'text-xs text-destructive'
: 'text-xs text-muted-foreground'
}
>
{j.status}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
)
}
function RecentRevisionsCard({
revisions,
loading,
}: {
revisions: import('@/types/api').RevisionRow[]
loading: boolean
}) {
return (
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Последние ревизии</CardTitle>
<CardDescription>История конфигураций</CardDescription>
</CardHeader>
<CardContent className="p-3">
{loading && revisions.length === 0 ? (
<Skeleton className="h-24 w-full" />
) : revisions.length === 0 ? (
<p className="px-2 py-6 text-center text-sm text-muted-foreground">Нет ревизий</p>
) : (
<ul className="flex flex-col gap-1">
{revisions.slice(0, 8).map((r) => (
<li
key={r.id}
className="flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/40"
>
<span className="truncate font-mono text-xs text-muted-foreground">{r.id.slice(0, 10)}</span>
<span className="text-xs text-muted-foreground">
{new Date(r.created_at).toLocaleString('ru-RU')}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
)
}
function NetworkStatusCard({
peers,
speakers,
loading,
}: {
peers: import('@/types/api').PeerRow[]
speakers: import('@/types/api').SpeakerRow[]
loading: boolean
}) {
const m = aggregateNetworkMetrics(peers, speakers)
return (
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Состояние сети</CardTitle>
<CardDescription>BGP-сессии и спикеры</CardDescription>
</CardHeader>
<CardContent className="p-3">
{loading && peers.length === 0 && speakers.length === 0 ? (
<Skeleton className="h-24 w-full" />
) : (
<div className="flex flex-col gap-2 px-1 py-1 text-sm">
<Row label="Пиры Established" value={`${m.peersEstablished} / ${m.peersEnabled}`} />
<Row label="Спикеры online" value={`${m.speakersOnline} / ${m.speakersTotal}`} />
{m.peersMismatch > 0 ? (
<Row label="Mismatches" value={String(m.peersMismatch)} variant="warning" />
) : null}
</div>
)}
</CardContent>
</Card>
)
}
function Row({
label,
value,
variant = 'default',
}: {
label: string
value: string
variant?: 'default' | 'warning'
}) {
return (
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">{label}</span>
<span className={variant === 'warning' ? 'font-medium text-warning-foreground' : 'font-medium tabular-nums'}>
{value}
</span>
</div> </div>
) )
} }
+56 -114
View File
@@ -1,21 +1,12 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { BookText, Globe, Info, RefreshCw, Tags } from 'lucide-react' import { BookText, Globe, RefreshCw, Tags } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Badge } from '@evobgp/ui/components/badge'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' import { DataGridCard } from '@/components/data-grid-shell'
import { import { DirectoriesCommunitiesGrid } from '@/components/directories/directories-communities-grid'
Table, import { DirectoriesDohGrid } from '@/components/directories/directories-doh-grid'
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { SectionCards, type SectionCardItem } from '@/components/section-cards' import { SectionCards, type SectionCardItem } from '@/components/section-cards'
@@ -76,111 +67,62 @@ function DirectoriesComponent() {
} }
/> />
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>О справочниках</AlertTitle>
<AlertDescription>
Сообщества BGP используются в AS- и CDN-модулях для тегирования префиксов. DoH-профили в
доменных модулях для DNS-over-HTTPS резолвинга.
</AlertDescription>
</Alert>
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />} {loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
<Tabs defaultValue="communities"> <BadgeTabs
<TabsList> defaultValue="communities"
<TabsTrigger value="communities">Сообщества BGP</TabsTrigger> items={[
<TabsTrigger value="doh">DoH профили</TabsTrigger> { value: 'communities', label: 'Сообщества BGP', count: communities.length },
</TabsList> { value: 'doh', label: 'DoH профили', count: dohProfiles.length, badgeVariant: 'info-light' },
]}
<TabsContent value="communities" className="mt-4"> >
<Card> <TabsContent value="communities" className="mt-0">
<CardHeader className="border-b py-3"> <DataGridCard
<CardTitle className="text-base">Сообщества BGP</CardTitle> title="Сообщества BGP"
<CardDescription>Теги для префиксов в фильтрах BIRD</CardDescription> description="Теги для префиксов в фильтрах BIRD"
</CardHeader> >
<CardContent className="p-0"> <QueryState
<QueryState data={communities}
data={communities} isLoading={communitiesQ.isLoading}
isLoading={communitiesQ.isLoading} isError={communitiesQ.isError}
isError={communitiesQ.isError} error={communitiesQ.error}
error={communitiesQ.error} empty={communities.length === 0}
empty={communities.length === 0} emptyTitle="Нет сообществ"
emptyTitle="Нет сообществ" skeleton={<TableSkeleton rows={4} cols={3} />}
skeleton={<TableSkeleton rows={4} cols={3} />} onRetry={() => communitiesQ.refetch()}
onRetry={() => communitiesQ.refetch()} >
> {(items) => (
{(items) => ( <DirectoriesCommunitiesGrid
<Table> items={items}
<TableHeader> isLoading={communitiesQ.isFetching && !communitiesQ.isLoading}
<TableRow> />
<TableHead>Название</TableHead> )}
<TableHead>Значение</TableHead> </QueryState>
<TableHead>Тип</TableHead> </DataGridCard>
</TableRow>
</TableHeader>
<TableBody>
{items.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.title}</TableCell>
<TableCell className="font-mono text-xs">{c.community}</TableCell>
<TableCell>
<Badge variant="outline">community</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="doh" className="mt-4"> <TabsContent value="doh" className="mt-0">
<Card> <DataGridCard title="DoH профили" description="Резолверы DNS-over-HTTPS для доменных модулей">
<CardHeader className="border-b py-3"> <QueryState
<CardTitle className="text-base">DoH профили</CardTitle> data={dohProfiles}
<CardDescription>Резолверы DNS-over-HTTPS для доменных модулей</CardDescription> isLoading={dohQ.isLoading}
</CardHeader> isError={dohQ.isError}
<CardContent className="p-0"> error={dohQ.error}
<QueryState empty={dohProfiles.length === 0}
data={dohProfiles} emptyTitle="Нет DoH профилей"
isLoading={dohQ.isLoading} skeleton={<TableSkeleton rows={4} cols={3} />}
isError={dohQ.isError} onRetry={() => dohQ.refetch()}
error={dohQ.error} >
empty={dohProfiles.length === 0} {(items) => (
emptyTitle="Нет DoH профилей" <DirectoriesDohGrid
skeleton={<TableSkeleton rows={4} cols={3} />} items={items}
onRetry={() => dohQ.refetch()} isLoading={dohQ.isFetching && !dohQ.isLoading}
> />
{(items) => ( )}
<Table> </QueryState>
<TableHeader> </DataGridCard>
<TableRow>
<TableHead>Название</TableHead>
<TableHead>URL</TableHead>
<TableHead>По умолчанию</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.name ?? p.url}</TableCell>
<TableCell className="font-mono text-xs">{p.url}</TableCell>
<TableCell>
<Badge variant="outline"></Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
</Card>
</TabsContent> </TabsContent>
</Tabs> </BadgeTabs>
</div> </div>
) )
} }
+129 -214
View File
@@ -1,38 +1,30 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Copy, Info, RefreshCw, Shield } from 'lucide-react' import { Copy, Plus, RefreshCw, Shield } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { PanelCard } from '@/components/panel-card'
import { Input } from '@evobgp/ui/components/input' import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label' import { Label } from '@evobgp/ui/components/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { import { DataGridCard } from '@/components/data-grid-shell'
Table, import { FirewallClientsGrid } from '@/components/firewall/firewall-clients-grid'
TableBody, import { FirewallRuleCreateDialog } from '@/components/firewall/firewall-rule-create-dialog'
TableCell, import { FirewallRulesGrid } from '@/components/firewall/firewall-rules-grid'
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { CommunitySelect } from '@/components/modules/community-select' import { QueryState } from '@/components/query-state'
import { StatusBadge } from '@/components/status-badge' import { TableSkeleton } from '@/components/skeletons'
import { communityLabel } from '@/lib/modules/helpers'
import { directoriesCommunitiesQueryOptions } from '@/queries/directories' import { directoriesCommunitiesQueryOptions } from '@/queries/directories'
import { import {
firewallClientsQueryOptions, firewallClientsQueryOptions,
firewallInstallContextQueryOptions, firewallInstallContextQueryOptions,
firewallRulesQueryOptions, firewallRulesQueryOptions,
useApproveFirewallClient, useApproveFirewallClient,
useCreateFirewallRule, useDeleteFirewallClient,
useDeleteFirewallRule, useDeleteFirewallRule,
} from '@/queries/firewall' } from '@/queries/firewall'
import type { BgpCommunity, FirewallClient } from '@/types/api'
function httpsOrigin(origin: string): string { function httpsOrigin(origin: string): string {
try { try {
@@ -54,7 +46,7 @@ function FirewallPage() {
const clientsQ = useQuery(firewallClientsQueryOptions()) const clientsQ = useQuery(firewallClientsQueryOptions())
const rulesQ = useQuery(firewallRulesQueryOptions('tenant')) const rulesQ = useQuery(firewallRulesQueryOptions('tenant'))
const approve = useApproveFirewallClient() const approve = useApproveFirewallClient()
const createRule = useCreateFirewallRule() const deleteClient = useDeleteFirewallClient()
const deleteRule = useDeleteFirewallRule() const deleteRule = useDeleteFirewallRule()
const installCtx = installCtxQ.data const installCtx = installCtxQ.data
@@ -64,6 +56,7 @@ function FirewallPage() {
typeof window !== 'undefined' ? httpsOrigin(window.location.origin) : 'https://api.example.com', typeof window !== 'undefined' ? httpsOrigin(window.location.origin) : 'https://api.example.com',
) )
const [seed, setSeed] = useState('') const [seed, setSeed] = useState('')
const [createRuleOpen, setCreateRuleOpen] = useState(false)
useEffect(() => { useEffect(() => {
if (installCtx?.suggested_cp_url) { if (installCtx?.suggested_cp_url) {
@@ -73,14 +66,16 @@ function FirewallPage() {
setSeed(installCtx.bundle_seed) setSeed(installCtx.bundle_seed)
} }
}, [installCtx?.bundle_seed, installCtx?.suggested_cp_url]) }, [installCtx?.bundle_seed, installCtx?.suggested_cp_url])
const [ruleAction, setRuleAction] = useState<'block' | 'accept'>('block')
const [ruleCommunityId, setRuleCommunityId] = useState<string | null>(null)
const [ruleComment, setRuleComment] = useState('')
const communities = communitiesQ.data?.items ?? [] const communities = communitiesQ.data?.items ?? []
const clients = clientsQ.data?.items ?? [] const { activeClients, pending } = useMemo(() => {
const pending = clients.filter((c) => c.status === 'pending') const all = clientsQ.data?.items ?? []
return {
activeClients: all.filter((c) => c.status !== 'revoked'),
pending: all.filter((c) => c.status === 'pending'),
}
}, [clientsQ.data?.items])
const rules = rulesQ.data?.items ?? [] const rules = rulesQ.data?.items ?? []
const installCmd = useMemo(() => { const installCmd = useMemo(() => {
@@ -97,7 +92,7 @@ function FirewallPage() {
toast.error( toast.error(
installCtx?.bundle_seed_configured === false installCtx?.bundle_seed_configured === false
? 'На CP не задан EVOBGP_BUNDLE_SEED_HEX' ? 'На CP не задан EVOBGP_BUNDLE_SEED_HEX'
: 'Bundle seed недоступен (нужна роль operator)', : 'Seed бандла недоступен (нужна роль оператора)',
) )
return return
} }
@@ -112,7 +107,7 @@ function FirewallPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<PageHeader <PageHeader
title="Firewall blocklist" title="Файрвол: blocklist"
description="Linux-серверы: синхронизация CIDR по policy block/accept" description="Linux-серверы: синхронизация CIDR по policy block/accept"
actions={ actions={
<Button <Button
@@ -130,25 +125,16 @@ function FirewallPage() {
} }
/> />
<Alert className="border-info/30 bg-info/5"> <PanelCard
<Info className="text-info" /> title={
<AlertTitle>Политика</AlertTitle> <span className="flex items-center gap-2">
<AlertDescription> <Shield className="size-4" />
Правила сопоставляются с <strong>BGP community</strong> префиксов опубликованной revision.{' '}
<strong>block</strong> добавляет префиксы community в kernel; <strong>accept</strong> не блокирует.
Community «Все» правило для любого community. Default без совпадений accept.
</AlertDescription>
</Alert>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="size-5" />
Установка на сервер Установка на сервер
</CardTitle> </span>
<CardDescription>One-liner для root на целевом Linux (bash, curl). После enroll approve в «Запросы».</CardDescription> }
</CardHeader> description="Команда для root на целевом Linux (bash, curl). После регистрации — одобрите клиента во вкладке «Запросы»."
<CardContent className="flex flex-col gap-4"> contentClassName="flex flex-col gap-4 py-4"
>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="fw-name">Имя сервера</Label> <Label htmlFor="fw-name">Имя сервера</Label>
@@ -159,7 +145,7 @@ function FirewallPage() {
<Input id="fw-url" value={cpUrl} onChange={(e) => setCpUrl(e.target.value)} /> <Input id="fw-url" value={cpUrl} onChange={(e) => setCpUrl(e.target.value)} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="fw-seed">Bundle seed</Label> <Label htmlFor="fw-seed">Seed бандла</Label>
<Input <Input
id="fw-seed" id="fw-seed"
type="password" type="password"
@@ -170,10 +156,10 @@ function FirewallPage() {
/> />
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{installCtxQ.isLoading {installCtxQ.isLoading
? 'Загрузка из control plane…' ? 'Загрузка с плоскости управления…'
: installCtx?.bundle_seed_configured : installCtx?.bundle_seed_configured
? 'Из переменной EVOBGP_BUNDLE_SEED_HEX на CP (docker compose / .env)' ? 'Из переменной EVOBGP_BUNDLE_SEED_HEX на CP (docker compose / .env)'
: 'На CP не задан EVOBGP_BUNDLE_SEED_HEX — enroll невозможен'} : 'На CP не задан EVOBGP_BUNDLE_SEED_HEX — регистрация невозможна'}
</p> </p>
</div> </div>
</div> </div>
@@ -182,180 +168,109 @@ function FirewallPage() {
<Copy /> <Copy />
Копировать команду Копировать команду
</Button> </Button>
</CardContent> </PanelCard>
</Card>
<Tabs defaultValue="clients"> <BadgeTabs
<TabsList> defaultValue="clients"
<TabsTrigger value="clients">Клиенты ({clients.length})</TabsTrigger> items={[
<TabsTrigger value="rules">Правила ({rules.length})</TabsTrigger> { value: 'clients', label: 'Клиенты', count: activeClients.length },
<TabsTrigger value="requests">Запросы ({pending.length})</TabsTrigger> { value: 'rules', label: 'Правила', count: rules.length, badgeVariant: 'info-light' },
</TabsList> {
value: 'requests',
<TabsContent value="clients" className="mt-4"> label: 'Запросы',
<ClientsTable clients={clients} onApprove={(id) => approve.mutate(id)} /> count: pending.length,
</TabsContent> badgeVariant: pending.length > 0 ? 'warning-light' : 'primary-light',
},
<TabsContent value="rules" className="mt-4 space-y-4"> ]}
<div className="flex flex-wrap items-end gap-3"> >
<div className="space-y-1"> <TabsContent value="clients" className="mt-0">
<Label>Действие</Label> <DataGridCard title="Клиенты" description="Активные Linux-серверы с синхронизацией списка блокировок">
<select <QueryState
className="border-input bg-background h-9 rounded-md border px-2 text-sm" data={clientsQ.data}
value={ruleAction} isLoading={clientsQ.isLoading}
onChange={(e) => setRuleAction(e.target.value as 'block' | 'accept')} isError={clientsQ.isError}
> error={clientsQ.error}
<option value="block">block</option> onRetry={() => void clientsQ.refetch()}
<option value="accept">accept</option> skeleton={<TableSkeleton rows={5} cols={6} />}
</select>
</div>
<CommunitySelect
id="fw-rule-community"
label="Community"
value={ruleCommunityId}
onValueChange={setRuleCommunityId}
communities={communities}
nullable
placeholder="Все communities"
/>
<div className="space-y-1">
<Label htmlFor="fw-rule-comment">Комментарий</Label>
<Input
id="fw-rule-comment"
className="max-w-xs"
placeholder="Комментарий"
value={ruleComment}
onChange={(e) => setRuleComment(e.target.value)}
/>
</div>
<Button
size="sm"
className="mb-0.5"
onClick={() =>
createRule.mutate({
scope: 'tenant',
action: ruleAction,
community_id: ruleCommunityId,
comment: ruleComment,
})
}
> >
Добавить правило {() => (
</Button> <FirewallClientsGrid
</div> clients={activeClients}
<RulesTable isLoading={clientsQ.isFetching && !clientsQ.isLoading}
rules={rules} onApprove={(id) => approve.mutate(id)}
onReject={(id) => deleteClient.mutate(id)}
approvePending={approve.isPending}
rejectPending={deleteClient.isPending}
/>
)}
</QueryState>
</DataGridCard>
</TabsContent>
<TabsContent value="rules" className="mt-0">
<DataGridCard
title="Правила"
actions={
<Button size="sm" type="button" onClick={() => setCreateRuleOpen(true)}>
<Plus />
Добавить правило
</Button>
}
>
<QueryState
data={rulesQ.data}
isLoading={rulesQ.isLoading}
isError={rulesQ.isError}
error={rulesQ.error}
onRetry={() => void rulesQ.refetch()}
skeleton={<TableSkeleton rows={5} cols={5} />}
>
{() => (
<FirewallRulesGrid
rules={rules}
communities={communities}
isLoading={rulesQ.isFetching && !rulesQ.isLoading}
onDelete={(id) => deleteRule.mutate(id)}
deletePending={deleteRule.isPending}
/>
)}
</QueryState>
</DataGridCard>
<FirewallRuleCreateDialog
open={createRuleOpen}
onOpenChange={setCreateRuleOpen}
communities={communities} communities={communities}
onDelete={(id) => deleteRule.mutate(id)}
/> />
</TabsContent> </TabsContent>
<TabsContent value="requests" className="mt-4"> <TabsContent value="requests" className="mt-0">
<ClientsTable <DataGridCard
clients={pending} title="Запросы"
onApprove={(id) => approve.mutate(id)} description="Запросы на регистрацию — одобрите или отклоните новые клиенты"
emptyTitle="Нет pending-запросов" >
/> <QueryState
data={clientsQ.data}
isLoading={clientsQ.isLoading}
isError={clientsQ.isError}
error={clientsQ.error}
onRetry={() => void clientsQ.refetch()}
skeleton={<TableSkeleton rows={3} cols={6} />}
>
{() => (
<FirewallClientsGrid
clients={pending}
isLoading={clientsQ.isFetching && !clientsQ.isLoading}
onApprove={(id) => approve.mutate(id)}
onReject={(id) => deleteClient.mutate(id)}
approvePending={approve.isPending}
rejectPending={deleteClient.isPending}
emptyTitle="Нет ожидающих запросов"
/>
)}
</QueryState>
</DataGridCard>
</TabsContent> </TabsContent>
</Tabs> </BadgeTabs>
</div> </div>
) )
} }
function ClientsTable({
clients,
onApprove,
emptyTitle = 'Нет клиентов',
}: {
clients: FirewallClient[]
onApprove: (id: string) => void
emptyTitle?: string
}) {
if (clients.length === 0) {
return <p className="text-muted-foreground text-sm">{emptyTitle}</p>
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Last seen</TableHead>
<TableHead>Apply</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{clients.map((c) => (
<TableRow key={c.id}>
<TableCell>
<div className="font-medium">{c.name}</div>
<div className="text-muted-foreground text-xs">{c.hostname || c.token_prefix}</div>
</TableCell>
<TableCell>
<StatusBadge status={c.status} />
</TableCell>
<TableCell className="text-xs">{c.last_seen_at?.slice(0, 19) ?? '—'}</TableCell>
<TableCell className="text-xs">
{c.last_apply_status ?? '—'}
{c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''}
</TableCell>
<TableCell>
{c.status === 'pending' ? (
<Button size="sm" variant="outline" onClick={() => onApprove(c.id)}>
Approve
</Button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function RulesTable({
rules,
communities,
onDelete,
}: {
rules: { id: string; priority: number; action: string; community_id?: string | null; comment?: string }[]
communities: BgpCommunity[]
onDelete: (id: string) => void
}) {
if (rules.length === 0) {
return <p className="text-muted-foreground text-sm">Нет правил blocklist пуст (default accept).</p>
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Действие</TableHead>
<TableHead>Community</TableHead>
<TableHead>Комментарий</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{rules.map((r) => (
<TableRow key={r.id}>
<TableCell>{r.priority}</TableCell>
<TableCell>
<StatusBadge status={r.action} label={r.action} />
</TableCell>
<TableCell className="text-sm">
{r.community_id ? communityLabel(r.community_id, communities) : 'Все'}
</TableCell>
<TableCell>{r.comment || '—'}</TableCell>
<TableCell>
<Button size="sm" variant="ghost" onClick={() => onDelete(r.id)}>
Удалить
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
@@ -1,8 +1,7 @@
import { createFileRoute, Link } from '@tanstack/react-router' import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, Info, RefreshCw } from 'lucide-react' import { ArrowLeft, RefreshCw } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
@@ -17,25 +16,12 @@ import {
directoriesDohQueryOptions, directoriesDohQueryOptions,
} from '@/queries/directories' } from '@/queries/directories'
import { moduleDetailQueryOptions, moduleEntriesQueryOptions, modulesKeys } from '@/queries/modules' import { moduleDetailQueryOptions, moduleEntriesQueryOptions, modulesKeys } from '@/queries/modules'
import type { AsEntry, ModuleRow } from '@/types/api' import type { AsEntry } from '@/types/api'
export const Route = createFileRoute('/_auth/modules/$moduleId')({ export const Route = createFileRoute('/_auth/modules/$moduleId')({
component: ModuleDetailComponent, component: ModuleDetailComponent,
}) })
function moduleTypeAlert(type: ModuleRow['type']): string {
switch (type) {
case 'AS_PREFIXES':
return 'Модуль AS получает префиксы через RIPEstat по указанным ASN. После refresh счётчики префиксов обновляются в таблице записей.'
case 'CDN_CIDRS':
return 'Модуль CDN скачивает списки CIDR по URL (plaintext или JSON). Используйте предпросмотр при добавлении источника.'
case 'DOMAINS':
return 'Модуль доменов резолвит FQDN через DoH-профили и конвертирует IP в префиксы. Политика и профили настраиваются в редактировании модуля.'
case 'IP_RANGES':
return 'Модуль IP-диапазонов использует статические CIDR без внешнего refresh (сервер может вернуть 204). Записи участвуют в агрегации напрямую.'
}
}
function ModuleDetailComponent() { function ModuleDetailComponent() {
const { moduleId } = Route.useParams() const { moduleId } = Route.useParams()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -114,12 +100,6 @@ function ModuleDetailComponent() {
)} )}
</div> </div>
<Alert>
<Info />
<AlertTitle>О модуле</AlertTitle>
<AlertDescription>{moduleTypeAlert(m.type)}</AlertDescription>
</Alert>
<ModuleKpiCards <ModuleKpiCards
mod={m} mod={m}
communities={communities} communities={communities}
+27 -79
View File
@@ -1,25 +1,15 @@
import { Link, createFileRoute } from '@tanstack/react-router' import { Link, createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Boxes, Plus, RefreshCw } from 'lucide-react' import { Plus, RefreshCw } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { Badge } from '@/components/reui/badge'
import { DataGridCard } from '@/components/data-grid-shell'
import { ModulesListGrid } from '@/components/modules/modules-list-grid'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons' import { TableSkeleton } from '@/components/skeletons'
import { TruncatedText } from '@/components/truncated-text'
import { modulesListQueryOptions } from '@/queries/modules' import { modulesListQueryOptions } from '@/queries/modules'
import type { ModuleRow } from '@/types/api'
export const Route = createFileRoute('/_auth/modules/')({ export const Route = createFileRoute('/_auth/modules/')({
component: ModulesListComponent, component: ModulesListComponent,
@@ -48,76 +38,34 @@ function ModulesListComponent() {
} }
/> />
<Card> <DataGridCard
<CardHeader className="flex flex-row items-center justify-between border-b py-3"> title="Все модули"
<CardTitle className="text-base">Все модули</CardTitle> actions={
<Button size="sm" render={<Link to="/modules/new" />}> <Button size="sm" render={<Link to="/modules/new" />}>
<Plus /> <Plus />
Создать Создать
</Button> </Button>
</CardHeader> }
<CardContent className="p-0"> >
<QueryState <QueryState
data={query.data?.items} data={query.data?.items}
isLoading={query.isLoading} isLoading={query.isLoading}
isError={query.isError} isError={query.isError}
error={query.error} error={query.error}
empty={query.data?.items?.length === 0} empty={query.data?.items?.length === 0}
emptyTitle="Нет модулей" emptyTitle="Нет модулей"
emptyDescription="Создайте первый модуль (AS, CDN, домены, IP)." emptyDescription="Создайте первый модуль (AS, CDN, домены, IP)."
skeleton={<TableSkeleton rows={6} cols={5} />} skeleton={<TableSkeleton rows={6} cols={5} />}
onRetry={() => query.refetch()} onRetry={() => query.refetch()}
> >
{(items) => <ModulesTable items={items} />} {(items) => (
</QueryState> <ModulesListGrid
</CardContent> items={items}
</Card> isLoading={query.isFetching && !query.isLoading}
/>
)}
</QueryState>
</DataGridCard>
</div> </div>
) )
} }
function ModulesTable({ items }: { items: ModuleRow[] }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Название</TableHead>
<TableHead>Тип</TableHead>
<TableHead>Приоритет</TableHead>
<TableHead>Состояние</TableHead>
<TableHead>Обновлено</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((m) => (
<TableRow
key={m.id}
className="cursor-pointer hover:bg-muted/40"
onClick={() => (window.location.href = `/modules/${m.id}`)}
>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Boxes className="size-4 text-muted-foreground" />
<TruncatedText className="max-w-[280px]">{m.name}</TruncatedText>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{m.type}</Badge>
</TableCell>
<TableCell className="font-mono text-sm tabular-nums">{m.priority}</TableCell>
<TableCell>
{m.enabled ? (
<Badge variant="success">включён</Badge>
) : (
<Badge variant="secondary">выключен</Badge>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{m.last_refreshed_at ? new Date(m.last_refreshed_at).toLocaleString('ru-RU') : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
+140 -271
View File
@@ -1,36 +1,32 @@
import { createFileRoute, useSearch } from '@tanstack/react-router' import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Activity, AlertTriangle, Bird, Database, Gauge, HardDrive, HeartPulse, Info, ListTodo, RefreshCw, ShieldCheck } from 'lucide-react' import { Activity, AlertTriangle, Bird, RefreshCw } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Badge } from '@evobgp/ui/components/badge'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { PanelCard } from '@/components/panel-card'
import { Separator } from '@evobgp/ui/components/separator' import { Separator } from '@evobgp/ui/components/separator'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { DataGridCard } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { jobKindRu } from '@/lib/ui-labels'
import { import {
Table, DashboardOperationsFlowCard,
TableBody, MonitoringHealthCard,
TableCell, } from '@/components/analytics'
TableHead, import { MonitoringReadyGrid } from '@/components/monitoring/monitoring-ready-grid'
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { SectionCards, type SectionCardItem } from '@/components/section-cards' import { AnalyticsDashboardSkeleton } from '@/components/skeletons'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { import {
monitoringHealthQueryOptions, monitoringHealthQueryOptions,
monitoringReadyQueryOptions, monitoringReadyQueryOptions,
monitoringVersionQueryOptions, monitoringVersionQueryOptions,
type ReadyStatus,
type VersionInfo, type VersionInfo,
} from '@/queries/monitoring' } from '@/queries/monitoring'
import { networkBirdQueryOptions } from '@/queries/network' import { networkBirdQueryOptions } from '@/queries/network'
import { operationsJobsQueryOptions } from '@/queries/operations' import { operationsJobsQueryOptions } from '@/queries/operations'
import { overviewModulesQueryOptions } from '@/queries/overview'
export const Route = createFileRoute('/_auth/monitoring')({ export const Route = createFileRoute('/_auth/monitoring')({
component: MonitoringComponent, component: MonitoringComponent,
@@ -44,58 +40,31 @@ export const Route = createFileRoute('/_auth/monitoring')({
function MonitoringComponent() { function MonitoringComponent() {
const search = useSearch({ from: '/_auth/monitoring' }) const search = useSearch({ from: '/_auth/monitoring' })
const navigate = Route.useNavigate()
const healthQ = useQuery(monitoringHealthQueryOptions()) const healthQ = useQuery(monitoringHealthQueryOptions())
const readyQ = useQuery(monitoringReadyQueryOptions()) const readyQ = useQuery(monitoringReadyQueryOptions())
const versionQ = useQuery(monitoringVersionQueryOptions()) const versionQ = useQuery(monitoringVersionQueryOptions())
const birdQ = useQuery(networkBirdQueryOptions()) const birdQ = useQuery(networkBirdQueryOptions())
const jobsQ = useQuery(operationsJobsQueryOptions()) const jobsQ = useQuery(operationsJobsQueryOptions())
const modulesQ = useQuery(overviewModulesQueryOptions())
const refreshing = const refreshing =
healthQ.isFetching || healthQ.isFetching ||
readyQ.isFetching || readyQ.isFetching ||
versionQ.isFetching || versionQ.isFetching ||
birdQ.isFetching || birdQ.isFetching ||
jobsQ.isFetching jobsQ.isFetching ||
modulesQ.isFetching
const jobs = jobsQ.data?.items ?? [] const jobs = jobsQ.data?.items ?? []
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length const modules = modulesQ.data?.items ?? []
const failed = jobs.filter((j) => const failed = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()), ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length ).length
const versionText = formatVersion(versionQ.data) const versionText = formatVersion(versionQ.data)
const analyticsLoading =
const items: SectionCardItem[] = [ (healthQ.isLoading || readyQ.isLoading || jobsQ.isLoading) && jobs.length === 0
{
label: 'Общий статус',
value: overallStatusLabel({ health: healthQ.data, ready: readyQ.data, jobsFailed: failed }),
icon: <Gauge className="size-4" />,
hint: overallHint({ health: healthQ.data, jobsFailed: failed }),
},
{
label: 'BGP сессии',
value: birdQ.data
? `${birdQ.data.bgp_established}/${birdQ.data.bgp_sessions_total}`
: '—',
icon: <Bird className="size-4" />,
hint: birdQ.data?.birdc_configured
? 'Established / total на API-хосте'
: 'birdc не настроен',
},
{
label: 'Задачи',
value: running,
icon: <Activity className="size-4" />,
hint: `активных из ${jobs.length}`,
variant: failed > 0 ? 'warning' : 'default',
},
{
label: 'Версия',
value: versionText,
icon: <Gauge className="size-4" />,
hint: versionQ.data?.git_sha ?? versionQ.data?.build_time ?? 'GET /v1/version',
},
]
function refetchAll() { function refetchAll() {
void healthQ.refetch() void healthQ.refetch()
@@ -103,6 +72,7 @@ function MonitoringComponent() {
void versionQ.refetch() void versionQ.refetch()
void birdQ.refetch() void birdQ.refetch()
void jobsQ.refetch() void jobsQ.refetch()
void modulesQ.refetch()
} }
const failedJobs = jobs const failedJobs = jobs
@@ -113,7 +83,13 @@ function MonitoringComponent() {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<PageHeader <PageHeader
title="Мониторинг" title="Мониторинг"
description="Состояние API, BGP и задач для диагностики инцидентов" description={`Состояние API, BGP и задач для диагностики инцидентов${
versionText !== '—'
? ` · версия ${versionText}${
versionQ.data?.git_sha ? ` (${versionQ.data.git_sha.slice(0, 8)})` : ''
}`
: ''
}`}
actions={ actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}> <Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} /> <RefreshCw className={refreshing ? 'animate-spin' : ''} />
@@ -122,45 +98,58 @@ function MonitoringComponent() {
} }
/> />
<Tabs defaultValue={search.tab}> <BadgeTabs
<TabsList> value={search.tab}
<TabsTrigger value="system">Система</TabsTrigger> onValueChange={(tab) =>
<TabsTrigger value="postgres">PostgreSQL</TabsTrigger> navigate({ search: { tab: tab as 'system' | 'postgres' | 'runtime-logs' } })
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger> }
</TabsList> items={[
{ value: 'system', label: 'Система' },
<TabsContent value="system" className="mt-4 flex flex-col gap-6"> { value: 'postgres', label: 'PostgreSQL' },
{refreshing ? <SectionCardsSkeleton count={4} /> : <SectionCards items={items} />} { value: 'runtime-logs', label: 'Файловые логи' },
]}
>
<TabsContent value="system" className="mt-0 flex flex-col gap-6">
{analyticsLoading ? (
<AnalyticsDashboardSkeleton />
) : (
<div className="grid gap-4 lg:grid-cols-2">
<MonitoringHealthCard
healthOk={healthQ.data?.ok ?? false}
ready={readyQ.data}
loading={healthQ.isLoading || readyQ.isLoading}
/>
<DashboardOperationsFlowCard jobs={jobs} modules={modules} loading={jobsQ.isLoading} />
</div>
)}
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
<Card> <DataGridCard
<CardHeader> title="Доступность и готовность"
<CardTitle className="text-base">Доступность и готовность</CardTitle> description="GET /v1/health · GET /v1/ready"
<CardDescription>GET /v1/health · GET /v1/ready</CardDescription> >
</CardHeader> <QueryState
<CardContent className="space-y-4"> data={readyQ.data}
<QueryState isLoading={readyQ.isLoading}
data={readyQ.data} isError={readyQ.isError}
isLoading={readyQ.isLoading} error={readyQ.error}
isError={readyQ.isError} skeleton={<div className="h-40" />}
error={readyQ.error} onRetry={() => readyQ.refetch()}
skeleton={<div className="h-40" />} >
onRetry={() => readyQ.refetch()} {(ready) => <MonitoringReadyGrid health={healthQ.data} ready={ready} />}
> </QueryState>
{(ready) => <ReadyTable health={healthQ.data} ready={ready} />} </DataGridCard>
</QueryState>
</CardContent>
</Card>
<Card> <PanelCard
<CardHeader> title={
<CardTitle className="flex items-center gap-2 text-base"> <span className="flex items-center gap-2">
<Bird className="size-4" /> <Bird className="size-4" />
BGP на API-хосте BGP на API-хосте
</CardTitle> </span>
<CardDescription>GET /v1/bird/status</CardDescription> }
</CardHeader> description="GET /v1/bird/status"
<CardContent> contentClassName="py-4"
>
<QueryState <QueryState
data={birdQ.data} data={birdQ.data}
isLoading={birdQ.isLoading} isLoading={birdQ.isLoading}
@@ -171,22 +160,22 @@ function MonitoringComponent() {
> >
{(bird) => <BirdSummary bird={bird} />} {(bird) => <BirdSummary bird={bird} />}
</QueryState> </QueryState>
</CardContent> </PanelCard>
</Card>
</div> </div>
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
<Card> <PanelCard
<CardHeader> title={
<CardTitle className="flex items-center gap-2 text-base"> <span className="flex items-center gap-2">
<Activity className="size-4" /> <Activity className="size-4" />
Задачи Задачи
</CardTitle> </span>
<CardDescription>Последние 100 задач · GET /v1/jobs</CardDescription> }
</CardHeader> description="Последние 100 задач · GET /v1/jobs"
<CardContent className="space-y-4"> contentClassName="space-y-4 py-4"
>
<div className="flex flex-wrap gap-4 text-sm"> <div className="flex flex-wrap gap-4 text-sm">
<Metric label="Активных" value={running} /> <Metric label="Активных" value={jobs.filter((j) => j.status === 'running' || j.status === 'queued').length} />
<Metric <Metric
label="С ошибками" label="С ошибками"
value={failed} value={failed}
@@ -202,8 +191,8 @@ function MonitoringComponent() {
{failedJobs.map((job) => ( {failedJobs.map((job) => (
<li key={job.job_id} className="rounded-lg border px-3 py-2 text-sm"> <li key={job.job_id} className="rounded-lg border px-3 py-2 text-sm">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<p className="font-medium">{job.kind}</p> <p className="font-medium">{jobKindRu(job.kind)}</p>
<Badge variant="destructive">{job.status}</Badge> <StatusBadge status={job.status} />
</div> </div>
{job.error ? ( {job.error ? (
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
@@ -220,91 +209,68 @@ function MonitoringComponent() {
Критичных сбоев в последних 100 задачах нет. Критичных сбоев в последних 100 задачах нет.
</p> </p>
)} )}
</CardContent> </PanelCard>
</Card>
<Card> <PanelCard
<CardHeader> title={
<CardTitle className="flex items-center gap-2 text-base"> <span className="flex items-center gap-2">
<AlertTriangle className="size-4 text-muted-foreground" /> <AlertTriangle className="size-4 text-muted-foreground" />
Что проверять при деградации Что проверять при деградации
</CardTitle> </span>
<CardDescription>Короткая шпаргалка для triage</CardDescription> }
</CardHeader> description="Краткая шпаргалка для первичной диагностики"
<CardContent className="space-y-3"> contentClassName="py-4"
<Alert> >
<HeartPulse className="size-4" /> <ul className="space-y-3 text-sm text-muted-foreground">
<AlertTitle>API недоступен</AlertTitle> <li>
<AlertDescription> <span className="font-medium text-foreground">API недоступен.</span> Если{' '}
Если <code className="text-xs">/v1/health</code> возвращает ошибку проверьте процесс <code className="text-xs">/v1/health</code> возвращает ошибку проверьте процесс API и
API и его логи. его логи.
</AlertDescription> </li>
</Alert> <li>
<Alert> <span className="font-medium text-foreground">Готовность не «Готов».</span> Сначала{' '}
<Database className="size-4" /> <code className="text-xs">postgres</code>, затем <code className="text-xs">store</code>{' '}
<AlertTitle>Readiness не «Готов»</AlertTitle> и <code className="text-xs">jobs</code> в проверках.
<AlertDescription> </li>
Сначала <code className="text-xs">postgres</code>, затем{' '} <li>
<code className="text-xs">store</code> и <code className="text-xs">jobs</code> в checks. <span className="font-medium text-foreground">Низкий ratio BGP.</span> Проверьте{' '}
</AlertDescription> <code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
</Alert> </li>
<Alert> <li>
<Bird className="size-4" /> <span className="font-medium text-foreground">Ошибки задач.</span> Откройте Операции и
<AlertTitle>Низкий ratio BGP</AlertTitle> проверьте последние неуспешные задачи.
<AlertDescription> </li>
Проверьте <code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети. </ul>
</AlertDescription> </PanelCard>
</Alert>
<Alert>
<ListTodo className="size-4" />
<AlertTitle>Ошибки задач</AlertTitle>
<AlertDescription>
Откройте Операции и проверьте последние неуспешные jobs.
</AlertDescription>
</Alert>
</CardContent>
</Card>
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="postgres" className="mt-4"> <TabsContent value="postgres" className="mt-0">
<Card> <PanelCard
<CardHeader> title="PostgreSQL"
<CardTitle className="text-base">PostgreSQL</CardTitle> description={
<CardDescription>Статус соединения и пул</CardDescription> <>
</CardHeader> Статус соединения и пул. PostgreSQL отображается в readiness-проверке на вкладке «Система»
<CardContent> (check <code className="text-xs">postgres</code>).
<Alert> </>
<Database className="size-4" /> }
<AlertTitle>Статус готовности</AlertTitle> contentClassName="py-4"
<AlertDescription> />
PostgreSQL-соединение отображается в readiness-проверке на вкладке «Система» (check{' '}
<code className="text-xs">postgres</code>).
</AlertDescription>
</Alert>
</CardContent>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="runtime-logs" className="mt-4"> <TabsContent value="runtime-logs" className="mt-0">
<Card> <PanelCard
<CardHeader> title="Файловые логи"
<CardTitle className="text-base">Файловые логи</CardTitle> description={
<CardDescription>Логи API и pipeline</CardDescription> <>
</CardHeader> Логи API и pipeline настраиваются переменной <code className="text-xs">EVOBGP_LOG_*</code> и
<CardContent> управляются в tenant-settings.
<Alert> </>
<Info className="size-4" /> }
<AlertTitle>Логи на сервере</AlertTitle> contentClassName="py-4"
<AlertDescription> />
Файловые логи настраиваются переменной <code className="text-xs">EVOBGP_LOG_*</code> и
управляются tenant-settings на странице «Настройки BIRD».
</AlertDescription>
</Alert>
</CardContent>
</Card>
</TabsContent> </TabsContent>
</Tabs> </BadgeTabs>
</div> </div>
) )
} }
@@ -323,103 +289,6 @@ function formatVersion(version?: VersionInfo | null): string {
return version.version ?? version.app ?? '—' return version.version ?? version.app ?? '—'
} }
interface OverallInput {
health?: { ok?: boolean } | null
ready?: ReadyStatus | null
jobsFailed: number
}
function overallStatusLabel(input: OverallInput): string {
if (!input.health?.ok) return 'Ошибка'
if (input.jobsFailed > 0) return 'Внимание'
if (input.ready?.status && input.ready.status !== 'ok') return 'Внимание'
return 'В норме'
}
function overallHint(input: OverallInput): string {
if (!input.health?.ok) return 'API недоступен или возвращает ошибку'
if (input.jobsFailed > 0) return `Есть провальные задачи (${input.jobsFailed})`
return 'Все системы работают в штатном режиме'
}
function ReadyTable({
health,
ready,
}: {
health?: { ok?: boolean; status?: string; error?: string } | null
ready: ReadyStatus
}) {
const checks = ready.checks ?? {}
const iconByKey: Record<string, typeof Database> = {
postgres: Database,
store: HardDrive,
jobs: ListTodo,
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[55%]">Проверка</TableHead>
<TableHead>Статус</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>
<div className="flex items-center gap-2">
<HeartPulse className="size-4 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Liveness</p>
<p className="text-xs text-muted-foreground">/v1/health</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={health?.ok ? 'default' : 'destructive'}>
{health?.ok ? 'OK' : 'Ошибка'}
</Badge>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<div className="flex items-center gap-2">
<ShieldCheck className="size-4 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Readiness</p>
<p className="text-xs text-muted-foreground">/v1/ready</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={ready.status === 'ok' ? 'default' : 'secondary'}>
{ready.status ?? '—'}
</Badge>
</TableCell>
</TableRow>
{Object.entries(checks).map(([key, value]) => {
const ok = typeof value === 'boolean' ? value : value?.ok !== false
const Icon = iconByKey[key] ?? ListTodo
return (
<TableRow key={key}>
<TableCell>
<div className="flex items-center gap-2">
<Icon className="size-4 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">{key}</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={ok ? 'default' : 'destructive'}>{ok ? 'OK' : 'Ошибка'}</Badge>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)
}
function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) { function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
if (!bird.birdc_configured) { if (!bird.birdc_configured) {
return ( return (
@@ -435,7 +304,7 @@ function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Established / total</span> <span className="text-muted-foreground">Установлено / всего</span>
<span className="font-medium tabular-nums"> <span className="font-medium tabular-nums">
{bird.bgp_established} / {bird.bgp_sessions_total} {bird.bgp_established} / {bird.bgp_sessions_total}
{ratio !== null ? <span className="text-muted-foreground"> ({ratio}%)</span> : null} {ratio !== null ? <span className="text-muted-foreground"> ({ratio}%)</span> : null}
+85 -191
View File
@@ -1,26 +1,21 @@
import { createFileRoute, useSearch } from '@tanstack/react-router' import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { PanelCard } from '@/components/panel-card'
import { Info, RefreshCw } from 'lucide-react' import { RefreshCw } from 'lucide-react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { Badge } from '@/components/reui/badge'
import { StatusBadge } from '@/components/status-badge'
import {
DashboardNetworkCapacityCard,
NetworkOverviewAnalyticsCard,
} from '@/components/analytics'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { NetworkPeersCard } from '@/components/network/network-peers-card'
import { NetworkSpeakersCard } from '@/components/network/network-speakers-card'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons' import { TableSkeleton } from '@/components/skeletons'
import { networkBirdQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions } from '@/queries/network' import { networkBirdQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions } from '@/queries/network'
import { aggregateNetworkMetrics } from '@/queries/overview' import { overviewJobsQueryOptions } from '@/queries/overview'
export const Route = createFileRoute('/_auth/network')({ export const Route = createFileRoute('/_auth/network')({
component: NetworkComponent, component: NetworkComponent,
@@ -33,14 +28,17 @@ export const Route = createFileRoute('/_auth/network')({
function NetworkComponent() { function NetworkComponent() {
const search = useSearch({ from: '/_auth/network' }) const search = useSearch({ from: '/_auth/network' })
const navigate = Route.useNavigate()
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 }) const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 }) const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 }) const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
const jobsQ = useQuery(overviewJobsQueryOptions())
const refreshing = peersQ.isFetching || speakersQ.isFetching const refreshing = peersQ.isFetching || speakersQ.isFetching
const peers = peersQ.data?.items ?? [] const peers = peersQ.data?.items ?? []
const speakers = speakersQ.data?.items ?? [] const speakers = speakersQ.data?.items ?? []
const net = aggregateNetworkMetrics(peers, speakers) const jobs = jobsQ.data?.items ?? []
const overviewLoading = peersQ.isLoading || speakersQ.isLoading
function refetchAll() { function refetchAll() {
void peersQ.refetch() void peersQ.refetch()
@@ -61,115 +59,86 @@ function NetworkComponent() {
} }
/> />
<Alert className="border-info/30 bg-info/5"> <BadgeTabs
<Info className="text-info" /> value={search.tab}
<AlertTitle>О сетевой конфигурации</AlertTitle> onValueChange={(tab) =>
<AlertDescription> navigate({
Вкладка «Обзор» live-статус agent и BGP на CP и репликах. Apply и ревизии на странице «Операции». search: {
</AlertDescription> tab: tab as 'overview' | 'peers' | 'speakers' | 'control-plane',
</Alert> },
})
<Tabs defaultValue={search.tab}> }
<TabsList> items={[
<TabsTrigger value="overview">Обзор</TabsTrigger> { value: 'overview', label: 'Обзор' },
<TabsTrigger value="peers">Пиры ({peers.length})</TabsTrigger> { value: 'peers', label: 'Пиры', count: peers.length },
<TabsTrigger value="speakers">Спикеры ({speakers.length})</TabsTrigger> { value: 'speakers', label: 'Спикеры', count: speakers.length, badgeVariant: 'info-light' },
<TabsTrigger value="control-plane">Control plane</TabsTrigger> { value: 'control-plane', label: 'Плоскость управления' },
</TabsList> ]}
>
<TabsContent value="overview" className="mt-4"> <TabsContent value="overview" className="mt-0">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
<Card> <NetworkOverviewAnalyticsCard
<CardHeader className="border-b py-3"> peers={peers}
<CardTitle className="text-base">Сводка сети</CardTitle> speakers={speakers}
</CardHeader> loading={overviewLoading}
<CardContent className="grid grid-cols-2 gap-3 p-4 text-sm"> />
<Field label="Пиры всего" value={String(net.peersTotal)} /> <DashboardNetworkCapacityCard
<Field label="Established" value={`${net.peersEstablished} / ${net.peersEnabled}`} /> peers={peers}
<Field label="Спикеры всего" value={String(net.speakersTotal)} /> speakers={speakers}
<Field label="Online" value={`${net.speakersOnline} / ${net.speakersTotal}`} /> jobs={jobs}
{net.peersMismatch > 0 ? ( loading={overviewLoading}
<Field label="Mismatches" value={String(net.peersMismatch)} /> />
) : null}
</CardContent>
</Card>
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">BIRD (control plane)</CardTitle>
<CardDescription>Статус birdc на хосте API</CardDescription>
</CardHeader>
<CardContent className="p-4">
<QueryState
data={birdQ.data}
isLoading={birdQ.isLoading}
isError={birdQ.isError}
error={birdQ.error}
skeleton={<TableSkeleton rows={3} cols={2} />}
onRetry={() => birdQ.refetch()}
>
{(bird) => <BirdSummary bird={bird} />}
</QueryState>
</CardContent>
</Card>
</div> </div>
</TabsContent> <PanelCard
className="mt-4"
<TabsContent value="peers" className="mt-4"> title="BIRD (control plane)"
<Card> description="Статус birdc на хосте API"
<CardHeader className="border-b py-3"> contentClassName="py-4"
<CardTitle className="text-base">Пиры</CardTitle> >
</CardHeader>
<CardContent className="p-0">
<QueryState <QueryState
data={peers} data={birdQ.data}
isLoading={peersQ.isLoading} isLoading={birdQ.isLoading}
isError={peersQ.isError} isError={birdQ.isError}
error={peersQ.error} error={birdQ.error}
empty={peers.length === 0} skeleton={<TableSkeleton rows={3} cols={2} />}
emptyTitle="Нет пиров" onRetry={() => birdQ.refetch()}
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={() => peersQ.refetch()}
> >
{(items) => <PeersTable items={items} />} {(bird) => <BirdSummary bird={bird} />}
</QueryState> </QueryState>
</CardContent> </PanelCard>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="speakers" className="mt-4"> <TabsContent value="peers" className="mt-0">
<Card> <NetworkPeersCard
<CardHeader className="border-b py-3"> items={peers}
<CardTitle className="text-base">Спикеры</CardTitle> speakers={speakers}
</CardHeader> isLoading={peersQ.isLoading}
<CardContent className="p-0"> isError={peersQ.isError}
<QueryState error={peersQ.error}
data={speakers} onRetry={() => peersQ.refetch()}
isLoading={speakersQ.isLoading} />
isError={speakersQ.isError}
error={speakersQ.error}
empty={speakers.length === 0}
emptyTitle="Нет спикеров"
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={() => speakersQ.refetch()}
>
{(items) => <SpeakersTable items={items} />}
</QueryState>
</CardContent>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="control-plane" className="mt-4"> <TabsContent value="speakers" className="mt-0">
<Card> <NetworkSpeakersCard
<CardHeader className="border-b py-3"> items={speakers}
<CardTitle className="text-base">Настройки Control Plane (BIRD)</CardTitle> isLoading={speakersQ.isLoading}
<CardDescription>Конфигурация tenant-level в разделе «Настройки BIRD»</CardDescription> isError={speakersQ.isError}
</CardHeader> error={speakersQ.error}
<CardContent className="p-4 text-sm text-muted-foreground"> onRetry={() => speakersQ.refetch()}
См. раздел «Настройки BIRD». />
</CardContent>
</Card>
</TabsContent> </TabsContent>
</Tabs>
<TabsContent value="control-plane" className="mt-0">
<PanelCard
title="Настройки Control Plane (BIRD)"
description="Конфигурация tenant-level — в разделе «Настройки BIRD»"
contentClassName="py-4 text-sm text-muted-foreground"
>
См. раздел «Настройки BIRD».
</PanelCard>
</TabsContent>
</BadgeTabs>
</div> </div>
) )
} }
@@ -196,78 +165,3 @@ function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
</div> </div>
) )
} }
function PeersTable({ items }: { items: import('@/types/api').PeerRow[] }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Neighbor</TableHead>
<TableHead>ASN</TableHead>
<TableHead>Состояние</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.name ?? p.neighbor}</TableCell>
<TableCell className="font-mono text-xs">{p.neighbor}</TableCell>
<TableCell className="font-mono text-xs">{p.remote_asn ?? '—'}</TableCell>
<TableCell>
<StatusBadge status={p.session_state} />
{p.session_mismatch ? (
<Badge variant="warning" className="ml-1">
mismatch
</Badge>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function SpeakersTable({ items }: { items: import('@/types/api').SpeakerRow[] }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Endpoint</TableHead>
<TableHead>Роль</TableHead>
<TableHead>Agent</TableHead>
<TableHead>BGP</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-mono text-xs">{s.endpoint}</TableCell>
<TableCell>
<Badge variant="outline">{s.role}</Badge>
</TableCell>
<TableCell>
{s.live?.agent_ok === true ? (
<StatusBadge status="ok" label="online" />
) : s.live?.agent_ok === false ? (
<StatusBadge status="error" label="offline" />
) : (
<Badge variant="outline"></Badge>
)}
</TableCell>
<TableCell>
{s.live ? (
<span className="text-xs">
{s.live.bgp_established ?? 0} / {s.live.bgp_sessions_total ?? 0}
</span>
) : (
'—'
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
+86 -279
View File
@@ -1,39 +1,24 @@
import { createFileRoute, useSearch } from '@tanstack/react-router' import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Clock, Activity, Info, RefreshCw } from 'lucide-react' import { RefreshCw } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { PanelCard } from '@/components/panel-card'
import { import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
Select, import { DataGridCard } from '@/components/data-grid-shell'
SelectContent, import { OperationsAnalyticsCard } from '@/components/analytics'
SelectItem, import { SelectMenu } from '@/components/select-field'
SelectTrigger, import { OperationsJobsGrid } from '@/components/operations/operations-jobs-grid'
SelectValue, import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid'
} 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 { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { operationsJobsQueryOptions, operationsRevisionsQueryOptions, operationsDiffQueryOptions } from '@/queries/operations' import { operationsJobsQueryOptions, operationsRevisionsQueryOptions, operationsDiffQueryOptions } from '@/queries/operations'
import { moduleNameById, overviewModulesQueryOptions } from '@/queries/overview' import { moduleNameById, overviewModulesQueryOptions } from '@/queries/overview'
import { apiMutate, waitForJob } from '@/lib/api-client' import { apiMutate, waitForJob } from '@/lib/api-client'
import type { JobRow } from '@/types/api'
export const Route = createFileRoute('/_auth/operations')({ export const Route = createFileRoute('/_auth/operations')({
component: OperationsComponent, component: OperationsComponent,
@@ -47,6 +32,7 @@ export const Route = createFileRoute('/_auth/operations')({
function OperationsComponent() { function OperationsComponent() {
const search = useSearch({ from: '/_auth/operations' }) const search = useSearch({ from: '/_auth/operations' })
const navigate = Route.useNavigate()
const qc = useQueryClient() const qc = useQueryClient()
const revisionsQ = useQuery(operationsRevisionsQueryOptions()) const revisionsQ = useQuery(operationsRevisionsQueryOptions())
@@ -58,32 +44,6 @@ function OperationsComponent() {
const nameById = moduleNameById(modulesQ.data?.items ?? []) const nameById = moduleNameById(modulesQ.data?.items ?? [])
const refreshing = revisionsQ.isFetching || jobsQ.isFetching const refreshing = revisionsQ.isFetching || jobsQ.isFetching
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
const failed = jobs.filter(
(j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const items: SectionCardItem[] = [
{
label: 'Ревизий',
value: revisions.length,
icon: <Activity className="size-4" />,
hint: 'история конфигов',
},
{
label: 'Активных задач',
value: running,
icon: <Clock className="size-4" />,
hint: 'queued и running',
},
{
label: 'Задач с ошибкой',
value: failed,
icon: <AlertTriangle className="size-4" />,
hint: failed > 0 ? 'требуют внимания' : 'критичных сбоев нет',
variant: failed > 0 ? 'warning' : 'default',
},
]
function refetchAll() { function refetchAll() {
void revisionsQ.refetch() void revisionsQ.refetch()
@@ -133,237 +93,101 @@ function OperationsComponent() {
} }
/> />
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Три раздела на одной странице</AlertTitle>
<AlertDescription>
<strong>Ревизии</strong> история конфигов и откат; <strong>Сравнение</strong> diff
префиксов; <strong>Задачи</strong> ingest, apply, rollback.
</AlertDescription>
</Alert>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<ConfirmDialog <ConfirmDialog
trigger={ trigger={
<Button variant="default" size="sm" disabled={applyMutation.isPending}> <Button variant="default" size="sm" disabled={applyMutation.isPending}>
Apply Применить
</Button> </Button>
} }
title="Применить конфигурацию на всех спикерах?" title="Применить конфигурацию на всех спикерах?"
description="Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль operator." description="Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль оператора."
confirmLabel="Применить" confirmLabel="Применить"
onConfirm={() => applyMutation.mutate()} onConfirm={() => applyMutation.mutate()}
/> />
<ConfirmDialog <ConfirmDialog
trigger={ trigger={
<Button variant="outline" size="sm" disabled={birdReloadMutation.isPending}> <Button variant="outline" size="sm" disabled={birdReloadMutation.isPending}>
BIRD reload Перезагрузка BIRD
</Button> </Button>
} }
title="Перезагрузить BIRD?" title="Перезагрузить BIRD?"
description="BIRD перезагрузит конфигурацию. Требуется роль operator." description="BIRD перезагрузит конфигурацию. Требуется роль оператора."
confirmLabel="Перезагрузить" confirmLabel="Перезагрузить"
onConfirm={() => birdReloadMutation.mutate()} onConfirm={() => birdReloadMutation.mutate()}
/> />
</div> </div>
{revisionsQ.isLoading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />} {revisionsQ.isLoading ? (
<OperationsAnalyticsCard jobs={[]} revisions={[]} loading />
) : (
<OperationsAnalyticsCard jobs={jobs} revisions={revisions} />
)}
<Tabs defaultValue={search.tab}> <BadgeTabs
<TabsList> value={search.tab}
<TabsTrigger value="revisions">Ревизии ({revisions.length})</TabsTrigger> onValueChange={(tab) =>
<TabsTrigger value="diff">Сравнение</TabsTrigger> navigate({ search: { tab: tab as 'revisions' | 'diff' | 'jobs' } })
<TabsTrigger value="jobs">Задачи ({jobs.length})</TabsTrigger> }
</TabsList> items={[
{ value: 'revisions', label: 'Ревизии', count: revisions.length },
<TabsContent value="revisions" className="mt-4"> { value: 'diff', label: 'Сравнение' },
<Card> { value: 'jobs', label: 'Задачи', count: jobs.length, badgeVariant: 'info-light' },
<CardHeader className="border-b py-3"> ]}
<CardTitle className="text-base">История ревизий</CardTitle> >
</CardHeader> <TabsContent value="revisions" className="mt-0">
<CardContent className="p-0"> <DataGridCard title="История ревизий">
<QueryState <QueryState
data={revisions} data={revisions}
isLoading={revisionsQ.isLoading} isLoading={revisionsQ.isLoading}
isError={revisionsQ.isError} isError={revisionsQ.isError}
error={revisionsQ.error} error={revisionsQ.error}
empty={revisions.length === 0} empty={revisions.length === 0}
emptyTitle="Нет ревизий" emptyTitle="Нет ревизий"
onRetry={() => revisionsQ.refetch()} onRetry={() => revisionsQ.refetch()}
> >
{(items) => <RevisionsTable items={items} qc={qc} />} {(items) => (
</QueryState> <OperationsRevisionsGrid
</CardContent> items={items}
</Card> qc={qc}
isLoading={revisionsQ.isFetching && !revisionsQ.isLoading}
/>
)}
</QueryState>
</DataGridCard>
</TabsContent> </TabsContent>
<TabsContent value="diff" className="mt-4"> <TabsContent value="diff" className="mt-0">
<DiffTab revisions={revisions} /> <DiffTab revisions={revisions} />
</TabsContent> </TabsContent>
<TabsContent value="jobs" className="mt-4"> <TabsContent value="jobs" className="mt-0">
<Card> <DataGridCard title="Задачи">
<CardHeader className="border-b py-3"> <QueryState
<CardTitle className="text-base">Задачи</CardTitle> data={jobs}
</CardHeader> isLoading={jobsQ.isLoading}
<CardContent className="p-0"> isError={jobsQ.isError}
<QueryState error={jobsQ.error}
data={jobs} empty={jobs.length === 0}
isLoading={jobsQ.isLoading} emptyTitle="Нет задач"
isError={jobsQ.isError} onRetry={() => jobsQ.refetch()}
error={jobsQ.error} >
empty={jobs.length === 0} {(items) => (
emptyTitle="Нет задач" <OperationsJobsGrid
onRetry={() => jobsQ.refetch()} items={items}
> nameById={nameById}
{(items) => <JobsTable items={items} nameById={nameById} qc={qc} />} qc={qc}
</QueryState> isLoading={jobsQ.isFetching && !jobsQ.isLoading}
</CardContent> />
</Card> )}
</QueryState>
</DataGridCard>
</TabsContent> </TabsContent>
</Tabs> </BadgeTabs>
</div> </div>
) )
} }
function RevisionsTable({
items,
qc,
}: {
items: import('@/types/api').RevisionRow[]
qc: import('@tanstack/react-query').QueryClient
}) {
const rollbackMutation = useMutation({
mutationFn: (id: string) =>
apiMutate(`/v1/revisions/${id}/rollback`, 'POST', {}).then(() => id),
onSuccess: () => {
toast.success('Откат выполнен')
void qc.invalidateQueries({ queryKey: ['operations'] })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось откатить'),
})
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Создана</TableHead>
<TableHead>Префиксов</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-mono text-xs">{r.id.slice(0, 12)}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{new Date(r.created_at).toLocaleString('ru-RU')}
</TableCell>
<TableCell className="font-mono text-sm tabular-nums">
{r.materialized_prefix_count}
</TableCell>
<TableCell>
<ConfirmDialog
trigger={
<Button variant="ghost" size="icon-sm" className="text-destructive">
<RefreshCw className="size-3.5" />
</Button>
}
title={`Откатиться к ревизии ${r.id.slice(0, 8)}…?`}
description="Будет создана новая ревизия на основе выбранной. Требуется роль operator."
confirmLabel="Откатить"
destructive
onConfirm={() => rollbackMutation.mutate(r.id)}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function JobsTable({
items,
nameById,
qc,
}: {
items: JobRow[]
nameById: Map<string, string>
qc: import('@tanstack/react-query').QueryClient
}) {
const cancelMutation = useMutation({
mutationFn: (jobId: string) => apiMutate(`/v1/jobs/${jobId}/cancel`, 'POST', {}),
onSuccess: () => {
toast.success('Задача отменена')
void qc.invalidateQueries({ queryKey: ['operations'] })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отменить'),
})
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Вид</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Создана</TableHead>
<TableHead>Завершена</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((j) => (
<TableRow key={j.job_id}>
<TableCell className="font-medium">
<div className="flex flex-col gap-0.5">
<span>{j.kind}</span>
{j.meta?.module_id ? (
<span className="text-xs text-muted-foreground">
{nameById.get(String(j.meta.module_id)) ?? String(j.meta.module_id)}
</span>
) : null}
</div>
</TableCell>
<TableCell>
<StatusBadgeColored status={j.status} />
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{j.created_at ? new Date(j.created_at).toLocaleString('ru-RU') : '—'}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{j.finished_at ? new Date(j.finished_at).toLocaleString('ru-RU') : '—'}
</TableCell>
<TableCell>
{j.status === 'running' || j.status === 'queued' ? (
<Button
variant="ghost"
size="icon-sm"
className="text-destructive"
onClick={() => cancelMutation.mutate(j.job_id)}
>
</Button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function StatusBadgeColored({ status }: { status: string }) {
const cls =
status === 'succeeded'
? 'text-success'
: status === 'failed' || status === 'cancelled'
? 'text-destructive'
: 'text-info'
return <span className={`text-sm font-medium ${cls}`}>{status}</span>
}
function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[] }) { function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[] }) {
const [a, setA] = useState('') const [a, setA] = useState('')
const [b, setB] = useState('') const [b, setB] = useState('')
@@ -378,41 +202,25 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
) )
return ( return (
<Card> <PanelCard title="Сравнение ревизий" contentClassName="flex flex-col gap-4 py-4">
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Сравнение ревизий</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-4">
<div className="flex flex-wrap items-end gap-3"> <div className="flex flex-wrap items-end gap-3">
<div className="flex w-full max-w-xs flex-col gap-1"> <div className="flex w-full max-w-xs flex-col gap-1">
<span className="text-xs text-muted-foreground">Ревизия A</span> <span className="text-xs text-muted-foreground">Ревизия A</span>
<Select items={revisionItems} value={a} onValueChange={(v) => v && setA(v)}> <SelectMenu
<SelectTrigger> items={revisionItems}
<SelectValue placeholder="Выберите" /> value={a}
</SelectTrigger> placeholder="Выберите"
<SelectContent> onValueChange={(v) => v && setA(v)}
{revisions.map((r) => ( />
<SelectItem key={r.id} value={r.id}>
{r.id.slice(0, 12)}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<div className="flex w-full max-w-xs flex-col gap-1"> <div className="flex w-full max-w-xs flex-col gap-1">
<span className="text-xs text-muted-foreground">Ревизия B</span> <span className="text-xs text-muted-foreground">Ревизия B</span>
<Select items={revisionItems} value={b} onValueChange={(v) => v && setB(v)}> <SelectMenu
<SelectTrigger> items={revisionItems}
<SelectValue placeholder="Выберите" /> value={b}
</SelectTrigger> placeholder="Выберите"
<SelectContent> onValueChange={(v) => v && setB(v)}
{revisions.map((r) => ( />
<SelectItem key={r.id} value={r.id}>
{r.id.slice(0, 12)}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<Button onClick={() => diffQ.refetch()} disabled={!a || !b || diffQ.isFetching}> <Button onClick={() => diffQ.refetch()} disabled={!a || !b || diffQ.isFetching}>
Сравнить Сравнить
@@ -430,8 +238,7 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
> >
{(diff) => <DiffView diff={diff} />} {(diff) => <DiffView diff={diff} />}
</QueryState> </QueryState>
</CardContent> </PanelCard>
</Card>
) )
} }
+50 -159
View File
@@ -1,28 +1,18 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Clock, Info, ListTodo, RefreshCw } from 'lucide-react' import { AlertTriangle, Clock, ListTodo, RefreshCw } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useState } from 'react' import { useState } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Badge } from '@evobgp/ui/components/badge'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs' import { DataGridCard } from '@/components/data-grid-shell'
import { import { ScheduleJobsGrid } from '@/components/schedule/schedule-jobs-grid'
Table, import { ScheduleModulesGrid } from '@/components/schedule/schedule-modules-grid'
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { SectionCards, type SectionCardItem } from '@/components/section-cards' import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons' import { SectionCardsSkeleton } from '@/components/skeletons'
import { LoadingButton } from '@/components/loading-button'
import { operationsJobsQueryOptions } from '@/queries/operations' import { operationsJobsQueryOptions } from '@/queries/operations'
import { modulesListQueryOptions } from '@/queries/modules' import { modulesListQueryOptions } from '@/queries/modules'
@@ -49,7 +39,7 @@ function ScheduleComponent() {
const items: SectionCardItem[] = [ const items: SectionCardItem[] = [
{ label: 'Всего задач', value: jobs.length, icon: <ListTodo className="size-4" />, hint: 'в выборке' }, { label: 'Всего задач', value: jobs.length, icon: <ListTodo className="size-4" />, hint: 'в выборке' },
{ label: 'В работе', value: running, icon: <Clock className="size-4" />, hint: 'queued и running' }, { label: 'В работе', value: running, icon: <Clock className="size-4" />, hint: 'в очереди и выполняются' },
{ {
label: 'С ошибкой', label: 'С ошибкой',
value: failed, value: failed,
@@ -96,94 +86,35 @@ function ScheduleComponent() {
} }
/> />
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Как работает расписание</AlertTitle>
<AlertDescription>
Планировщик использует <code className="text-xs">refresh_interval_sec</code> и опционально{' '}
<code className="text-xs">cron_expr</code>. Ручной запуск {' '}
<code className="text-xs">POST /v1/modules/&#123;id&#125;/refresh</code>.
</AlertDescription>
</Alert>
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />} {loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
<Card> <DataGridCard
<CardHeader> title="Модули"
<CardTitle className="text-base">Модули</CardTitle> description="Расписание обновления и ручной запуск обновления"
<CardDescription>Расписание обновления и ручной запуск ingest</CardDescription> >
</CardHeader> <QueryState
<CardContent className="p-0"> data={modules}
<QueryState isLoading={modulesQ.isLoading}
data={modules} isError={modulesQ.isError}
isLoading={modulesQ.isLoading} error={modulesQ.error}
isError={modulesQ.isError} empty={modules.length === 0}
error={modulesQ.error} emptyTitle="Нет модулей"
empty={modules.length === 0} onRetry={() => modulesQ.refetch()}
emptyTitle="Нет модулей" >
onRetry={() => modulesQ.refetch()} {(items) => (
> <ScheduleModulesGrid
{(items) => ( items={items}
<Table> refreshing={refreshing}
<TableHeader> onRefresh={(id) => refreshMutation.mutate(id)}
<TableRow> isLoading={modulesQ.isFetching && !modulesQ.isLoading}
<TableHead>Модуль</TableHead> />
<TableHead>Тип</TableHead> )}
<TableHead>Расписание</TableHead> </QueryState>
<TableHead>Обновлено</TableHead> </DataGridCard>
<TableHead>Статус</TableHead>
<TableHead className="w-32 text-right" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((m) => (
<TableRow key={m.id}>
<TableCell className="font-medium">{m.name}</TableCell>
<TableCell>
<Badge variant="outline">{m.type}</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{m.cron_expr ?? (m.refresh_interval_sec ? `${m.refresh_interval_sec}s` : '—')}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{m.last_refreshed_at ? new Date(m.last_refreshed_at).toLocaleString('ru-RU') : '—'}
</TableCell>
<TableCell>
{m.enabled ? (
<Badge variant="default">Вкл</Badge>
) : (
<Badge variant="secondary">Выкл</Badge>
)}
</TableCell>
<TableCell className="text-right">
<LoadingButton
size="sm"
variant="secondary"
loading={!!refreshing[m.id]}
onClick={() => refreshMutation.mutate(m.id)}
>
<RefreshCw />
Обновить
</LoadingButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
</Card>
<Card> <DataGridCard title="Задачи" description="Последние задачи из API">
<CardHeader> <JobsTabs jobs={jobs} loading={jobsQ.isLoading} />
<CardTitle className="text-base">Задачи</CardTitle> </DataGridCard>
<CardDescription>Последние задачи из API</CardDescription>
</CardHeader>
<CardContent className="p-0">
<JobsTabs jobs={jobs} loading={jobsQ.isLoading} />
</CardContent>
</Card>
</div> </div>
) )
} }
@@ -195,69 +126,29 @@ function JobsTabs({ jobs, loading }: { jobs: JobRow[]; loading: boolean }) {
) )
return ( return (
<Tabs defaultValue="all"> <BadgeTabs
<TabsList> defaultValue="all"
<TabsTrigger value="all">Все ({jobs.length})</TabsTrigger> listClassName="mx-3 mb-0 w-auto"
<TabsTrigger value="refresh">Обновление ({refresh.length})</TabsTrigger> items={[
<TabsTrigger value="failed">С ошибкой ({failed.length})</TabsTrigger> { value: 'all', label: 'Все', count: jobs.length },
</TabsList> { value: 'refresh', label: 'Обновление', count: refresh.length, badgeVariant: 'info-light' },
{
value: 'failed',
label: 'С ошибкой',
count: failed.length,
badgeVariant: failed.length > 0 ? 'destructive-light' : 'primary-light',
},
]}
>
<TabsContent value="all" className="mt-0"> <TabsContent value="all" className="mt-0">
<JobsTable items={jobs} loading={loading} /> <ScheduleJobsGrid items={jobs} isLoading={loading} />
</TabsContent> </TabsContent>
<TabsContent value="refresh" className="mt-0"> <TabsContent value="refresh" className="mt-0">
<JobsTable items={refresh} loading={loading} /> <ScheduleJobsGrid items={refresh} isLoading={loading} />
</TabsContent> </TabsContent>
<TabsContent value="failed" className="mt-0"> <TabsContent value="failed" className="mt-0">
<JobsTable items={failed} loading={loading} /> <ScheduleJobsGrid items={failed} isLoading={loading} />
</TabsContent> </TabsContent>
</Tabs> </BadgeTabs>
)
}
function JobsTable({ items, loading }: { items: JobRow[]; loading: boolean }) {
if (loading) return <div className="p-6 text-center text-sm text-muted-foreground">Загрузка</div>
if (items.length === 0)
return <div className="p-6 text-center text-sm text-muted-foreground">Нет задач</div>
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Вид</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Создана</TableHead>
<TableHead>Завершена</TableHead>
<TableHead>Ошибка</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((j) => (
<TableRow key={j.job_id}>
<TableCell className="font-medium">{j.kind}</TableCell>
<TableCell>
<Badge
variant={
j.status === 'succeeded'
? 'default'
: j.status === 'failed'
? 'destructive'
: 'secondary'
}
>
{j.status}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{j.created_at ? new Date(j.created_at).toLocaleString('ru-RU') : '—'}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{j.finished_at ? new Date(j.finished_at).toLocaleString('ru-RU') : '—'}
</TableCell>
<TableCell className="max-w-xs truncate text-xs text-destructive">
{j.error ?? ''}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) )
} }
+21 -53
View File
@@ -1,25 +1,18 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { PanelCard } from '@/components/panel-card'
import { Input } from '@evobgp/ui/components/input' import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label' import { Label } from '@evobgp/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client' import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
import { authKeys, authSessionQueryOptions } from '@/queries/auth' import { authKeys, authSessionQueryOptions } from '@/queries/auth'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Info, Save } from 'lucide-react' import { Save } from 'lucide-react'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@@ -76,25 +69,11 @@ function SettingsComponent() {
description="Параметры интерфейса и подключения браузера к API." description="Параметры интерфейса и подключения браузера к API."
/> />
<Alert className="border-info/30 bg-info/5"> <PanelCard
<Info className="text-info" /> title="Подключение к API"
<AlertTitle>Локальная разработка</AlertTitle> description="Токен хранится только в этом браузере (localStorage). Управление ключами tenant — в разделе «Права доступа»."
<AlertDescription> contentClassName="flex flex-col gap-4 py-4"
При включённом demo-seed API принимает токен <code className="text-xs">dev</code> (роль{' '} >
<code className="text-xs">operator</code>). Вводите только значение токена, без префикса{' '}
<code className="text-xs">Bearer</code> он добавляется автоматически.
</AlertDescription>
</Alert>
<Card>
<CardHeader>
<CardTitle>Подключение к API</CardTitle>
<CardDescription>
Токен хранится только в этом браузере (localStorage). Управление ключами tenant в
разделе «Права доступа».
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="token">Токен для запросов</Label> <Label htmlFor="token">Токен для запросов</Label>
<Input <Input
@@ -130,34 +109,23 @@ function SettingsComponent() {
<code className="text-xs">EVOBGP_SEED_DEMO</code> 0) и запущенный API. <code className="text-xs">EVOBGP_SEED_DEMO</code> 0) и запущенный API.
</p> </p>
) : null} ) : null}
</CardContent> </PanelCard>
</Card>
<Card> <PanelCard
<CardHeader> title="Оформление"
<CardTitle>Оформление</CardTitle> description="Тема интерфейса. Быстрый переключатель также доступен в боковой панели."
<CardDescription> contentClassName="flex flex-col gap-2 py-4"
Тема интерфейса. Быстрый переключатель также доступен в боковой панели. >
</CardDescription> <SelectField
</CardHeader> id="theme-select"
<CardContent className="flex flex-col gap-2"> label="Тема"
<Label htmlFor="theme-select">Тема</Label>
<Select
items={[...THEME_SELECT_ITEMS]} items={[...THEME_SELECT_ITEMS]}
value={theme ?? 'system'} value={theme ?? 'system'}
placeholder="Выберите тему"
triggerClassName="max-w-xs"
onValueChange={(v) => v && setTheme(v)} onValueChange={(v) => v && setTheme(v)}
> />
<SelectTrigger id="theme-select" className="w-full max-w-xs"> </PanelCard>
<SelectValue placeholder="Выберите тему" />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Светлая</SelectItem>
<SelectItem value="dark">Тёмная</SelectItem>
<SelectItem value="system">Как в системе</SelectItem>
</SelectContent>
</Select>
</CardContent>
</Card>
</div> </div>
) )
} }
+101 -168
View File
@@ -1,30 +1,16 @@
import { createFileRoute, useSearch } from '@tanstack/react-router' import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Info, Save } from 'lucide-react' import { Save } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert' import { PanelCard } from '@/components/panel-card'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Input } from '@evobgp/ui/components/input' import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label' import { Label } from '@evobgp/ui/components/label'
import { import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
Select, import { DataGridCard } from '@/components/data-grid-shell'
SelectContent, import { SelectField } from '@/components/select-field'
SelectItem, import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
@@ -56,8 +42,8 @@ const RUNTIME_LOGS_ENABLED_ITEMS = [
] as const ] as const
const RUNTIME_LOGS_MODE_ITEMS = [ const RUNTIME_LOGS_MODE_ITEMS = [
{ value: 'truncate', label: 'truncate — обнулить' }, { value: 'truncate', label: 'обнулить (truncate)' },
{ value: 'delete', label: 'delete — удалить файл' }, { value: 'delete', label: 'удалить файл (delete)' },
] as const ] as const
const BIRD_LABELS: Record<BirdSettingKey, string> = { const BIRD_LABELS: Record<BirdSettingKey, string> = {
@@ -71,6 +57,7 @@ const BIRD_LABELS: Record<BirdSettingKey, string> = {
function TenantSettingsComponent() { function TenantSettingsComponent() {
const search = useSearch({ from: '/_auth/tenant-settings' }) const search = useSearch({ from: '/_auth/tenant-settings' })
const navigate = Route.useNavigate()
const settingsQ = useQuery(settingsQueryOptions()) const settingsQ = useQuery(settingsQueryOptions())
const qc = useQueryClient() const qc = useQueryClient()
@@ -112,45 +99,35 @@ function TenantSettingsComponent() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<PageHeader <PageHeader
title="Параметры tenant" title="Параметры арендатора"
description="Параметры control plane для текущего tenant (API /v1/settings)" description="Параметры плоскости управления для текущего арендатора (API /v1/settings)"
/> />
<Alert className="border-info/30 bg-info/5"> <BadgeTabs
<Info className="text-info" /> value={search.tab}
<AlertTitle>Operator-only</AlertTitle> onValueChange={(tab) =>
<AlertDescription> navigate({
Изменение значений через <code className="text-xs">PATCH /v1/settings</code> требует роли search: { tab: tab as 'bird' | 'revision' | 'runtime-logs' | 'additional' },
operator. При отсутствии прав API вернёт 403. })
</AlertDescription> }
</Alert> items={[
{ value: 'bird', label: 'BIRD' },
<Tabs defaultValue={search.tab}> { value: 'revision', label: 'Ревизии' },
<TabsList> { value: 'runtime-logs', label: 'Файловые логи' },
<TabsTrigger value="bird">BIRD</TabsTrigger> { value: 'additional', label: 'Дополнительно' },
<TabsTrigger value="revision">Ревизии</TabsTrigger> ]}
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger> >
<TabsTrigger value="additional">Дополнительно</TabsTrigger> <TabsContent value="bird" className="mt-0">
</TabsList> <PanelCard
title="BIRD control plane"
<TabsContent value="bird" className="mt-4"> description={
<Card> <>
<CardHeader>
<CardTitle>BIRD control plane</CardTitle>
<CardDescription>
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через{' '} Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через{' '}
<code className="text-xs">PATCH /v1/settings</code> (роль operator). <code className="text-xs">PATCH /v1/settings</code> (роль operator).
</CardDescription> </>
</CardHeader> }
<CardContent className="space-y-4"> contentClassName="space-y-4 py-4"
<Alert className="border-info/30 bg-info/5"> >
<Info className="text-info" />
<AlertTitle>Подстановка в конфиг</AlertTitle>
<AlertDescription>
Значения используются при генерации BIRD-конфигурации (router id, local AS, адреса).
Пиры и спикеры настраиваются в разделе «Сеть».
</AlertDescription>
</Alert>
<QueryState <QueryState
data={partitioned} data={partitioned}
isLoading={settingsQ.isLoading} isLoading={settingsQ.isLoading}
@@ -182,17 +159,15 @@ function TenantSettingsComponent() {
</div> </div>
)} )}
</QueryState> </QueryState>
</CardContent> </PanelCard>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="revision" className="mt-4"> <TabsContent value="revision" className="mt-0">
<Card> <PanelCard
<CardHeader> title="Ревизии"
<CardTitle>Ревизии</CardTitle> description="Время хранения ревизий в БД"
<CardDescription>Время хранения ревизий в БД</CardDescription> contentClassName="space-y-4 py-4"
</CardHeader> >
<CardContent className="space-y-4">
<QueryState <QueryState
data={partitioned} data={partitioned}
isLoading={settingsQ.isLoading} isLoading={settingsQ.isLoading}
@@ -229,17 +204,15 @@ function TenantSettingsComponent() {
</div> </div>
)} )}
</QueryState> </QueryState>
</CardContent> </PanelCard>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="runtime-logs" className="mt-4"> <TabsContent value="runtime-logs" className="mt-0">
<Card> <PanelCard
<CardHeader> title="Файловые логи"
<CardTitle>Файловые логи</CardTitle> description="Автоматическая очистка логов"
<CardDescription>Автоматическая очистка логов</CardDescription> contentClassName="space-y-4 py-4"
</CardHeader> >
<CardContent className="space-y-4">
<QueryState <QueryState
data={partitioned} data={partitioned}
isLoading={settingsQ.isLoading} isLoading={settingsQ.isLoading}
@@ -250,31 +223,20 @@ function TenantSettingsComponent() {
> >
{() => ( {() => (
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col gap-1.5"> <SelectField
<Label>Авто-очистка включена</Label> label="Авто-очистка включена"
<Select items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
items={[...RUNTIME_LOGS_ENABLED_ITEMS]} value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'} placeholder="Выберите"
onValueChange={(v) => description="runtime_logs_auto_enabled"
v && onValueChange={(v) =>
setRuntimeLogsForm((s) => ({ v &&
...s, setRuntimeLogsForm((s) => ({
runtime_logs_auto_enabled: v, ...s,
})) runtime_logs_auto_enabled: v,
} }))
> }
<SelectTrigger> />
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Вкл</SelectItem>
<SelectItem value="false">Выкл</SelectItem>
</SelectContent>
</Select>
<p className="font-mono text-xs text-muted-foreground">
runtime_logs_auto_enabled
</p>
</div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label htmlFor="runtime_logs_max_file_mb">Макс. размер файла (MB)</Label> <Label htmlFor="runtime_logs_max_file_mb">Макс. размер файла (MB)</Label>
<Input <Input
@@ -308,31 +270,20 @@ function TenantSettingsComponent() {
runtime_logs_auto_schedule runtime_logs_auto_schedule
</p> </p>
</div> </div>
<div className="flex flex-col gap-1.5"> <SelectField
<Label>Режим очистки</Label> label="Режим очистки"
<Select items={[...RUNTIME_LOGS_MODE_ITEMS]}
items={[...RUNTIME_LOGS_MODE_ITEMS]} value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''} placeholder="Выберите"
onValueChange={(v) => description="runtime_logs_auto_mode"
v && onValueChange={(v) =>
setRuntimeLogsForm((s) => ({ v &&
...s, setRuntimeLogsForm((s) => ({
runtime_logs_auto_mode: v, ...s,
})) runtime_logs_auto_mode: v,
} }))
> }
<SelectTrigger> />
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
<SelectItem value="truncate">truncate обнулить</SelectItem>
<SelectItem value="delete">delete удалить файл</SelectItem>
</SelectContent>
</Select>
<p className="font-mono text-xs text-muted-foreground">
runtime_logs_auto_mode
</p>
</div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}> <LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
<Save /> <Save />
@@ -342,52 +293,34 @@ function TenantSettingsComponent() {
</div> </div>
)} )}
</QueryState> </QueryState>
</CardContent> </PanelCard>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="additional" className="mt-4"> <TabsContent value="additional" className="mt-0">
<Card> <DataGridCard
<CardHeader> title="Дополнительные параметры"
<CardTitle>Дополнительные параметры</CardTitle> description="Параметры вне стандартных групп (только чтение — изменяются через API)"
<CardDescription> >
Параметры вне стандартных групп (readonly изменяются только через API) <QueryState
</CardDescription> data={partitioned?.additional ?? []}
</CardHeader> isLoading={settingsQ.isLoading}
<CardContent className="p-0"> isError={settingsQ.isError}
<QueryState error={settingsQ.error}
data={partitioned?.additional ?? []} empty={(partitioned?.additional ?? []).length === 0}
isLoading={settingsQ.isLoading} emptyTitle="Нет дополнительных параметров"
isError={settingsQ.isError} skeleton={<div className="h-32" />}
error={settingsQ.error} onRetry={() => settingsQ.refetch()}
empty={(partitioned?.additional ?? []).length === 0} >
emptyTitle="Нет дополнительных параметров" {(items) => (
skeleton={<div className="h-32" />} <SettingsKvGrid
onRetry={() => settingsQ.refetch()} items={items}
> isLoading={settingsQ.isFetching && !settingsQ.isLoading}
{(items) => ( />
<Table> )}
<TableHeader> </QueryState>
<TableRow> </DataGridCard>
<TableHead>Ключ</TableHead>
<TableHead>Значение</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((row) => (
<TableRow key={row.id}>
<TableCell className="font-mono text-xs">{row.key}</TableCell>
<TableCell className="font-mono text-xs">{row.value}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
</Card>
</TabsContent> </TabsContent>
</Tabs> </BadgeTabs>
</div> </div>
) )
} }
+2
View File
@@ -356,6 +356,8 @@ export type FirewallClient = {
last_apply_at?: string | null last_apply_at?: string | null
last_apply_status?: string last_apply_status?: string
last_apply_prefix_count?: number last_apply_prefix_count?: number
last_apply_packets_dropped?: number
last_apply_packets_accepted?: number
last_apply_source?: string last_apply_source?: string
client_version?: string client_version?: string
created_at: string created_at: string
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More