From 7f0c1009dedc0da845a7bb23bd186af103c53b58 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 20 Jul 2026 22:40:48 +0700 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D0=B8=D0=B5=20detail-=D1=80=D0=BE=D1=83=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B8=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82=20?= =?UTF-8?q?ReUI=20DataGrid=20=D1=81=20EvoBGP/CFDM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling routes index+$id, ColumnHeader/PrimaryCell/StatusBadge/ConfirmDialog, tabs и таблица entries списков. Co-authored-by: Cursor --- apps/web/src/components/data-grid-cell.tsx | 46 ++ apps/web/src/components/status-badge.tsx | 34 +- apps/web/src/queries/index.ts | 11 + apps/web/src/routeTree.gen.ts | 199 ++++----- .../_auth/{agents.$id.tsx => agents/$id.tsx} | 160 ++++--- .../_auth/{agents.tsx => agents/index.tsx} | 107 +++-- apps/web/src/routes/_auth/lists/$id.tsx | 198 +++++++++ .../_auth/{lists.tsx => lists/index.tsx} | 121 ++++- .../{rules.$setId.tsx => rules/$setId.tsx} | 412 ++++++++++++------ .../_auth/{rules.tsx => rules/index.tsx} | 112 ++++- 10 files changed, 1040 insertions(+), 360 deletions(-) create mode 100644 apps/web/src/components/data-grid-cell.tsx rename apps/web/src/routes/_auth/{agents.$id.tsx => agents/$id.tsx} (74%) rename apps/web/src/routes/_auth/{agents.tsx => agents/index.tsx} (76%) create mode 100644 apps/web/src/routes/_auth/lists/$id.tsx rename apps/web/src/routes/_auth/{lists.tsx => lists/index.tsx} (67%) rename apps/web/src/routes/_auth/{rules.$setId.tsx => rules/$setId.tsx} (54%) rename apps/web/src/routes/_auth/{rules.tsx => rules/index.tsx} (66%) diff --git a/apps/web/src/components/data-grid-cell.tsx b/apps/web/src/components/data-grid-cell.tsx new file mode 100644 index 0000000..5a3d23f --- /dev/null +++ b/apps/web/src/components/data-grid-cell.tsx @@ -0,0 +1,46 @@ +import type { ReactNode } from 'react' + +import { cn } from '@evofw/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 ( +
+ {title} + {subtitle ? ( + {subtitle} + ) : null} +
+ ) +} + +export function DataGridMutedCell({ + children, + className, +}: { + children: ReactNode + className?: string +}) { + return ( + + {children} + + ) +} diff --git a/apps/web/src/components/status-badge.tsx b/apps/web/src/components/status-badge.tsx index 2d3c6d3..fe09ded 100644 --- a/apps/web/src/components/status-badge.tsx +++ b/apps/web/src/components/status-badge.tsx @@ -11,13 +11,24 @@ const STATUS_VARIANT: Record = { synced: 'success-light', ok: 'success-light', up: 'success-light', + enabled: 'success-light', + approved: 'success-light', + allow: 'success-light', pending_push: 'secondary', + pending: 'warning-light', warning: 'warning-light', degraded: 'warning-light', conflict: 'destructive-light', error: 'destructive-light', expired: 'destructive-light', down: 'destructive-light', + deny: 'destructive-light', + revoked: 'secondary', + disabled: 'secondary', + static: 'secondary', + domains: 'info-light', + json_url: 'info-light', + evobgp_community: 'info-light', unknown: 'outline', } @@ -37,6 +48,13 @@ const STATUS_LABELS: Record = { active: 'Активен', synced: 'Синхронизировано', pending_push: 'Ожидает отправки', + pending: 'Pending', + approved: 'Approved', + revoked: 'Revoked', + enabled: 'Включён', + disabled: 'Выключен', + allow: 'allow', + deny: 'deny', conflict: 'Конфликт', error: 'Ошибка', ok: 'OK', @@ -45,6 +63,10 @@ const STATUS_LABELS: Record = { degraded: 'Slow', down: 'Down', expired: 'Истёк', + static: 'static', + domains: 'domains', + json_url: 'json_url', + evobgp_community: 'evobgp_community', unknown: 'Неизвестно', } @@ -60,8 +82,16 @@ export function StatusBadge({ const variant = STATUS_VARIANT[status] ?? 'outline' const dotColor = DOT_COLOR[variant] ?? 'bg-muted-foreground' return ( - - + + {label ?? STATUS_LABELS[status] ?? status} ) diff --git a/apps/web/src/queries/index.ts b/apps/web/src/queries/index.ts index 6ba2fd2..29408b7 100644 --- a/apps/web/src/queries/index.ts +++ b/apps/web/src/queries/index.ts @@ -32,6 +32,17 @@ export const listsQueryOptions = () => queryFn: () => apiFetch<{ items: IpList[] }>('/api/v1/lists'), }) +export const listQueryOptions = (id: string) => + queryOptions({ + queryKey: ['lists', id], + queryFn: () => + apiFetch< + IpList & { + entries: string[] + } + >(`/api/v1/lists/${id}`), + }) + export const policySetsQueryOptions = () => queryOptions({ queryKey: ['policy-sets'], diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index a1f71e4..18cde6e 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -11,14 +11,15 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as AuthRouteImport } from './routes/_auth' import { Route as AuthIndexRouteImport } from './routes/_auth/index' -import { Route as AuthAgentsRouteImport } from './routes/_auth/agents' -import { Route as AuthListsRouteImport } from './routes/_auth/lists' -import { Route as AuthRulesRouteImport } from './routes/_auth/rules' import { Route as AuthSettingsRouteImport } from './routes/_auth/settings' import { Route as AuthStatsRouteImport } from './routes/_auth/stats' import { Route as AuthCallbackRouteImport } from './routes/auth.callback' -import { Route as AuthAgentsIdRouteImport } from './routes/_auth/agents.$id' -import { Route as AuthRulesSetIdRouteImport } from './routes/_auth/rules.$setId' +import { Route as AuthAgentsIndexRouteImport } from './routes/_auth/agents/index' +import { Route as AuthAgentsIdRouteImport } from './routes/_auth/agents/$id' +import { Route as AuthListsIndexRouteImport } from './routes/_auth/lists/index' +import { Route as AuthListsIdRouteImport } from './routes/_auth/lists/$id' +import { Route as AuthRulesIndexRouteImport } from './routes/_auth/rules/index' +import { Route as AuthRulesSetIdRouteImport } from './routes/_auth/rules/$setId' const AuthRoute = AuthRouteImport.update({ id: '/_auth', @@ -29,21 +30,6 @@ const AuthIndexRoute = AuthIndexRouteImport.update({ path: '/', getParentRoute: () => AuthRoute, } as any) -const AuthAgentsRoute = AuthAgentsRouteImport.update({ - id: '/agents', - path: '/agents', - getParentRoute: () => AuthRoute, -} as any) -const AuthListsRoute = AuthListsRouteImport.update({ - id: '/lists', - path: '/lists', - getParentRoute: () => AuthRoute, -} as any) -const AuthRulesRoute = AuthRulesRouteImport.update({ - id: '/rules', - path: '/rules', - getParentRoute: () => AuthRoute, -} as any) const AuthSettingsRoute = AuthSettingsRouteImport.update({ id: '/settings', path: '/settings', @@ -59,87 +45,113 @@ const AuthCallbackRoute = AuthCallbackRouteImport.update({ path: '/auth/callback', getParentRoute: () => rootRouteImport, } as any) +const AuthAgentsIndexRoute = AuthAgentsIndexRouteImport.update({ + id: '/agents/', + path: '/agents/', + getParentRoute: () => AuthRoute, +} as any) const AuthAgentsIdRoute = AuthAgentsIdRouteImport.update({ - id: '/$id', - path: '/$id', - getParentRoute: () => AuthAgentsRoute, + id: '/agents/$id', + path: '/agents/$id', + getParentRoute: () => AuthRoute, +} as any) +const AuthListsIndexRoute = AuthListsIndexRouteImport.update({ + id: '/lists/', + path: '/lists/', + getParentRoute: () => AuthRoute, +} as any) +const AuthListsIdRoute = AuthListsIdRouteImport.update({ + id: '/lists/$id', + path: '/lists/$id', + getParentRoute: () => AuthRoute, +} as any) +const AuthRulesIndexRoute = AuthRulesIndexRouteImport.update({ + id: '/rules/', + path: '/rules/', + getParentRoute: () => AuthRoute, } as any) const AuthRulesSetIdRoute = AuthRulesSetIdRouteImport.update({ - id: '/$setId', - path: '/$setId', - getParentRoute: () => AuthRulesRoute, + id: '/rules/$setId', + path: '/rules/$setId', + getParentRoute: () => AuthRoute, } as any) export interface FileRoutesByFullPath { '/': typeof AuthIndexRoute - '/agents': typeof AuthAgentsRouteWithChildren - '/lists': typeof AuthListsRoute - '/rules': typeof AuthRulesRouteWithChildren '/settings': typeof AuthSettingsRoute '/stats': typeof AuthStatsRoute '/auth/callback': typeof AuthCallbackRoute '/agents/$id': typeof AuthAgentsIdRoute + '/lists/$id': typeof AuthListsIdRoute '/rules/$setId': typeof AuthRulesSetIdRoute + '/agents/': typeof AuthAgentsIndexRoute + '/lists/': typeof AuthListsIndexRoute + '/rules/': typeof AuthRulesIndexRoute } export interface FileRoutesByTo { - '/agents': typeof AuthAgentsRouteWithChildren - '/lists': typeof AuthListsRoute - '/rules': typeof AuthRulesRouteWithChildren '/settings': typeof AuthSettingsRoute '/stats': typeof AuthStatsRoute '/auth/callback': typeof AuthCallbackRoute '/': typeof AuthIndexRoute '/agents/$id': typeof AuthAgentsIdRoute + '/lists/$id': typeof AuthListsIdRoute '/rules/$setId': typeof AuthRulesSetIdRoute + '/agents': typeof AuthAgentsIndexRoute + '/lists': typeof AuthListsIndexRoute + '/rules': typeof AuthRulesIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/_auth': typeof AuthRouteWithChildren - '/_auth/agents': typeof AuthAgentsRouteWithChildren - '/_auth/lists': typeof AuthListsRoute - '/_auth/rules': typeof AuthRulesRouteWithChildren '/_auth/settings': typeof AuthSettingsRoute '/_auth/stats': typeof AuthStatsRoute '/auth/callback': typeof AuthCallbackRoute '/_auth/': typeof AuthIndexRoute '/_auth/agents/$id': typeof AuthAgentsIdRoute + '/_auth/lists/$id': typeof AuthListsIdRoute '/_auth/rules/$setId': typeof AuthRulesSetIdRoute + '/_auth/agents/': typeof AuthAgentsIndexRoute + '/_auth/lists/': typeof AuthListsIndexRoute + '/_auth/rules/': typeof AuthRulesIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' - | '/agents' - | '/lists' - | '/rules' | '/settings' | '/stats' | '/auth/callback' | '/agents/$id' + | '/lists/$id' | '/rules/$setId' + | '/agents/' + | '/lists/' + | '/rules/' fileRoutesByTo: FileRoutesByTo to: - | '/agents' - | '/lists' - | '/rules' | '/settings' | '/stats' | '/auth/callback' | '/' | '/agents/$id' + | '/lists/$id' | '/rules/$setId' + | '/agents' + | '/lists' + | '/rules' id: | '__root__' | '/_auth' - | '/_auth/agents' - | '/_auth/lists' - | '/_auth/rules' | '/_auth/settings' | '/_auth/stats' | '/auth/callback' | '/_auth/' | '/_auth/agents/$id' + | '/_auth/lists/$id' | '/_auth/rules/$setId' + | '/_auth/agents/' + | '/_auth/lists/' + | '/_auth/rules/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -163,27 +175,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthIndexRouteImport parentRoute: typeof AuthRoute } - '/_auth/agents': { - id: '/_auth/agents' - path: '/agents' - fullPath: '/agents' - preLoaderRoute: typeof AuthAgentsRouteImport - parentRoute: typeof AuthRoute - } - '/_auth/lists': { - id: '/_auth/lists' - path: '/lists' - fullPath: '/lists' - preLoaderRoute: typeof AuthListsRouteImport - parentRoute: typeof AuthRoute - } - '/_auth/rules': { - id: '/_auth/rules' - path: '/rules' - fullPath: '/rules' - preLoaderRoute: typeof AuthRulesRouteImport - parentRoute: typeof AuthRoute - } '/_auth/settings': { id: '/_auth/settings' path: '/settings' @@ -205,63 +196,73 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthCallbackRouteImport parentRoute: typeof rootRouteImport } + '/_auth/agents/': { + id: '/_auth/agents/' + path: '/agents' + fullPath: '/agents/' + preLoaderRoute: typeof AuthAgentsIndexRouteImport + parentRoute: typeof AuthRoute + } '/_auth/agents/$id': { id: '/_auth/agents/$id' - path: '/$id' + path: '/agents/$id' fullPath: '/agents/$id' preLoaderRoute: typeof AuthAgentsIdRouteImport - parentRoute: typeof AuthAgentsRoute + parentRoute: typeof AuthRoute + } + '/_auth/lists/': { + id: '/_auth/lists/' + path: '/lists' + fullPath: '/lists/' + preLoaderRoute: typeof AuthListsIndexRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/lists/$id': { + id: '/_auth/lists/$id' + path: '/lists/$id' + fullPath: '/lists/$id' + preLoaderRoute: typeof AuthListsIdRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/rules/': { + id: '/_auth/rules/' + path: '/rules' + fullPath: '/rules/' + preLoaderRoute: typeof AuthRulesIndexRouteImport + parentRoute: typeof AuthRoute } '/_auth/rules/$setId': { id: '/_auth/rules/$setId' - path: '/$setId' + path: '/rules/$setId' fullPath: '/rules/$setId' preLoaderRoute: typeof AuthRulesSetIdRouteImport - parentRoute: typeof AuthRulesRoute + parentRoute: typeof AuthRoute } } } -interface AuthAgentsRouteChildren { - AuthAgentsIdRoute: typeof AuthAgentsIdRoute -} - -const AuthAgentsRouteChildren: AuthAgentsRouteChildren = { - AuthAgentsIdRoute: AuthAgentsIdRoute, -} - -const AuthAgentsRouteWithChildren = AuthAgentsRoute._addFileChildren( - AuthAgentsRouteChildren, -) - -interface AuthRulesRouteChildren { - AuthRulesSetIdRoute: typeof AuthRulesSetIdRoute -} - -const AuthRulesRouteChildren: AuthRulesRouteChildren = { - AuthRulesSetIdRoute: AuthRulesSetIdRoute, -} - -const AuthRulesRouteWithChildren = AuthRulesRoute._addFileChildren( - AuthRulesRouteChildren, -) - interface AuthRouteChildren { - AuthAgentsRoute: typeof AuthAgentsRouteWithChildren - AuthListsRoute: typeof AuthListsRoute - AuthRulesRoute: typeof AuthRulesRouteWithChildren AuthSettingsRoute: typeof AuthSettingsRoute AuthStatsRoute: typeof AuthStatsRoute AuthIndexRoute: typeof AuthIndexRoute + AuthAgentsIdRoute: typeof AuthAgentsIdRoute + AuthListsIdRoute: typeof AuthListsIdRoute + AuthRulesSetIdRoute: typeof AuthRulesSetIdRoute + AuthAgentsIndexRoute: typeof AuthAgentsIndexRoute + AuthListsIndexRoute: typeof AuthListsIndexRoute + AuthRulesIndexRoute: typeof AuthRulesIndexRoute } const AuthRouteChildren: AuthRouteChildren = { - AuthAgentsRoute: AuthAgentsRouteWithChildren, - AuthListsRoute: AuthListsRoute, - AuthRulesRoute: AuthRulesRouteWithChildren, AuthSettingsRoute: AuthSettingsRoute, AuthStatsRoute: AuthStatsRoute, AuthIndexRoute: AuthIndexRoute, + AuthAgentsIdRoute: AuthAgentsIdRoute, + AuthListsIdRoute: AuthListsIdRoute, + AuthRulesSetIdRoute: AuthRulesSetIdRoute, + AuthAgentsIndexRoute: AuthAgentsIndexRoute, + AuthListsIndexRoute: AuthListsIndexRoute, + AuthRulesIndexRoute: AuthRulesIndexRoute, } const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren) diff --git a/apps/web/src/routes/_auth/agents.$id.tsx b/apps/web/src/routes/_auth/agents/$id.tsx similarity index 74% rename from apps/web/src/routes/_auth/agents.$id.tsx rename to apps/web/src/routes/_auth/agents/$id.tsx index 3b63098..cdea734 100644 --- a/apps/web/src/routes/_auth/agents.$id.tsx +++ b/apps/web/src/routes/_auth/agents/$id.tsx @@ -1,14 +1,15 @@ import { createFileRoute, Link } from '@tanstack/react-router' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' +import type { ColumnDef } from '@tanstack/react-table' import { BanIcon, CheckCircle2Icon, CpuIcon, ClockIcon, } from 'lucide-react' -import { PageHeader, PageShell, DetailPanel } from '@/components/reui-kit' +import { PageShell, DetailPanel } from '@/components/reui-kit' import { Frame, FrameDescription, @@ -16,7 +17,12 @@ import { FramePanel, FrameTitle, } from '@/components/reui/frame' -import { Badge } from '@/components/reui/badge' +import { StatusBadge } from '@/components/status-badge' +import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' +import { DataGridPrimaryCell } from '@/components/data-grid-cell' +import { DataGrid } from '@/components/reui/data-grid/data-grid' +import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' +import { getCoreRowModel, useReactTable } from '@tanstack/react-table' import { agentQueryOptions, agentsQueryOptions, @@ -36,6 +42,7 @@ import { SelectValue, } from '@evofw/ui/components/select' import { Skeleton } from '@evofw/ui/components/skeleton' +import type { PolicySet } from '@evofw/shared' export const Route = createFileRoute('/_auth/agents/$id')({ component: AgentDetailPage, @@ -115,11 +122,81 @@ function AgentDetailPage() { onError: (e: Error) => toast.error(e.message), }) + const setColumns: ColumnDef[] = useMemo( + () => [ + { + id: 'select', + enableSorting: false, + header: () => Выбор, + cell: ({ row }) => ( + { + setSelectedSets( + v + ? [...assignedIds, row.original.id] + : assignedIds.filter((x) => x !== row.original.id), + ) + }} + aria-label={`Назначить ${row.original.name}`} + /> + ), + }, + { + accessorKey: 'name', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + e.stopPropagation()} + > + + + ), + }, + { + accessorKey: 'enabled', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + }, + { + accessorKey: 'rules_count', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.rules_count ?? 0} + ), + }, + ], + [assignedIds], + ) + + const setsTable = useReactTable({ + data: setsQ.data?.items ?? [], + columns: setColumns, + getCoreRowModel: getCoreRowModel(), + getRowId: (r) => r.id, + }) + const a = agentQ.data if (agentQ.isLoading || !a) { return ( - ) @@ -133,23 +210,14 @@ function AgentDetailPage() { description={`${a.platform} · gen ${a.policy_generation}`} actions={
- + - + К списку +
} /> @@ -213,57 +281,31 @@ function AgentDetailPage() { - + Наборы правил Можно назначить несколько — мержатся при sync - -
- {(setsQ.data?.items ?? []).map((s) => { - const checked = assignedIds.includes(s.id) - return ( - - ) - })} -
+ + + + + +
- +
@@ -301,6 +343,7 @@ function AgentDetailPage() { {a.status === 'approved' ? ( ) }, }, ], - [revoke, remove], + [revoke], ) return ( @@ -258,10 +283,11 @@ function AgentsPage() { className="flex flex-wrap items-center justify-between gap-2 border-b py-2 last:border-0" >
-
{a.name}
-
- {a.platform} · {a.hostname ?? '—'} · {a.token_prefix}… -
+
@@ -316,6 +342,19 @@ function AgentsPage() { description: 'Установите agent на сервер и одобрите запрос.', }} /> + + { + if (!open) setDeleteId(null) + }} + title="Удалить агента?" + description="Агент и связанные назначения будут удалены." + onConfirm={() => { + if (deleteId) remove.mutate(deleteId) + }} + disabled={remove.isPending} + /> ) } diff --git a/apps/web/src/routes/_auth/lists/$id.tsx b/apps/web/src/routes/_auth/lists/$id.tsx new file mode 100644 index 0000000..421745f --- /dev/null +++ b/apps/web/src/routes/_auth/lists/$id.tsx @@ -0,0 +1,198 @@ +import { createFileRoute, Link } from '@tanstack/react-router' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { HashIcon, RefreshCwIcon, TagIcon } from 'lucide-react' +import { useMemo, useState } from 'react' +import type { ColumnDef } from '@tanstack/react-table' +import type { Filter, FilterFieldConfig } from '@/components/reui/filters' +import { PageShell, DetailPanel, ResourcePage } from '@/components/reui-kit' +import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' +import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell' +import { StatusBadge } from '@/components/status-badge' +import { listQueryOptions } from '@/queries' +import { apiFetch } from '@/lib/api' +import { Button } from '@evofw/ui/components/button' +import { Skeleton } from '@evofw/ui/components/skeleton' + +export const Route = createFileRoute('/_auth/lists/$id')({ + component: ListDetailPage, +}) + +type EntryRow = { id: string; cidr: string } + +/** + * IP list detail — entries as DataGrid table. + * Preview: https://reui.io/preview/base/data-grid-filtering-1 · empty-state-12 + */ +function ListDetailPage() { + const { id } = Route.useParams() + const qc = useQueryClient() + const listQ = useQuery(listQueryOptions(id)) + const [filters, setFilters] = useState([]) + + const refresh = useMutation({ + mutationFn: () => + apiFetch(`/api/v1/lists/${id}/refresh`, { method: 'POST' }), + onSuccess: () => { + toast.success('Обновлено') + void qc.invalidateQueries({ queryKey: ['lists'] }) + }, + onError: (e: Error) => toast.error(e.message), + }) + + const entries: EntryRow[] = useMemo( + () => + (listQ.data?.entries ?? []).map((cidr, i) => ({ + id: `${i}-${cidr}`, + cidr, + })), + [listQ.data?.entries], + ) + + const filterFields: FilterFieldConfig[] = useMemo( + () => [ + { + key: 'cidr', + label: 'Entry', + type: 'text', + placeholder: 'Поиск CIDR / домен…', + }, + ], + [], + ) + + const columns: ColumnDef[] = useMemo( + () => [ + { + accessorKey: 'cidr', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + }, + ], + [], + ) + + if (listQ.isLoading) { + return ( + + + + + ) + } + + if (!listQ.data) { + return ( + + + }> + К спискам + + } + /> + + + ) + } + + const list = listQ.data + + return ( + + + + + {list.type !== 'static' ? ( + + ) : null} + + + } + /> + + , + label: 'Entries', + description: String(entries.length), + }, + { + id: 'type', + icon: , + label: 'Тип', + description: list.type, + }, + { + id: 'refreshed', + icon: , + label: 'Refresh', + description: list.last_error + ? list.last_error + : (list.refreshed_at ?? '—'), + }, + ]} + /> + + + r.id} + filterFields={filterFields} + filters={filters} + onFiltersChange={setFilters} + onClearFilters={() => setFilters([])} + getFilterFieldValue={(item, field) => + field === 'cidr' ? item.cidr : undefined + } + emptyState={{ + title: 'Нет entries', + description: + list.type === 'static' + ? 'Добавьте CIDR при создании или обновите список.' + : 'Нажмите Refresh или проверьте источник.', + }} + /> + + + {list.last_error ? ( + + {list.last_error} + + ) : null} + + + ) +} diff --git a/apps/web/src/routes/_auth/lists.tsx b/apps/web/src/routes/_auth/lists/index.tsx similarity index 67% rename from apps/web/src/routes/_auth/lists.tsx rename to apps/web/src/routes/_auth/lists/index.tsx index 9debc0b..ac6ede4 100644 --- a/apps/web/src/routes/_auth/lists.tsx +++ b/apps/web/src/routes/_auth/lists/index.tsx @@ -1,10 +1,15 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' +import { Trash2 } from 'lucide-react' import { useCallback, useMemo, useState } from 'react' import type { ColumnDef } from '@tanstack/react-table' import type { Filter, FilterFieldConfig } from '@/components/reui/filters' import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit' +import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' +import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell' +import { StatusBadge } from '@/components/status-badge' +import { ConfirmDialog } from '@/components/confirm-dialog' import { listsQueryOptions } from '@/queries' import { apiFetch } from '@/lib/api' import { Button } from '@evofw/ui/components/button' @@ -28,11 +33,17 @@ import { } from '@evofw/ui/components/sheet' import type { IpList } from '@evofw/shared' -export const Route = createFileRoute('/_auth/lists')({ +export const Route = createFileRoute('/_auth/lists/')({ component: ListsPage, }) +/** + * IP lists — ResourcePage. + * Preview: https://reui.io/preview/base/data-grid-filtering-2 + * Empty: https://reui.io/preview/base/empty-state-12 + */ function ListsPage() { + const navigate = useNavigate() const qc = useQueryClient() const listsQ = useQuery(listsQueryOptions()) const [sheetOpen, setSheetOpen] = useState(false) @@ -42,6 +53,8 @@ function ListsPage() { >('static') const [extra, setExtra] = useState('') const [filters, setFilters] = useState([]) + const [activeTab, setActiveTab] = useState('all') + const [deleteId, setDeleteId] = useState(null) const create = useMutation({ mutationFn: async () => { @@ -62,17 +75,18 @@ function ListsPage() { } else { config.community_id = extra.trim() } - return apiFetch('/api/v1/lists', { + return apiFetch<{ id: string }>('/api/v1/lists', { method: 'POST', body: JSON.stringify({ name, type, config, entries }), }) }, - onSuccess: () => { + onSuccess: (row) => { toast.success('Список создан') setName('') setExtra('') setSheetOpen(false) void qc.invalidateQueries({ queryKey: ['lists'] }) + void navigate({ to: '/lists/$id', params: { id: row.id } }) }, onError: (e: Error) => toast.error(e.message), }) @@ -90,6 +104,8 @@ function ListsPage() { mutationFn: (id: string) => apiFetch(`/api/v1/lists/${id}`, { method: 'DELETE' }), onSuccess: () => { + toast.success('Удалён') + setDeleteId(null) void qc.invalidateQueries({ queryKey: ['lists'] }) }, }) @@ -120,32 +136,72 @@ function ListsPage() { return undefined }, []) + const tabFilter = useCallback((item: IpList, tabId: string) => { + if (tabId === 'all') return true + return item.type === tabId + }, []) + const columns: ColumnDef[] = useMemo( () => [ - { accessorKey: 'name', header: 'Имя' }, - { accessorKey: 'type', header: 'Тип' }, + { + accessorKey: 'name', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + + + ), + }, + { + accessorKey: 'type', + header: ({ column }) => ( + + ), + cell: ({ row }) => , + }, { accessorKey: 'entry_count', - header: 'Entries', + header: ({ column }) => ( + + ), cell: ({ row }) => ( {row.original.entry_count ?? 0} ), }, { id: 'refresh', - header: 'Refresh', + header: ({ column }) => ( + + ), cell: ({ row }) => ( - + {row.original.last_error ?? row.original.refreshed_at ?? '—'} - + ), }, { id: 'actions', + enableSorting: false, + header: () => Действия, cell: ({ row }) => { const l = row.original return (
+ {l.type !== 'static' ? (
) }, }, ], - [refresh, remove], + [refresh], ) return ( @@ -176,7 +234,9 @@ function ListsPage() { title="Списки IP" description="static · JSON URL · domains · EvoBGP community" actions={ - + } /> @@ -191,6 +251,16 @@ function ListsPage() { onFiltersChange={setFilters} onClearFilters={() => setFilters([])} getFilterFieldValue={getFilterFieldValue} + tabs={[ + { id: 'all', label: 'Все' }, + { id: 'static', label: 'static' }, + { id: 'domains', label: 'domains' }, + { id: 'json_url', label: 'json_url' }, + { id: 'evobgp_community', label: 'evobgp' }, + ]} + activeTab={activeTab} + onTabChange={setActiveTab} + tabFilter={tabFilter} isLoading={listsQ.isLoading} isError={listsQ.isError} error={listsQ.error} @@ -199,11 +269,26 @@ function ListsPage() { title: 'Пусто', description: 'Создайте первый список.', action: ( - + ), }} /> + { + if (!open) setDeleteId(null) + }} + title="Удалить список?" + description="Entries и ссылки из правил останутся неконсистентны — удаляйте осторожно." + onConfirm={() => { + if (deleteId) remove.mutate(deleteId) + }} + disabled={remove.isPending} + /> + @@ -223,7 +308,9 @@ function ListsPage() { Тип setAction(v as 'allow' | 'deny')} + onValueChange={(v) => { + if (v) setAction(v as 'allow' | 'deny') + }} > @@ -388,7 +546,9 @@ function PolicySetDetailPage() { Источник