feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -0,0 +1,145 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Asn } from "@/lib/data"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { FilterIcon, NetworkIcon } from "lucide-react"
|
||||
|
||||
interface AsnsDataGridProps {
|
||||
asns: Asn[]
|
||||
isLoading?: boolean
|
||||
emptyDescription?: string
|
||||
pagination?: boolean
|
||||
}
|
||||
|
||||
function AsnsDataGrid({ asns, isLoading, pagination = false, emptyDescription }: AsnsDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, Asn>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "asn",
|
||||
accessorKey: "asn",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="ASN" className="ml-1" />,
|
||||
cell: ({ row }) => <span className="font-mono font-semibold">{row.original.asn}</span>,
|
||||
meta: {
|
||||
headerTitle: "ASN",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "org",
|
||||
accessorKey: "org",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Имя / организация" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium max-w-[min(28rem,50vw)] truncate block" title={row.original.org}>
|
||||
{row.original.org}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Имя / организация",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prefixes",
|
||||
accessorKey: "prefixes",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Префиксов" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums">{row.original.prefixes?.toLocaleString("ru") ?? "—"}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Префиксов",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "filter",
|
||||
accessorKey: "filter",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Фильтр" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />
|
||||
{row.original.filter}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Фильтр",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
accessorKey: "updated",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Обновлён" />,
|
||||
cell: ({ row }) => <span className="text-xs text-muted-foreground">{row.original.updated}</span>,
|
||||
meta: {
|
||||
headerTitle: "Обновлён",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={`text-xs font-medium ${row.original.enabled ? "text-emerald-600" : "text-muted-foreground"}`}
|
||||
>
|
||||
{row.original.enabled ? "Активен" : "Отключён"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: !pagination,
|
||||
data: asns,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={asns.length}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
pagination={pagination}
|
||||
emptyMessage={
|
||||
<EmptyState
|
||||
icon={<NetworkIcon className="size-4" />}
|
||||
title="Нет ASN"
|
||||
description={emptyDescription ?? "Добавьте автономные системы или импортируйте каталог"}
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { AsnsDataGrid, type AsnsDataGridProps }
|
||||
@@ -0,0 +1,281 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useEffect, useMemo, type ReactNode } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Backup } from "@/lib/data"
|
||||
import { backupFileMissing, backupLocalFileDescription } from "@/lib/backup-file-state"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DEFAULT_DATA_GRID_LAYOUT,
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
DownloadIcon,
|
||||
HardDriveIcon,
|
||||
RefreshCwIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
interface BackupsDataGridProps {
|
||||
backups: Backup[]
|
||||
onDownload: (id: string, filename: string) => void
|
||||
onRestore: (backup: Backup) => void
|
||||
onDelete: (backup: Backup) => void
|
||||
canRestore?: boolean
|
||||
canWrite?: boolean
|
||||
canDownload?: boolean
|
||||
emptyAction?: ReactNode
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}
|
||||
|
||||
function storageBadge(storage: Backup["storage"]) {
|
||||
if (storage === "s3") return { label: "S3", variant: "info-light" as const }
|
||||
if (storage === "both") return { label: "Локально + S3", variant: "success-light" as const }
|
||||
return { label: "Локально", variant: "secondary" as const }
|
||||
}
|
||||
|
||||
function BackupsDataGrid({
|
||||
backups,
|
||||
onDownload,
|
||||
onRestore,
|
||||
onDelete,
|
||||
canRestore = true,
|
||||
canWrite = true,
|
||||
canDownload = true,
|
||||
emptyAction,
|
||||
emptyTitle = "Нет бэкапов",
|
||||
emptyDescription = "Создайте первый бэкап вручную или настройте расписание",
|
||||
}: BackupsDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, Backup>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "filename",
|
||||
accessorKey: "filename",
|
||||
size: 110,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Файл" />,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original
|
||||
return (
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<span title={b.filename} className="font-mono text-xs font-medium block break-all">{b.filename}</span>
|
||||
{backupLocalFileDescription(b) && <span id={`backup-file-${encodeURIComponent(b.id)}`} className="text-xs text-muted-foreground block">{backupLocalFileDescription(b)}</span>}
|
||||
{b.uploadError ? (
|
||||
<span className="text-[11px] text-destructive truncate block">{b.uploadError}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Файл",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "server",
|
||||
accessorKey: "server",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">{row.original.server}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "size",
|
||||
accessorFn: (row) => row.sizeBytes,
|
||||
sortUndefined: "last",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Размер" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.size}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Размер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
accessorKey: "kind",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={row.original.kind === "manual" ? "info-light" : "secondary"}
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
{row.original.kind === "auto" ? "авто" : "вручную"}
|
||||
</Badge>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Тип",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "storage",
|
||||
accessorKey: "storage",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Хранилище" />,
|
||||
cell: ({ row }) => {
|
||||
const backup = row.original
|
||||
const badge = backupFileMissing(backup)
|
||||
? { label: "Нет файла", variant: "warning-light" as const }
|
||||
: backup.localFileStatus === "missing" ? { label: "S3", variant: "warning-light" as const }
|
||||
: backup.localFileStatus === "unknown" ? { label: "Файл не проверен", variant: "warning-light" as const }
|
||||
: storageBadge(backup.storage)
|
||||
return (
|
||||
<Badge variant={badge.variant} size="sm" radius="full">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Хранилище",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "notes",
|
||||
accessorKey: "notes",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Заметки" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground max-w-[200px] truncate block">
|
||||
{row.original.notes || "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Заметки",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "created",
|
||||
accessorKey: "created",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Создан" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">{row.original.created}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Создан",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original
|
||||
return (
|
||||
<div className="flex items-center gap-1 justify-end sm:opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 min-w-[44px] min-h-[44px] sm:size-7 sm:min-w-0 sm:min-h-0"
|
||||
title={backupFileMissing(b) ? "Локальный файл отсутствует" : "Скачать"}
|
||||
aria-label={`Скачать ${b.filename}`}
|
||||
disabled={!canDownload || backupFileMissing(b)}
|
||||
aria-describedby={backupLocalFileDescription(b) ? `backup-file-${encodeURIComponent(b.id)}` : undefined}
|
||||
onClick={() => onDownload(b.id, b.filename)}
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 min-w-[44px] min-h-[44px] sm:size-7 sm:min-w-0 sm:min-h-0"
|
||||
title={backupFileMissing(b) ? "Локальный файл отсутствует" : "Восстановить"}
|
||||
aria-label={`Восстановить ${b.filename}`}
|
||||
disabled={!canWrite || !canRestore || !b.serverId || backupFileMissing(b)}
|
||||
aria-describedby={backupLocalFileDescription(b) ? `backup-file-${encodeURIComponent(b.id)}` : undefined}
|
||||
onClick={() => onRestore(b)}
|
||||
>
|
||||
<RefreshCwIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 min-w-[44px] min-h-[44px] sm:size-7 sm:min-w-0 sm:min-h-0 text-destructive hover:text-destructive"
|
||||
title="Удалить"
|
||||
aria-label={`Удалить ${b.filename}`}
|
||||
disabled={!canWrite}
|
||||
onClick={() => onDelete(b)}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
size: 176,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[onDelete, onDownload, onRestore, canWrite, canRestore, canDownload],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["filename"], end: [] } },
|
||||
data: backups,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(backups.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [backups.length, pageSize, pageIndex, table])
|
||||
|
||||
if (backups.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<HardDriveIcon className="size-5" />}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
action={emptyAction}
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <DataGridShell table={table} recordCount={backups.length}
|
||||
pagination={backups.length > 50 || pageSize !== 50}
|
||||
tableLayout={{ ...DEFAULT_DATA_GRID_LAYOUT, columnsPinnable: true }}>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
}
|
||||
|
||||
export { BackupsDataGrid, type BackupsDataGridProps }
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
import type { BgpSessionRow } from "@/lib/bgp/types"
|
||||
import { fmtBgpNum } from "@/lib/bgp/helpers"
|
||||
|
||||
function BgpSessionDetail({ session }: { session: BgpSessionRow }) {
|
||||
return <div className="sticky left-0 w-[calc(100vw-2rem)] max-w-full md:static md:w-auto px-4 pb-4 pt-2 bg-muted/20 border-t border-border/60">
|
||||
{session.state === "Unknown" && <p role="status" className="mb-3 text-xs text-muted-foreground">Состояние сессии неизвестно. Значения относятся к последнему полученному снимку.</p>}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
{[
|
||||
{ label: "Router ID", value: session.routerId || "—" },
|
||||
{ label: "Hold / KA", value: (session.holdTime === null ? "—" : session.holdTime + "s") + " / " + (session.keepalive === null ? "—" : session.keepalive + "s") },
|
||||
{ label: "AFI/SAFI", value: session.afi },
|
||||
{ label: "Сообщения ↓/↑", value: fmtBgpNum(session.inputMessages) + " / " + fmtBgpNum(session.outputMessages) },
|
||||
{ label: "Префиксов получено", value: fmtBgpNum(session.prefixesRx) },
|
||||
{ label: "Префиксов отправлено", value: fmtBgpNum(session.prefixesTx) },
|
||||
{ label: "Активных маршрутов", value: session.prefixesActive === null ? "Нет данных API" : fmtBgpNum(session.prefixesActive) },
|
||||
].map(({ label, value }) => <div key={label}><p className="text-[10px] text-muted-foreground mb-0.5">{label}</p><p className="text-xs font-mono font-medium break-words">{value}</p></div>)}
|
||||
</div>
|
||||
{session.capabilities.length > 0 && <div className="mb-4"><p className="text-[10px] text-muted-foreground mb-1.5">Capabilities</p><div className="flex flex-wrap gap-1.5">{session.capabilities.map((cap, i) => <span key={i} className="rounded px-1.5 py-0.5 text-[10px] border bg-muted/60 text-muted-foreground">{cap}</span>)}</div></div>}
|
||||
{session.lastError && <p className="mb-4 rounded-md border border-destructive/20 px-3 py-2 text-xs font-mono text-destructive break-words">{session.lastError}</p>}
|
||||
<p className="text-xs text-muted-foreground">API возвращает состояние сессии. Конфигурация connection, routing-table и фильтров для экспорта здесь не получена.</p>
|
||||
</div>
|
||||
}
|
||||
export { BgpSessionDetail }
|
||||
@@ -0,0 +1,250 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo, useEffect } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { BgpSessionDetail } from "@/components/data-grids/bgp-session-detail"
|
||||
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
|
||||
import { BGP_AS_NAMES } from "@/lib/bgp/types"
|
||||
import { fmtBgpNum } from "@/lib/bgp/helpers"
|
||||
import { ChevronDownIcon, ChevronRightIcon, NetworkIcon } from "lucide-react"
|
||||
|
||||
const STATE_DOT: Record<BgpState, string> = {
|
||||
Unknown: "bg-muted-foreground", Established: "bg-success", Active: "bg-warning",
|
||||
Idle: "bg-muted-foreground", Connect: "bg-info", OpenSent: "bg-info", OpenConfirm: "bg-info",
|
||||
}
|
||||
|
||||
function StateBadge({ state }: { state: BgpState }) {
|
||||
return <Badge variant="invert-light" size="sm">
|
||||
<span aria-hidden="true" className={cn("size-1.5 rounded-full shrink-0", STATE_DOT[state])} />
|
||||
{state === "Unknown" ? "Неизвестно" : state}
|
||||
</Badge>
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: BgpType }) {
|
||||
return <Badge variant="invert-light" size="sm">{type === "Unknown" ? "Тип неизвестен" : type}</Badge>
|
||||
}
|
||||
|
||||
interface BgpSessionsDataGridProps {
|
||||
sessions: BgpSessionRow[]
|
||||
emptyTitle?: string
|
||||
selectedSessionId?: string | null
|
||||
onSelectSession?: (id: string | null) => void
|
||||
}
|
||||
|
||||
function BgpSessionsDataGrid({ sessions, emptyTitle, selectedSessionId, onSelectSession }: BgpSessionsDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, BgpSessionRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "serverLabel",
|
||||
accessorKey: "serverLabel",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Роутер" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const expanded = row.getIsExpanded()
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon-xs" aria-label={(expanded ? "Закрыть" : "Открыть") + " сессию " + row.original.serverLabel + " · " + row.original.peerIp} aria-expanded={expanded}
|
||||
onClick={event => { event.stopPropagation(); if (onSelectSession) onSelectSession(expanded ? null : row.id); else row.toggleExpanded() }}>
|
||||
{expanded ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
|
||||
</Button>
|
||||
<span className="font-mono whitespace-nowrap">{row.original.serverLabel}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Роутер",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
expandedContent: (row: BgpSessionRow) => <BgpSessionDetail session={row} />,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "peerIp",
|
||||
accessorKey: "peerIp",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Peer IP" />,
|
||||
cell: ({ row }) => <span className="font-mono">{row.original.peerIp}</span>,
|
||||
meta: {
|
||||
headerTitle: "Peer IP",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "remoteAs",
|
||||
accessorKey: "remoteAs",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Remote AS" />,
|
||||
cell: ({ row }) => {
|
||||
const as = row.original.remoteAs
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 font-mono">
|
||||
<span>{as === null ? "AS неизвестна" : "AS" + as}</span>
|
||||
{as !== null && BGP_AS_NAMES[as] && (
|
||||
<span className="text-muted-foreground text-[10px]">{BGP_AS_NAMES[as]}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Remote AS",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "description",
|
||||
accessorKey: "description",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Описание" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground max-w-[180px] truncate block">
|
||||
{row.original.description}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Описание",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => <TypeBadge type={row.original.type} />,
|
||||
meta: {
|
||||
headerTitle: "Тип",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
accessorKey: "state",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => <StateBadge state={row.original.state} />,
|
||||
meta: {
|
||||
headerTitle: "Состояние",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
accessorKey: "uptime",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Uptime" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-muted-foreground">
|
||||
{row.original.uptime ?? "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Uptime",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prefixesRx",
|
||||
accessorKey: "prefixesRx",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Prefixes ↓" />,
|
||||
cell: ({ row }) => {
|
||||
const n = row.original.prefixesRx
|
||||
return n !== null ? (
|
||||
<span className="font-mono tabular-nums text-right block">
|
||||
{fmtBgpNum(n)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Prefixes ↓",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prefixesTx",
|
||||
accessorKey: "prefixesTx",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Prefixes ↑" />,
|
||||
cell: ({ row }) => {
|
||||
const n = row.original.prefixesTx
|
||||
return n !== null ? (
|
||||
<span className="font-mono tabular-nums text-[var(--chart-tx)] text-right block">
|
||||
{fmtBgpNum(n)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Prefixes ↑",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
],
|
||||
[onSelectSession],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 } },
|
||||
data: sessions,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
getRowCanExpand: () => true,
|
||||
...(onSelectSession ? { state: { expanded: selectedSessionId ? { [selectedSessionId]: true } : {} } } : {}),
|
||||
})
|
||||
|
||||
const {pageIndex,pageSize}=table.state.pagination
|
||||
const sorting=table.state.sorting
|
||||
useEffect(()=>{
|
||||
const last=Math.max(0,Math.ceil(sessions.length/pageSize)-1)
|
||||
if(pageIndex>last)table.setPageIndex(last)
|
||||
},[sessions.length,pageSize,pageIndex,table])
|
||||
const setPageIndex = table.setPageIndex
|
||||
const selectedIndex = selectedSessionId ? table.getPrePaginatedRowModel().rows.findIndex(row => row.id === selectedSessionId) : -1
|
||||
useEffect(() => {
|
||||
if (selectedIndex >= 0) setPageIndex(Math.floor(selectedIndex / pageSize))
|
||||
}, [selectedSessionId, selectedIndex, pageSize, sorting, setPageIndex])
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<NetworkIcon className="size-4" />}
|
||||
title={emptyTitle ?? "Нет BGP-сессий"}
|
||||
description="Измените фильтры или проверьте подключение к роутерам"
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={sessions.length}
|
||||
pagination={sessions.length>50 || pageSize!==50}
|
||||
onRowClick={(row) => onSelectSession ? onSelectSession(row.id === selectedSessionId ? null : row.id) : table.getRow(row.id).toggleExpanded()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { BgpSessionsDataGrid, type BgpSessionsDataGridProps }
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import type { CertificateView } from "@/lib/certificate-view"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function daysLeftColor(days: number | null): string {
|
||||
if (days === null) return "text-muted-foreground"
|
||||
if (days <= 7) return "text-destructive"
|
||||
if (days <= 30) return "text-warning"
|
||||
return "text-success"
|
||||
}
|
||||
|
||||
export function CertDaysCell({ cert }: { cert: CertificateView }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-1 text-xs">
|
||||
<span className={cn("font-mono font-medium", daysLeftColor(cert.daysLeft))}>
|
||||
{cert.daysLeft === null
|
||||
? "Срок неизвестен"
|
||||
: cert.daysLeft < 0
|
||||
? "Истёк " + -cert.daysLeft + "д назад"
|
||||
: cert.daysLeft + "д осталось"}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px] break-all">
|
||||
{cert.validUntil ?? "Дата окончания неизвестна"}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import type { CertificateView } from "@/lib/certificate-view"
|
||||
|
||||
function CertificateExpandedDetail({ cert }: { cert: CertificateView }) {
|
||||
return (
|
||||
<div className="min-w-0 [overflow-wrap:anywhere] px-5 sm:px-10 pb-4 grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs border-t border-border/50 pt-3">
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Размер ключа</p>
|
||||
<p className="font-mono font-medium">{cert.keySize === null ? "Неизвестен" : cert.keySize + " бит"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Начало срока</p>
|
||||
<p className="font-mono">{cert.validFrom ?? "Дата неизвестна"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Дополнительные имена (SAN)</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{cert.sans.length > 0
|
||||
? cert.sans.map((s) => (
|
||||
<span key={s} className="font-mono bg-muted px-1.5 py-0.5 rounded">
|
||||
{s}
|
||||
</span>
|
||||
))
|
||||
: <span className="text-muted-foreground">—</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Доверие</p>
|
||||
<p className={cert.trusted === null ? "text-muted-foreground" : cert.trusted ? "text-success" : "text-destructive"}>
|
||||
{cert.trusted === null ? "Неизвестно" : cert.trusted ? "Да (доверенный)" : "Нет (не доверенный)"}
|
||||
</p>
|
||||
</div>
|
||||
{"rawValidFrom" in cert && !cert.validFrom && cert.rawValidFrom && <p className="col-span-2 sm:col-span-4 text-muted-foreground">Начало срока не удалось определить. Значение RouterOS: <span className="font-mono">{cert.rawValidFrom}</span></p>}
|
||||
{"rawValidUntil" in cert && !cert.validUntil && cert.rawValidUntil && <p className="col-span-2 sm:col-span-4 text-muted-foreground">Окончание срока не удалось определить. Значение RouterOS: <span className="font-mono">{cert.rawValidUntil}</span></p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { CertificateExpandedDetail }
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { renderToStaticMarkup } from "react-dom/server"
|
||||
import type { CertificateObserved } from "@mmapp/contracts/certificates"
|
||||
import { CertificateExpandedDetail } from "./certificate-expanded-detail"
|
||||
import { CertDaysCell } from "./certificate-days-cell"
|
||||
|
||||
const cert: CertificateObserved = {
|
||||
id: "1:*A", rosId: "*A", name: "unknown-cert", serverId: "1", commonName: "test.example",
|
||||
sans: [], issuedBy: "—", validFrom: null, validUntil: null, rawValidFrom: null,
|
||||
rawValidUntil: null, daysLeft: null, keySize: null, trusted: null, status: "unknown", usage: [],
|
||||
}
|
||||
|
||||
test("unknown observed fields are explicit and do not become zero or untrusted", () => {
|
||||
const details = renderToStaticMarkup(<CertificateExpandedDetail cert={cert} />)
|
||||
const days = renderToStaticMarkup(<CertDaysCell cert={cert} />)
|
||||
assert.match(details, /Неизвестен/)
|
||||
assert.match(details, /Дата неизвестна/)
|
||||
assert.match(details, /Неизвестно/)
|
||||
assert.ok(!details.includes("0 бит"))
|
||||
assert.ok(!details.includes("Нет (не доверенный)"))
|
||||
assert.match(days, /Срок неизвестен/)
|
||||
assert.match(days, /Дата окончания неизвестна/)
|
||||
assert.doesNotMatch(days, /0д осталось/)
|
||||
})
|
||||
|
||||
test("known false trust and zero remaining days are preserved", () => {
|
||||
const known = { ...cert, trusted: false, keySize: 2048, daysLeft: 0, validUntil: "2026-10-01T00:00:00Z" }
|
||||
const details = renderToStaticMarkup(<CertificateExpandedDetail cert={known} />)
|
||||
assert.ok(details.includes("Нет (не доверенный)"))
|
||||
assert.match(details, /2048 бит/)
|
||||
const days = renderToStaticMarkup(<CertDaysCell cert={known} />)
|
||||
assert.match(days, /0д осталось/)
|
||||
assert.match(days, /2026-10-01T00:00:00Z/)
|
||||
})
|
||||
|
||||
test("expired and positive observed days stay distinct", () => {
|
||||
assert.match(renderToStaticMarkup(<CertDaysCell cert={{ ...cert, daysLeft: -3 }} />), /Истёк 3д назад/)
|
||||
assert.match(renderToStaticMarkup(<CertDaysCell cert={{ ...cert, daysLeft: 40 }} />), /40д осталось/)
|
||||
})
|
||||
|
||||
test("unparsed RouterOS dates remain explicit raw values without claiming valid dates", () => {
|
||||
const details = renderToStaticMarkup(<CertificateExpandedDetail cert={{ ...cert, rawValidFrom: "oct/01/2026 12:00:00", rawValidUntil: "oct/01/2027 12:00:00" }} />)
|
||||
assert.match(details, /Начало срока не удалось определить/)
|
||||
assert.match(details, /Окончание срока не удалось определить/)
|
||||
assert.ok(details.includes("oct/01/2026 12:00:00"))
|
||||
})
|
||||
@@ -0,0 +1,275 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo, useEffect, type ReactNode } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Server } from "@/lib/data"
|
||||
import type { CertificateView } from "@/lib/certificate-view"
|
||||
import { CertDaysCell } from "@/components/data-grids/certificate-days-cell"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
DEFAULT_DATA_GRID_LAYOUT,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import {
|
||||
CertificateExpandedDetail,
|
||||
} from "@/components/data-grids/certificate-expanded-detail"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
BadgeCheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ServerIcon,
|
||||
ShieldAlertIcon,
|
||||
ShieldCheckIcon,
|
||||
ShieldOffIcon,
|
||||
CircleHelpIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
CertificateView["status"],
|
||||
{ label: string; icon: ReactNode; badge: string; row: string }
|
||||
> = {
|
||||
unknown: {
|
||||
label: "Неизвестен",
|
||||
icon: <CircleHelpIcon aria-hidden="true" className="size-4 text-muted-foreground" />,
|
||||
badge: "bg-muted text-muted-foreground border-border",
|
||||
row: "",
|
||||
},
|
||||
valid: {
|
||||
label: "Действителен",
|
||||
icon: <BadgeCheckIcon aria-hidden="true" className="size-4 text-success" />,
|
||||
badge: "bg-success/10 text-success border-success/20",
|
||||
row: "",
|
||||
},
|
||||
expired: {
|
||||
label: "Истёк",
|
||||
icon: <ShieldOffIcon aria-hidden="true" className="size-4 text-destructive" />,
|
||||
badge: "bg-destructive/10 text-destructive border-destructive/20",
|
||||
row: "bg-destructive/5",
|
||||
},
|
||||
revoked: {
|
||||
label: "Отозван",
|
||||
icon: <ShieldAlertIcon aria-hidden="true" className="size-4 text-warning" />,
|
||||
badge: "bg-warning/10 text-warning border-warning/20",
|
||||
row: "bg-warning/5",
|
||||
},
|
||||
}
|
||||
|
||||
interface CertificatesDataGridProps {
|
||||
certificates: CertificateView[]
|
||||
serverMap: Map<string, Server>
|
||||
isLoading?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}
|
||||
|
||||
function CertificatesDataGrid({ certificates, serverMap, isLoading, emptyTitle = "Нет сертификатов", emptyDescription = "Выпустите или импортируйте сертификат" }: CertificatesDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, CertificateView>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
size: 160,
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Сертификат" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const cert = row.original
|
||||
const cfg = STATUS_CONFIG[cert.status]
|
||||
const expanded = row.getIsExpanded()
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<Button type="button" variant="ghost" size="icon" className="size-11 sm:size-8 shrink-0" aria-label={"Детали сертификата " + cert.name} aria-expanded={expanded} onClick={event => { event.stopPropagation(); row.toggleExpanded() }}>
|
||||
{expanded ? (
|
||||
<ChevronDownIcon aria-hidden="true" className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRightIcon aria-hidden="true" className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="hidden sm:inline shrink-0">{cfg.icon}</span>
|
||||
<span className="font-medium text-sm truncate" title={cert.name}>
|
||||
{cert.name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono mt-0.5 truncate" title={cert.commonName}>
|
||||
{cert.commonName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Сертификат",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
expandedContent: (row: CertificateView) => <CertificateExpandedDetail cert={row} />,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "server",
|
||||
accessorKey: "serverId",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||
cell: ({ row }) => {
|
||||
const cert = row.original
|
||||
const server = serverMap.get(cert.serverId)
|
||||
return server ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
|
||||
<Flag code={server.country} size={12} />
|
||||
<span className="font-mono truncate">{server.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
|
||||
<ServerIcon className="size-3.5" />
|
||||
<span className="font-mono truncate">{cert.serverName ?? cert.serverId}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "issuedBy",
|
||||
accessorKey: "issuedBy",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Издатель" />,
|
||||
cell: ({ row }) => (
|
||||
<p className="text-xs text-muted-foreground truncate min-w-0" title={row.original.issuedBy}>
|
||||
{row.original.issuedBy}
|
||||
</p>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Издатель",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "daysLeft",
|
||||
accessorKey: "daysLeft",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Срок" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="min-w-0">
|
||||
<CertDaysCell cert={row.original} />
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Срок",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "usage",
|
||||
accessorKey: "usage",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Использование</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-wrap gap-1 min-w-0 overflow-hidden">
|
||||
{row.original.usage.map((u) => (
|
||||
<span
|
||||
key={u}
|
||||
className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground border"
|
||||
>
|
||||
{u}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Использование",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
size: 120,
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => {
|
||||
const cfg = STATUS_CONFIG[row.original.status]
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap",
|
||||
cfg.badge,
|
||||
)}
|
||||
>
|
||||
{cfg.label}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[serverMap],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
autoResetExpanded: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["name"], end: [] } },
|
||||
data: certificates,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
getRowCanExpand: () => true,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(certificates.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [certificates.length, pageSize, pageIndex, table])
|
||||
|
||||
if (!isLoading && certificates.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<ShieldCheckIcon className="size-4" />}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={certificates.length}
|
||||
tableLayout={{ ...DEFAULT_DATA_GRID_LAYOUT, columnsPinnable: true }}
|
||||
pagination={certificates.length > 50 || pageSize !== 50}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal">
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { CertificatesDataGrid, STATUS_CONFIG, type CertificatesDataGridProps }
|
||||
@@ -0,0 +1,301 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useEffect, useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
ServerIcon,
|
||||
TagIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export type CommunityType = "standard" | "no-export" | "no-advertise" | "local-as" | "custom"
|
||||
|
||||
export interface CommunityRow {
|
||||
id: string
|
||||
value: string
|
||||
name: string
|
||||
description: string
|
||||
type: CommunityType
|
||||
filterIds: string[]
|
||||
serverCount: number
|
||||
prefixCount: number | null
|
||||
action: "permit" | "deny" | "local-pref" | "metric"
|
||||
actionValue?: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<CommunityType, string> = {
|
||||
standard: "Стандартный",
|
||||
"no-export": "No-export",
|
||||
"no-advertise": "No-advertise",
|
||||
"local-as": "Local-AS",
|
||||
custom: "Кастомный",
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<CommunityRow["action"], string> = {
|
||||
permit: "Permit",
|
||||
deny: "Deny",
|
||||
"local-pref": "Local-pref",
|
||||
metric: "MED/Metric",
|
||||
}
|
||||
|
||||
const ACTION_COLOR: Record<CommunityRow["action"], string> = {
|
||||
permit: "text-emerald-500",
|
||||
deny: "text-red-500",
|
||||
"local-pref": "text-blue-500",
|
||||
metric: "text-amber-500",
|
||||
}
|
||||
|
||||
interface CommunitiesDataGridProps {
|
||||
communities: CommunityRow[]
|
||||
selectedId?: string | null
|
||||
copying?: boolean
|
||||
copiedValue?: string | null
|
||||
onSelect: (community: CommunityRow) => void
|
||||
onCopy: (value: string) => void
|
||||
}
|
||||
|
||||
function CommunitiesDataGrid({
|
||||
communities,
|
||||
selectedId,
|
||||
copiedValue,
|
||||
copying = false,
|
||||
onSelect,
|
||||
onCopy,
|
||||
}: CommunitiesDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, CommunityRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "value",
|
||||
size: 192,
|
||||
accessorKey: "value",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Community" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const c = row.original
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
data-community-disabled={!c.enabled ? true : undefined}
|
||||
>
|
||||
<TagIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-xs font-medium break-all bg-muted px-1.5 py-0.5 rounded">
|
||||
{c.value}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onCopy(c.value)
|
||||
}}
|
||||
className="size-11 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:bg-muted"
|
||||
aria-label={`Скопировать community: ${c.value}`}
|
||||
disabled={copying}
|
||||
>
|
||||
{copiedValue === c.value ? (
|
||||
<CheckIcon className="size-3" />
|
||||
) : (
|
||||
<CopyIcon className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Community",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
size: 240,
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Имя / описание" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-xs">{row.original.name}</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">{row.original.description}</p>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Имя / описание",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
size: 128,
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{TYPE_LABELS[row.original.type]}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Тип",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
size: 128,
|
||||
accessorKey: "action",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Действие" />,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original
|
||||
return (
|
||||
<span className={cn("text-xs font-medium", ACTION_COLOR[c.action])}>
|
||||
{ACTION_LABELS[c.action]}
|
||||
{c.actionValue !== undefined ? ` ${c.actionValue}` : ""}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Действие",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prefixCount",
|
||||
size: 112,
|
||||
accessorKey: "prefixCount",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Маршрутов" className="ml-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs tabular-nums text-right block">
|
||||
{row.original.prefixCount?.toLocaleString("ru-RU") ?? "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Маршрутов",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "serverCount",
|
||||
size: 104,
|
||||
accessorKey: "serverCount",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Серверов" className="ml-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<ServerIcon className="size-3 text-muted-foreground" />
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{row.original.serverCount.toLocaleString("ru-RU")}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Серверов",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "chevron",
|
||||
header: () => <span className="sr-only">Детали</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<Button type="button" variant="ghost" size="icon" className="size-11" aria-label={`Детали community: ${row.original.value}`} onClick={event => { event.stopPropagation(); onSelect(row.original) }}>
|
||||
<ChevronRightIcon aria-hidden="true" className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
),
|
||||
size: 64,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[copiedValue, copying, onCopy, onSelect],
|
||||
)
|
||||
|
||||
const rowSelection = useMemo(
|
||||
() => (selectedId ? { [selectedId]: true as const } : {}),
|
||||
[selectedId],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["value"], end: [] } },
|
||||
data: communities,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
enableRowSelection: true,
|
||||
state: { rowSelection },
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(communities.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [communities.length, pageIndex, pageSize, table])
|
||||
|
||||
if (communities.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<TagIcon className="size-4" />}
|
||||
title="Ничего не найдено"
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={communities.length}
|
||||
pagination={communities.length > 50 || pageSize !== 50}
|
||||
tableLayout={{ dense: true, columnsPinnable: true }}
|
||||
onRowClick={(row) => onSelect(row)}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn(
|
||||
"group/row",
|
||||
"data-[state=selected]:bg-primary/5",
|
||||
"[&:has([data-community-disabled=true])]:opacity-50",
|
||||
),
|
||||
}}
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
CommunitiesDataGrid,
|
||||
type CommunitiesDataGridProps,
|
||||
TYPE_LABELS,
|
||||
ACTION_LABELS,
|
||||
ACTION_COLOR,
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo, useEffect, type ReactNode, type CSSProperties } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
DEFAULT_DATA_GRID_LAYOUT,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { InboxIcon } from "lucide-react"
|
||||
|
||||
export interface CompactDataGridColumn<T> {
|
||||
id: string
|
||||
header: string
|
||||
accessorKey?: keyof T & string
|
||||
cell?: (row: T) => ReactNode
|
||||
enableSorting?: boolean
|
||||
size?: number
|
||||
headerClassName?: string
|
||||
cellClassName?: string
|
||||
}
|
||||
|
||||
interface CompactDataGridProps<T extends { id: string }> {
|
||||
data: T[]
|
||||
columns: CompactDataGridColumn<T>[]
|
||||
isLoading?: boolean
|
||||
horizontalScroll?: boolean
|
||||
pinnedColumnId?: string
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
onRowClick?: (row: T) => void
|
||||
getExpandedContent?: (row: T) => ReactNode
|
||||
}
|
||||
|
||||
function CompactDataGrid<T extends { id: string }>({
|
||||
data,
|
||||
columns,
|
||||
isLoading,
|
||||
horizontalScroll = false,
|
||||
pinnedColumnId,
|
||||
emptyTitle = "Нет записей",
|
||||
emptyDescription,
|
||||
onRowClick,
|
||||
getExpandedContent,
|
||||
}: CompactDataGridProps<T>) {
|
||||
const columnDefs = useMemo<ColumnDef<DataGridFeatures, T>[]>(
|
||||
() =>
|
||||
columns.map((col, index) => ({
|
||||
id: col.id,
|
||||
size: col.size,
|
||||
accessorKey: col.accessorKey ?? col.id,
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title={col.header} className={index === 0 ? "ml-1" : undefined} />
|
||||
),
|
||||
cell: ({ row }) => (col.cell ? col.cell(row.original) : String(row.getValue(col.id) ?? "—")),
|
||||
enableSorting: col.enableSorting !== false,
|
||||
meta: {
|
||||
headerTitle: col.header,
|
||||
headerClassName:
|
||||
col.headerClassName ??
|
||||
(index === 0
|
||||
? DATA_GRID_CELL_PAD_FIRST
|
||||
: index === columns.length - 1
|
||||
? DATA_GRID_CELL_PAD_LAST
|
||||
: DATA_GRID_CELL_PAD),
|
||||
cellClassName:
|
||||
col.cellClassName ??
|
||||
(index === 0
|
||||
? DATA_GRID_CELL_PAD_FIRST
|
||||
: index === columns.length - 1
|
||||
? DATA_GRID_CELL_PAD_LAST
|
||||
: DATA_GRID_CELL_PAD),
|
||||
...(getExpandedContent && index === 0
|
||||
? { expandedContent: getExpandedContent }
|
||||
: {}),
|
||||
},
|
||||
})),
|
||||
[columns, getExpandedContent],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: pinnedColumnId ? [pinnedColumnId] : [], end: [] } },
|
||||
data,
|
||||
columns: columnDefs,
|
||||
getRowId: (row) => row.id,
|
||||
...(getExpandedContent ? { getRowCanExpand: () => true } : {}),
|
||||
})
|
||||
|
||||
const {pageIndex,pageSize}=table.state.pagination
|
||||
useEffect(()=>{
|
||||
const last=Math.max(0,Math.ceil(data.length/pageSize)-1)
|
||||
if(pageIndex>last)table.setPageIndex(last)
|
||||
},[data.length,pageSize,pageIndex,table])
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={data.length}
|
||||
tableLayout={pinnedColumnId ? { ...DEFAULT_DATA_GRID_LAYOUT, columnsPinnable: true } : undefined}
|
||||
pagination={data.length > 50 || table.state.pagination.pageSize !== 50}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
onRowClick={
|
||||
onRowClick ??
|
||||
(getExpandedContent
|
||||
? (row) => table.getRow(row.id).toggleExpanded()
|
||||
: undefined)
|
||||
}
|
||||
emptyMessage={
|
||||
<EmptyState
|
||||
icon={<InboxIcon className="size-4" />}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{horizontalScroll ? (
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea
|
||||
orientation="horizontal"
|
||||
className={pinnedColumnId ? "[&_[data-slot=scroll-area-viewport]]:[scroll-padding-inline-start:var(--pinned-column-width)]" : undefined}
|
||||
style={pinnedColumnId ? { "--pinned-column-width": `${table.getColumn(pinnedColumnId)?.getSize() ?? 0}px` } as CSSProperties : undefined}
|
||||
>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
) : undefined}
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { CompactDataGrid, type CompactDataGridProps }
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import type { Server, ServerType } from "@/lib/data"
|
||||
import type {DashboardProbe} from "@/lib/dashboard-probes"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
import { Sparkline } from "@/components/sparkline"
|
||||
import { CompactDataGrid, type CompactDataGridColumn } from "@/components/data-grids/compact-data-grid"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FilterIcon } from "lucide-react"
|
||||
|
||||
function formatLossPct(loss: number|null): string {
|
||||
if (loss===null || !Number.isFinite(loss)) return "—"
|
||||
return Number.isInteger(loss) ? `${loss}%` : `${loss.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function TypeChip({ type }: { type: ServerType }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
|
||||
type === "home-router"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: type === "jump-host"
|
||||
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
|
||||
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
)}
|
||||
>
|
||||
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ProbeSourceCell({ probe, catalog }: { probe: DashboardProbe; catalog: Server[] }) {
|
||||
const srv = catalog.find((s) => s.id === probe.srcServerId)
|
||||
const iface = (probe.srcInterface ?? "").trim() || "auto"
|
||||
|
||||
if (!srv) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
|
||||
<span className="mt-1 shrink-0 inline-flex">
|
||||
<StatusDot status="unknown" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-medium text-muted-foreground truncate">
|
||||
Сервер <span className="font-mono tabular-nums">{probe.srcServerId}</span>
|
||||
</p>
|
||||
<p className="text-[11px] font-mono text-muted-foreground truncate mt-0.5" title={iface}>
|
||||
{iface}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
|
||||
<span className="mt-1 shrink-0 inline-flex">
|
||||
<StatusDot status={srv.status} pulse={srv.status === "online"} />
|
||||
</span>
|
||||
<div className="flex gap-2 min-w-0 flex-1">
|
||||
<Flag code={srv.country} size={16} className="shrink-0 mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[13px] font-medium leading-tight truncate">{srv.name}</span>
|
||||
<TypeChip type={srv.type} />
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 truncate" title={`Интерфейс: ${iface}`}>
|
||||
<span className="font-mono tabular-nums">{iface}</span>
|
||||
{srv.site && srv.site !== "—" && (
|
||||
<span className="text-muted-foreground/90"> · {srv.site}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DashboardActiveProbesDataGridProps {
|
||||
probes: DashboardProbe[]
|
||||
catalog: Server[]
|
||||
isLoading?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}
|
||||
|
||||
function DashboardActiveProbesDataGrid({
|
||||
probes,
|
||||
catalog,
|
||||
isLoading,
|
||||
emptyTitle = "Нет активных проб",
|
||||
emptyDescription,
|
||||
}: DashboardActiveProbesDataGridProps) {
|
||||
const columns = useMemo<CompactDataGridColumn<DashboardProbe>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "source",
|
||||
header: "Источник",
|
||||
enableSorting: false,
|
||||
cell: (p) => <ProbeSourceCell probe={p} catalog={catalog} />,
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
header: "Проба",
|
||||
accessorKey: "name",
|
||||
cell: (p) => <span className="font-medium">{p.name}</span>,
|
||||
},
|
||||
{
|
||||
id: "target",
|
||||
header: "Цель",
|
||||
accessorKey: "target",
|
||||
cell: (p) => <span className="font-mono text-xs text-muted-foreground">{p.target}</span>,
|
||||
},
|
||||
{
|
||||
id: "filter",
|
||||
header: "Фильтр",
|
||||
accessorKey: "filter",
|
||||
enableSorting: false,
|
||||
cell: (p) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs border border-border rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />
|
||||
{p.filter}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "rtt",
|
||||
header: "RTT",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "font-mono text-right",
|
||||
cell: (p) => (p.rtt == null ? "—" : `${p.rtt} мс`),
|
||||
},
|
||||
{
|
||||
id: "loss",
|
||||
header: "Потери",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "font-mono text-right",
|
||||
cell: (p) => (
|
||||
<span
|
||||
className={cn(
|
||||
(p.loss??0) > 5 ? "text-red-500" : (p.loss??0) > 0 ? "text-amber-500" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{formatLossPct(p.loss)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "series",
|
||||
header: "RTT · 1 ч",
|
||||
enableSorting: false,
|
||||
headerClassName: "w-36",
|
||||
cell: (p) => {
|
||||
const sparkColor =
|
||||
p.status === "down"
|
||||
? "hsl(0 84% 60%)"
|
||||
: p.status === "warn"
|
||||
? "hsl(32 94% 44%)"
|
||||
: "hsl(142 76% 36%)"
|
||||
return <Sparkline data={p.series} width={120} height={24} color={sparkColor} />
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Статус",
|
||||
enableSorting: false,
|
||||
cell: (p) => (
|
||||
<span title={p.observationError ?? undefined}><StatusBadge
|
||||
status={p.status === "unknown" ? "unknown" : p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
|
||||
/></span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[catalog],
|
||||
)
|
||||
|
||||
return (
|
||||
<CompactDataGrid
|
||||
data={probes}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
emptyTitle={emptyTitle}
|
||||
emptyDescription={emptyDescription}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { DashboardActiveProbesDataGrid, type DashboardActiveProbesDataGridProps }
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { SchedulerJobStatusDto } from "@/lib/scheduler-settings"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { RefreshCwIcon } from "lucide-react"
|
||||
|
||||
export interface SchedulerJobGridRow {
|
||||
id: string
|
||||
jobKey: string
|
||||
label: string
|
||||
description: string
|
||||
fixedSchedule: boolean
|
||||
enabled: boolean
|
||||
settingsKnown: boolean
|
||||
intervalValue: string
|
||||
intervalReadOnly: boolean
|
||||
intervalDisabled: boolean
|
||||
defaultInterval: number
|
||||
job?: SchedulerJobStatusDto
|
||||
onEnabledChange?: (enabled: boolean) => void
|
||||
onIntervalChange: (value: string) => void
|
||||
onRunNow: () => void
|
||||
runNowDisabled: boolean
|
||||
runNowLoading: boolean
|
||||
saveBusy: boolean
|
||||
}
|
||||
|
||||
interface DataCollectionSchedulerDataGridProps {
|
||||
rows: SchedulerJobGridRow[]
|
||||
}
|
||||
|
||||
function DataCollectionSchedulerDataGrid({ rows }: DataCollectionSchedulerDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, SchedulerJobGridRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "task",
|
||||
accessorKey: "label",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Задача</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="align-top">
|
||||
<span className="font-medium text-sm">{row.original.label}</span>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 leading-snug">
|
||||
{row.original.description}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground font-mono mt-1">{row.original.jobKey}</p>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD_FIRST, "align-top"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground text-center block">Вкл</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const { fixedSchedule, enabled, saveBusy, onEnabledChange, settingsKnown } = row.original
|
||||
if (!settingsKnown) return <span className="text-muted-foreground" aria-label="Настройки не получены">—</span>
|
||||
return (
|
||||
<div className="text-center align-top">
|
||||
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
|
||||
<FormToggle
|
||||
ariaLabel={`Включить: ${row.original.label}`}
|
||||
checked={enabled}
|
||||
disabled={fixedSchedule || saveBusy}
|
||||
onChange={(v) => {
|
||||
if (fixedSchedule || saveBusy) return
|
||||
onEnabledChange?.(v)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 56,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: cn(DATA_GRID_CELL_PAD, "align-top") },
|
||||
},
|
||||
{
|
||||
id: "interval",
|
||||
accessorKey: "intervalValue",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Интервал (с)</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<div className="w-28 align-top">
|
||||
<Input
|
||||
value={r.intervalValue}
|
||||
onChange={(e) => r.onIntervalChange(e.target.value)}
|
||||
className="h-8 text-sm tabular-nums"
|
||||
inputMode="numeric"
|
||||
readOnly={r.intervalReadOnly}
|
||||
disabled={r.intervalDisabled}
|
||||
placeholder={r.settingsKnown ? String(r.defaultInterval) : "—"}
|
||||
aria-label={`Интервал: ${r.label}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: cn(DATA_GRID_CELL_PAD, "align-top") },
|
||||
},
|
||||
{
|
||||
id: "lastRun",
|
||||
accessorFn: (row) => row.job?.lastFinishedAt ?? "",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Последний прогон</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const j = row.original.job
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground align-top">
|
||||
{j?.lastFinishedAt ? new Date(j.lastFinishedAt).toLocaleString("ru-RU") : "—"}
|
||||
{j?.lastDurationMs != null && (
|
||||
<span className="block text-[11px]">{j.lastDurationMs} мс</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: cn(DATA_GRID_CELL_PAD, "align-top") },
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorFn: (row) => row.job?.lastStatus ?? "",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Статус</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const j = row.original.job
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5 align-top">
|
||||
{j?.running ? (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
выполняется
|
||||
</Badge>
|
||||
) : null}
|
||||
{j?.lastStatus ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
j.lastStatus === "ok" && "border-emerald-500/40 text-emerald-700 dark:text-emerald-400",
|
||||
j.lastStatus === "error" && "border-destructive/50 text-destructive",
|
||||
)}
|
||||
>
|
||||
{j.lastStatus}
|
||||
</Badge>
|
||||
) : null}
|
||||
{j?.lastError ? (
|
||||
<span className="text-[10px] text-destructive max-w-[200px] truncate block" title={j.lastError}>
|
||||
{j.lastError}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: cn(DATA_GRID_CELL_PAD, "align-top") },
|
||||
},
|
||||
{
|
||||
id: "runNow",
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground text-right block">Сейчас</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<div className="text-right align-top">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
disabled={r.runNowDisabled || r.runNowLoading}
|
||||
onClick={r.onRunNow}
|
||||
aria-label={`Запустить: ${r.label}`}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-3.5", r.runNowLoading && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD_LAST, "align-top"),
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
data: rows,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rows.length}
|
||||
tableClassNames={{ bodyRow: "group/row hover:bg-muted/40 text-sm" }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataCollectionSchedulerDataGrid, type DataCollectionSchedulerDataGridProps }
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Domain } from "@/lib/data"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { FilterIcon, GlobeIcon } from "lucide-react"
|
||||
|
||||
interface DomainsDataGridProps {
|
||||
domains: Domain[]
|
||||
isLoading?: boolean
|
||||
emptyDescription?: string
|
||||
pagination?: boolean
|
||||
}
|
||||
|
||||
function DomainsDataGrid({ domains, isLoading, pagination = false, emptyDescription }: DomainsDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, Domain>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "domain",
|
||||
accessorKey: "domain",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Домен" className="ml-1" />,
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.domain}</span>,
|
||||
meta: {
|
||||
headerTitle: "Домен",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "resolvedIp",
|
||||
accessorKey: "resolvedIp",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Resolved IP" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.resolvedIp}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Resolved IP",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "asn",
|
||||
accessorKey: "asn",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="ASN" />,
|
||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.asn}</span>,
|
||||
meta: {
|
||||
headerTitle: "ASN",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "purpose",
|
||||
accessorKey: "purpose",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs border border-border rounded px-2 py-0.5">{row.original.purpose}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Назначение",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "filter",
|
||||
accessorKey: "filter",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Фильтр" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />
|
||||
{row.original.filter}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Фильтр",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
accessorKey: "updated",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Обновлён" />,
|
||||
cell: ({ row }) => <span className="text-xs text-muted-foreground">{row.original.updated}</span>,
|
||||
meta: {
|
||||
headerTitle: "Обновлён",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={`text-xs font-medium ${row.original.enabled ? "text-emerald-600" : "text-muted-foreground"}`}
|
||||
>
|
||||
{row.original.enabled ? "Активен" : "Отключён"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: !pagination,
|
||||
data: domains,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={domains.length}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
pagination={pagination}
|
||||
emptyMessage={
|
||||
<EmptyState
|
||||
icon={<GlobeIcon className="size-4" />}
|
||||
title="Нет доменов"
|
||||
description={emptyDescription ?? "Добавьте домены или импортируйте каталог"}
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { DomainsDataGrid, type DomainsDataGridProps }
|
||||
@@ -0,0 +1,406 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo } from "react"
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { FilterRule, GreTunnel, Server } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
PencilIcon,
|
||||
RouteIcon,
|
||||
StarIcon,
|
||||
TrashIcon,
|
||||
FilterIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export interface RecursiveRouteLite {
|
||||
id: string
|
||||
dstAddress: string
|
||||
gateway: string
|
||||
distance: number
|
||||
routingTable: string
|
||||
comment: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
function innerIpToGateway(ip: string) {
|
||||
return ip.split("/")[0]
|
||||
}
|
||||
|
||||
function gatewayFromRecursiveDst(dstAddress: string) {
|
||||
return innerIpToGateway((dstAddress ?? "").trim())
|
||||
}
|
||||
|
||||
function pickRecursiveRouteByGatewayHop(routes: RecursiveRouteLite[], hopIp: string) {
|
||||
const hop = hopIp.trim()
|
||||
if (!hop) return undefined
|
||||
const candidates = routes.filter(
|
||||
(r) => !r.disabled && gatewayFromRecursiveDst(r.dstAddress) === hop,
|
||||
)
|
||||
if (candidates.length === 0) return undefined
|
||||
return candidates.reduce((a, b) => (a.distance <= b.distance ? a : b))
|
||||
}
|
||||
|
||||
function isRecursiveGatewayRef(ref: string) {
|
||||
return ref.startsWith("rec:")
|
||||
}
|
||||
|
||||
function FilterGatewayCell({
|
||||
rule,
|
||||
tunnelsList,
|
||||
serversList,
|
||||
recursiveRoutes,
|
||||
}: {
|
||||
rule: FilterRule
|
||||
tunnelsList: GreTunnel[]
|
||||
serversList: Server[]
|
||||
recursiveRoutes: RecursiveRouteLite[]
|
||||
}) {
|
||||
const isBlackhole = rule.action === "blackhole"
|
||||
const isRecRef = !isBlackhole && isRecursiveGatewayRef(rule.gatewayTunnelId)
|
||||
const recRowByRef = isRecRef
|
||||
? recursiveRoutes.find((r) => r.id === rule.gatewayTunnelId.slice(4))
|
||||
: undefined
|
||||
const recRowByHop =
|
||||
!isBlackhole && !recRowByRef && (isRecRef || !(rule.gatewayTunnelId ?? "").trim()) && rule.gateway.trim()
|
||||
? pickRecursiveRouteByGatewayHop(recursiveRoutes, rule.gateway)
|
||||
: undefined
|
||||
const recRow = recRowByRef ?? recRowByHop
|
||||
const treatAsRecursive = !isBlackhole && (isRecRef || !!recRowByHop)
|
||||
const tunnel =
|
||||
!isBlackhole && !treatAsRecursive
|
||||
? tunnelsList.find((t) => t.id === rule.gatewayTunnelId)
|
||||
: undefined
|
||||
const remoteSrv = tunnel ? serversList.find((s) => s.host === tunnel.remoteAddress) : undefined
|
||||
|
||||
if (isBlackhole) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-1.5 rounded-full shrink-0 bg-red-500 animate-pulse" />
|
||||
<span className="font-mono text-xs font-medium text-red-600 dark:text-red-400">type=blackhole</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full shrink-0",
|
||||
treatAsRecursive
|
||||
? "bg-sky-500"
|
||||
: tunnel?.status === "up"
|
||||
? "bg-[var(--status-online)]"
|
||||
: tunnel?.status === "degraded"
|
||||
? "bg-[var(--status-degraded)]"
|
||||
: "bg-[var(--status-offline)]",
|
||||
)}
|
||||
/>
|
||||
{treatAsRecursive ? (
|
||||
<>
|
||||
<RouteIcon className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-xs font-medium">
|
||||
{recRow ? gatewayFromRecursiveDst(recRow.dstAddress) : rule.gateway}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-muted-foreground border border-border rounded px-1 uppercase tracking-wide">
|
||||
recursive
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{remoteSrv && <Flag code={remoteSrv.country} />}
|
||||
<span className="font-mono text-xs font-medium">{rule.gateway}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{treatAsRecursive ? (
|
||||
recRow ? (
|
||||
<p className="text-[11px] text-muted-foreground truncate pl-3">{recRow.dstAddress}</p>
|
||||
) : isRecRef ? (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400 truncate pl-3">
|
||||
рекурсивный маршрут (нет строки в списке)
|
||||
</p>
|
||||
) : null
|
||||
) : tunnel ? (
|
||||
<p className="text-[11px] text-muted-foreground truncate pl-3">{tunnel.name}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterRowActions({
|
||||
disabled,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
disabled: boolean
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="size-11 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onEdit}
|
||||
disabled={disabled}
|
||||
aria-label="Редактировать правило"
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="size-11 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
disabled={disabled}
|
||||
aria-label="Удалить правило"
|
||||
>
|
||||
<TrashIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FiltersDataGridProps {
|
||||
rules: FilterRule[]
|
||||
orderedRules?: FilterRule[]
|
||||
mutationsLocked?: boolean
|
||||
tunnelsList: GreTunnel[]
|
||||
serversList: Server[]
|
||||
communityNameMap: Record<string, string>
|
||||
recursiveRoutes: RecursiveRouteLite[]
|
||||
enableSorting?: boolean
|
||||
onEdit: (rule: FilterRule) => void
|
||||
onDelete: (id: string) => void
|
||||
onMoveUp: (id: string) => void
|
||||
onMoveDown: (id: string) => void
|
||||
}
|
||||
|
||||
function FiltersDataGrid({
|
||||
rules,
|
||||
orderedRules = rules,
|
||||
mutationsLocked = false,
|
||||
tunnelsList,
|
||||
serversList,
|
||||
communityNameMap,
|
||||
recursiveRoutes,
|
||||
enableSorting = false,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
}: FiltersDataGridProps) {
|
||||
const orderedIndex = useMemo(() => new Map(orderedRules.map((rule, index) => [rule.id, index])), [orderedRules])
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, FilterRule>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "priority",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">#</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const index = orderedIndex.get(row.original.id) ?? -1
|
||||
const isLast = index === orderedRules.length - 1
|
||||
return (
|
||||
<div className="flex items-center justify-center text-[11px] font-mono text-muted-foreground/40">
|
||||
{isLast ? (
|
||||
<StarIcon className="size-3 text-amber-400 fill-amber-400" aria-label="Наивысший приоритет" />
|
||||
) : (
|
||||
<span>{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 32,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||
},
|
||||
{
|
||||
id: "reorder",
|
||||
header: () => <span className="sr-only">Порядок</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const index = orderedIndex.get(row.original.id) ?? -1
|
||||
const isLast = index === orderedRules.length - 1
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMoveUp(row.original.id)}
|
||||
aria-label={`Поднять правило ${row.original.community}`}
|
||||
disabled={mutationsLocked || index <= 0}
|
||||
className="size-11 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:bg-muted disabled:opacity-40"
|
||||
>
|
||||
<ChevronUpIcon className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMoveDown(row.original.id)}
|
||||
aria-label={`Опустить правило ${row.original.community}`}
|
||||
disabled={mutationsLocked || index < 0 || isLast}
|
||||
className="size-11 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:bg-muted disabled:opacity-40"
|
||||
>
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 104,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "community",
|
||||
size: 120,
|
||||
accessorKey: "community",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Community" />,
|
||||
enableSorting,
|
||||
cell: ({ row }) => {
|
||||
const rule = row.original
|
||||
const isBlackhole = rule.action === "blackhole"
|
||||
const communityName = communityNameMap[rule.community] ?? rule.communityName
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border font-mono",
|
||||
isBlackhole
|
||||
? "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/25"
|
||||
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
)}
|
||||
>
|
||||
{rule.community}
|
||||
</span>
|
||||
{isBlackhole && (
|
||||
<span className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold border bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20 uppercase">
|
||||
⊘ blackhole
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{communityName && (
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">{communityName}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: "Community", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "gateway",
|
||||
size: 192,
|
||||
accessorKey: "gateway",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Gateway" />,
|
||||
enableSorting,
|
||||
cell: ({ row }) => (
|
||||
<FilterGatewayCell
|
||||
rule={row.original}
|
||||
tunnelsList={tunnelsList}
|
||||
serversList={serversList}
|
||||
recursiveRoutes={recursiveRoutes}
|
||||
/>
|
||||
),
|
||||
meta: { headerTitle: "Gateway", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "description",
|
||||
size: 240,
|
||||
accessorKey: "description",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Описание" />,
|
||||
enableSorting,
|
||||
cell: ({ row }) => (
|
||||
<p className="text-xs text-muted-foreground truncate">{row.original.description || "—"}</p>
|
||||
),
|
||||
meta: { headerTitle: "Описание", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<FilterRowActions
|
||||
disabled={mutationsLocked}
|
||||
onEdit={() => onEdit(row.original)}
|
||||
onDelete={() => onDelete(row.original.id)}
|
||||
/>
|
||||
),
|
||||
size: 112,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[
|
||||
communityNameMap,
|
||||
orderedRules,
|
||||
orderedIndex,
|
||||
mutationsLocked,
|
||||
enableSorting,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onMoveDown,
|
||||
onMoveUp,
|
||||
recursiveRoutes,
|
||||
serversList,
|
||||
tunnelsList,
|
||||
],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["priority", "community"], end: [] } },
|
||||
manualSorting: !enableSorting,
|
||||
data: rules,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(rules.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [rules.length, pageIndex, pageSize, table])
|
||||
|
||||
if (rules.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<FilterIcon className="size-4" />}
|
||||
title="Ничего не найдено"
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataGridShell table={table} recordCount={rules.length} pagination={rules.length > 50 || pageSize !== 50} tableLayout={{ dense: true, columnsPinnable: true }}>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
<div className="px-5 py-2 text-[11px] text-muted-foreground/40 flex items-center gap-1.5 border-t">
|
||||
<StarIcon className="size-3 text-amber-400 fill-amber-400 shrink-0" />
|
||||
Последнее правило имеет наивысший приоритет в RouterOS
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export { FiltersDataGrid, type FiltersDataGridProps }
|
||||
@@ -0,0 +1,407 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useEffect, useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { FirewallRule } from "@/lib/data"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
DATA_GRID_CONTAINER_CLASS,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, DataGridTableDndRowHandle, DataGridTableDndRows } from "@/components/reui/data-grid"
|
||||
import type { DragEndEvent } from "@dnd-kit/core"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
CopyIcon,
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
MoreHorizontalIcon,
|
||||
PencilIcon,
|
||||
PowerIcon,
|
||||
ShieldOffIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
const ACTION_STYLES: Record<string, string> = {
|
||||
accept: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
|
||||
drop: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||||
reject: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||||
masquerade: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
|
||||
"mark-routing": "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
|
||||
"mark-conn": "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
|
||||
"fasttrack-connection": "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
|
||||
"dst-nat": "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
"src-nat": "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
"add-src-to-address-list": "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20",
|
||||
}
|
||||
|
||||
const CHAIN_STYLES: Record<string, string> = {
|
||||
forward: "bg-foreground/5 text-foreground/70",
|
||||
input: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
|
||||
output: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||
srcnat: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
||||
dstnat: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
||||
prerouting: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
|
||||
postrouting: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
|
||||
"ip6-input": "bg-violet-500/10 text-violet-600 dark:text-violet-400",
|
||||
"ip6-forward": "bg-foreground/5 text-foreground/70",
|
||||
"ip6-output": "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||
}
|
||||
|
||||
function fmtHits(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}М`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}к`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function ActionBadge({ action }: { action: string }) {
|
||||
const cls = ACTION_STYLES[action] ?? "bg-muted text-muted-foreground border-border"
|
||||
return (
|
||||
<span className={cn("text-[11px] font-mono font-medium px-2 py-0.5 rounded border whitespace-nowrap", cls)}>
|
||||
{action}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ChainBadge({ chain }: { chain: string }) {
|
||||
const cls = CHAIN_STYLES[chain] ?? "bg-muted text-muted-foreground"
|
||||
return (
|
||||
<span className={cn("text-[11px] font-mono px-2 py-0.5 rounded", cls)}>
|
||||
{chain}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface FirewallRulesDataGridProps {
|
||||
rules: FirewallRule[]
|
||||
mutationsDisabled?: boolean
|
||||
onToggle: (id: string) => void
|
||||
onEdit: (rule: FirewallRule) => void
|
||||
onDelete?: (rule: FirewallRule) => void
|
||||
onReorder?: (activeId: string, overId: string) => void
|
||||
showServer?: boolean
|
||||
}
|
||||
|
||||
function FirewallRulesDataGrid({
|
||||
rules,
|
||||
mutationsDisabled = false,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReorder,
|
||||
showServer = false,
|
||||
}: FirewallRulesDataGridProps) {
|
||||
const indexedRules = useMemo(
|
||||
() => rules.map((rule, index) => ({ ...rule, _index: index + 1 })),
|
||||
[rules],
|
||||
)
|
||||
const neighbours = useMemo(() => {
|
||||
const groups = new Map<string, FirewallRule[]>()
|
||||
for (const rule of rules) {
|
||||
const key = [rule.serverId, rule.family, rule.table].join(":")
|
||||
const group = groups.get(key) ?? []
|
||||
group.push(rule); groups.set(key, group)
|
||||
}
|
||||
const result = new Map<string, { up?: string; down?: string }>()
|
||||
for (const group of groups.values()) group.forEach((rule, index) => result.set(rule.id, { up: group[index - 1]?.id, down: group[index + 1]?.id }))
|
||||
return result
|
||||
}, [rules])
|
||||
const reorderable = Boolean(onReorder)
|
||||
const indexPad = reorderable ? DATA_GRID_CELL_PAD : DATA_GRID_CELL_PAD_FIRST
|
||||
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, FirewallRule & { _index: number }>[]>(
|
||||
() => [
|
||||
...(reorderable
|
||||
? [{
|
||||
id: "drag",
|
||||
header: () => <span className="sr-only">Порядок</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const targets = neighbours.get(row.original.id)
|
||||
return <div className="flex items-center gap-1">
|
||||
<DataGridTableDndRowHandle className="size-11 shrink-0" />
|
||||
<Button type="button" variant="ghost" size="icon" className="size-11 shrink-0" aria-label={`Поднять правило ${row.original.rosId || row.original.id}`} disabled={!targets?.up || mutationsDisabled} onClick={() => targets?.up && onReorder?.(row.original.id, targets.up)}><ChevronUpIcon /></Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-11 shrink-0" aria-label={`Опустить правило ${row.original.rosId || row.original.id}`} disabled={!targets?.down || mutationsDisabled} onClick={() => targets?.down && onReorder?.(row.original.id, targets.down)}><ChevronDownIcon /></Button>
|
||||
</div>
|
||||
},
|
||||
size: 160,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
} satisfies ColumnDef<DataGridFeatures, FirewallRule & { _index: number }>]
|
||||
: []),
|
||||
{
|
||||
id: "index",
|
||||
accessorKey: "_index",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">#</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className="font-mono text-xs text-muted-foreground"
|
||||
data-rule-disabled={!row.original.enabled ? true : undefined}
|
||||
>
|
||||
{row.original._index}
|
||||
</span>
|
||||
),
|
||||
size: 48,
|
||||
meta: {
|
||||
headerClassName: indexPad,
|
||||
cellClassName: indexPad,
|
||||
},
|
||||
},
|
||||
...(showServer
|
||||
? [{
|
||||
id: "server",
|
||||
accessorFn: (row: FirewallRule & { _index: number }) => row.serverName ?? row.serverId ?? "",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground truncate">
|
||||
{row.original.serverName || row.original.serverId || "—"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: "Сервер", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
} satisfies ColumnDef<DataGridFeatures, FirewallRule & { _index: number }>]
|
||||
: []),
|
||||
{
|
||||
id: "chain",
|
||||
size: 112,
|
||||
accessorKey: "chain",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Цепочка" />,
|
||||
cell: ({ row }) => <ChainBadge chain={row.original.chain} />,
|
||||
meta: { headerTitle: "Цепочка", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
size: 176,
|
||||
accessorKey: "action",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Действие" />,
|
||||
cell: ({ row }) => <ActionBadge action={row.original.action} />,
|
||||
meta: { headerTitle: "Действие", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "src",
|
||||
size: 176,
|
||||
accessorKey: "src",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Источник" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground max-w-[140px] truncate block">
|
||||
{row.original.src || "any"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: "Источник", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "dst",
|
||||
size: 176,
|
||||
accessorKey: "dst",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground max-w-[140px] truncate block">
|
||||
{row.original.dst || "any"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: "Назначение", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "proto",
|
||||
size: 96,
|
||||
accessorKey: "proto",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Протокол" />,
|
||||
cell: ({ row }) => <span className="text-xs font-mono">{row.original.proto}</span>,
|
||||
meta: { headerTitle: "Протокол", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "port",
|
||||
size: 112,
|
||||
accessorKey: "port",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Порт" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.port || "—"}</span>
|
||||
),
|
||||
meta: { headerTitle: "Порт", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "iface",
|
||||
size: 160,
|
||||
accessorKey: "iface",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.iface || "—"}</span>
|
||||
),
|
||||
meta: { headerTitle: "Интерфейс", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "hits",
|
||||
size: 112,
|
||||
accessorKey: "hits",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Пакетов" className="ml-auto" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const hits = row.original.hits
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-mono tabular-nums text-right block",
|
||||
hits > 1_000_000
|
||||
? "text-emerald-600 dark:text-emerald-400 font-semibold"
|
||||
: hits > 10_000
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{fmtHits(hits)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Пакетов",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
size: 64,
|
||||
accessorKey: "enabled",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Вкл</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<FormToggle ariaLabel={`Включено правило ${row.original.rosId || row.original.id}`} disabled={mutationsDisabled} checked={row.original.enabled} onChange={() => onToggle(row.original.id)} />
|
||||
</div>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<div className="flex justify-end" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"size-11 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
|
||||
"transition-colors",
|
||||
"data-popup-open:opacity-100",
|
||||
)}
|
||||
aria-label={`Действия: ${r.chain} ${r.action}`}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem disabled={mutationsDisabled} onClick={() => onEdit(r)}>
|
||||
<PencilIcon className="size-4" />
|
||||
Редактировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<CopyIcon className="size-4" />
|
||||
Дублировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={mutationsDisabled} onClick={() => onToggle(r.id)}>
|
||||
<PowerIcon className="size-4" />
|
||||
{r.enabled ? "Отключить" : "Включить"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={mutationsDisabled} variant="destructive" onClick={() => onDelete?.(r)}>
|
||||
<Trash2Icon className="size-4" />
|
||||
Удалить правило
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 64,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[onEdit, onToggle, onDelete, onReorder, neighbours, showServer, reorderable, indexPad, mutationsDisabled],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["index", "chain"], end: [] } },
|
||||
data: indexedRules,
|
||||
columns,
|
||||
manualSorting: reorderable,
|
||||
enableSorting: !reorderable,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(rules.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [rules.length, pageIndex, pageSize, table])
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id || !onReorder) return
|
||||
onReorder(String(active.id), String(over.id))
|
||||
}
|
||||
|
||||
if (rules.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<ShieldOffIcon className="size-4" />}
|
||||
title="Правила не найдены"
|
||||
description="Попробуйте изменить фильтр или добавьте новое правило"
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rules.length}
|
||||
pagination={rules.length > 50 || pageSize !== 50}
|
||||
tableLayout={{ dense: true, columnsPinnable: true }}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row", "[&:has([data-rule-disabled=true])]:opacity-40"),
|
||||
}}
|
||||
>
|
||||
<DataGridContainer border={false} className={DATA_GRID_CONTAINER_CLASS}>
|
||||
<DataGridScrollArea>
|
||||
{reorderable ? <DataGridTableDndRows dataIds={table.getRowModel().rows.map(row => row.id)} handleDragEnd={handleDragEnd} /> : <DataGridTable />}
|
||||
</DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { FirewallRulesDataGrid, type FirewallRulesDataGridProps, ActionBadge, ChainBadge }
|
||||
@@ -0,0 +1,245 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useEffect, useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { ActionBadge, ChainBadge } from "@/components/data-grids/firewall-rules-data-grid"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CONTAINER_CLASS,
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PowerIcon, Trash2Icon } from "lucide-react"
|
||||
|
||||
function ScenarioCell({ enabled, children }: { enabled: boolean; children: React.ReactNode }) {
|
||||
return <div className={cn(!enabled && "opacity-40")}>{children}</div>
|
||||
}
|
||||
|
||||
export interface ScenarioRuleRow {
|
||||
id: string
|
||||
chain: string
|
||||
action: string
|
||||
proto: string
|
||||
src: string
|
||||
dst: string
|
||||
port: string
|
||||
iface: string
|
||||
comment: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface FirewallScenarioRulesDataGridProps {
|
||||
rules: ScenarioRuleRow[]
|
||||
onToggleEnabled: (id: string) => void
|
||||
onMoveUp: (id: string) => void
|
||||
onMoveDown: (id: string) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
function FirewallScenarioRulesDataGrid({
|
||||
rules,
|
||||
onToggleEnabled,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onRemove,
|
||||
}: FirewallScenarioRulesDataGridProps) {
|
||||
const indexedRules = useMemo(
|
||||
() => rules.map((rule, index) => ({ ...rule, _index: index + 1 })),
|
||||
[rules],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, ScenarioRuleRow & { _index: number }>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "index",
|
||||
accessorKey: "_index",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">#</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<ScenarioCell enabled={row.original.enabled}>
|
||||
<span className="font-mono text-xs text-muted-foreground tabular-nums">{row.original._index}</span>
|
||||
</ScenarioCell>
|
||||
),
|
||||
size: 40,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "chain",
|
||||
size: 112,
|
||||
accessorKey: "chain",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Цепочка</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<ScenarioCell enabled={row.original.enabled}>
|
||||
<ChainBadge chain={row.original.chain} />
|
||||
</ScenarioCell>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
size: 176,
|
||||
accessorKey: "action",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Действие</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <ActionBadge action={row.original.action} />,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "src",
|
||||
size: 176,
|
||||
accessorKey: "src",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Src</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground max-w-[90px] truncate block">
|
||||
{row.original.src || "any"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "dst",
|
||||
size: 176,
|
||||
accessorKey: "dst",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Dst</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground max-w-[90px] truncate block">
|
||||
{row.original.dst || "any"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "port",
|
||||
size: 112,
|
||||
accessorKey: "port",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Порт</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.port || "—"}</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "iface",
|
||||
size: 160,
|
||||
accessorKey: "iface",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Iface</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.iface || "—"}</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "comment",
|
||||
size: 192,
|
||||
accessorKey: "comment",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Комментарий</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground/70 max-w-[110px] truncate block">
|
||||
{row.original.comment || "—"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 208,
|
||||
header: () => null,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const index = row.original._index - 1
|
||||
return (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleEnabled(row.original.id)}
|
||||
aria-label={`${row.original.enabled ? "Отключить" : "Включить"} правило ${index + 1}`}
|
||||
className="size-11 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<PowerIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMoveUp(row.original.id)}
|
||||
aria-label={`Поднять правило ${index + 1}`}
|
||||
disabled={index === 0}
|
||||
className="size-11 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMoveDown(row.original.id)}
|
||||
aria-label={`Опустить правило ${index + 1}`}
|
||||
disabled={index === rules.length - 1}
|
||||
className="size-11 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(row.original.id)}
|
||||
aria-label={`Удалить правило ${index + 1}`}
|
||||
className="size-11 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[onMoveDown, onMoveUp, onRemove, onToggleEnabled, rules.length],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["index", "chain"], end: [] } },
|
||||
manualSorting: true,
|
||||
data: indexedRules,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(rules.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [rules.length, pageIndex, pageSize, table])
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rules.length}
|
||||
pagination={rules.length > 50 || pageSize !== 50}
|
||||
tableLayout={{ dense: true, columnsPinnable: true }}
|
||||
tableClassNames={{
|
||||
bodyRow: cn("group/row text-xs", "hover:bg-muted/20 transition-colors"),
|
||||
}}
|
||||
>
|
||||
<DataGridContainer border={false} className={DATA_GRID_CONTAINER_CLASS}>
|
||||
<DataGridScrollArea><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { FirewallScenarioRulesDataGrid, type FirewallScenarioRulesDataGridProps }
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { GrePool } from "@/lib/data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { LayersIcon } from "lucide-react"
|
||||
|
||||
interface GrePoolsDataGridProps {
|
||||
pools: GrePool[]
|
||||
}
|
||||
|
||||
function GrePoolsDataGrid({ pools }: GrePoolsDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, GrePool>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Имя пула" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-[13px] font-medium">{row.original.name}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Имя пула",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cidr",
|
||||
accessorKey: "cidr",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Диапазон CIDR" />,
|
||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.cidr}</span>,
|
||||
meta: {
|
||||
headerTitle: "Диапазон CIDR",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "allocated",
|
||||
accessorKey: "allocated",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Назначено /30" className="ml-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums text-right block">{row.original.allocated}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Назначено /30",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "available",
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground block text-right">
|
||||
Доступно /30
|
||||
</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const p = row.original
|
||||
return (
|
||||
<span className="tabular-nums text-muted-foreground text-right block">
|
||||
{p.total - p.allocated}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "usage",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Использование</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const pool = row.original
|
||||
const pct = pool.total > 0 ? Math.round((pool.allocated / pool.total) * 100) : 0
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-[140px]">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full", pct > 80 ? "bg-amber-500" : "bg-emerald-500")}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums w-8 text-right">{pct}%</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "comment",
|
||||
accessorKey: "comment",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs">{row.original.comment}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Назначение",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: true,
|
||||
data: pools,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
if (pools.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<LayersIcon className="size-4" />}
|
||||
title="Нет IP-пулов"
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={pools.length}
|
||||
tableClassNames={{ headerRow: "border-b border-border", bodyRow: "group/row" }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { GrePoolsDataGrid, type GrePoolsDataGridProps }
|
||||
@@ -0,0 +1,413 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type {
|
||||
GrePool,
|
||||
GreTunnel,
|
||||
GreStatus,
|
||||
IpsecEncAlg,
|
||||
IpsecAuthAlg,
|
||||
IpsecDhGroup,
|
||||
IkeVersion,
|
||||
Server,
|
||||
} from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
CodeXmlIcon,
|
||||
LockIcon,
|
||||
LockOpenIcon,
|
||||
MoreHorizontalIcon,
|
||||
NetworkIcon,
|
||||
PencilIcon,
|
||||
PowerIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
const ENC_LABELS: Record<IpsecEncAlg, string> = {
|
||||
"aes-128": "AES-128",
|
||||
"aes-192": "AES-192",
|
||||
"aes-256": "AES-256",
|
||||
}
|
||||
const AUTH_LABELS: Record<IpsecAuthAlg, string> = {
|
||||
sha1: "SHA-1",
|
||||
sha256: "SHA-256",
|
||||
sha512: "SHA-512",
|
||||
}
|
||||
const DH_LABELS: Record<IpsecDhGroup, string> = {
|
||||
modp1024: "DH-2 (1024)",
|
||||
modp2048: "DH-14 (2048)",
|
||||
modp4096: "DH-16 (4096)",
|
||||
ecp256: "ECP-256",
|
||||
ecp384: "ECP-384",
|
||||
ecp521: "ECP-521",
|
||||
}
|
||||
const IKE_LABELS: Record<IkeVersion, string> = { ikev1: "IKEv1", ikev2: "IKEv2" }
|
||||
|
||||
const STATUS_MAP: Record<GreStatus, { label: string; dot: string }> = {
|
||||
up: { label: "Up", dot: "bg-emerald-500" },
|
||||
degraded: { label: "Degraded", dot: "bg-amber-500" },
|
||||
down: { label: "Down", dot: "bg-red-500" },
|
||||
}
|
||||
|
||||
function TunnelStatus({ status }: { status: GreStatus }) {
|
||||
const s = STATUS_MAP[status] ?? STATUS_MAP.degraded
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm">
|
||||
<span className={cn("size-1.5 rounded-full", s.dot)} />
|
||||
{s.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function IpsecBadge({ secured }: { secured: boolean }) {
|
||||
return secured ? (
|
||||
<Badge variant="invert-light" size="sm">
|
||||
<LockIcon className="size-3" /> IPsec
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="invert-light" size="sm">
|
||||
<LockOpenIcon className="size-3" /> Открытый
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
interface GreTunnelsDataGridProps {
|
||||
tunnels: GreTunnel[]
|
||||
servers: Server[]
|
||||
pools: GrePool[]
|
||||
onCodePreview: (tunnel: GreTunnel) => void
|
||||
onEdit?: (tunnel: GreTunnel) => void
|
||||
onToggle?: (tunnel: GreTunnel) => void
|
||||
onDelete?: (tunnel: GreTunnel) => void
|
||||
mutationsLocked?: boolean
|
||||
canManage?: (serverId: string) => boolean
|
||||
unknownServerIds?: Set<string>
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}
|
||||
|
||||
type GreActions = Pick<GreTunnelsDataGridProps, "onCodePreview" | "onEdit" | "onToggle" | "onDelete" | "mutationsLocked" | "canManage">
|
||||
|
||||
const GreActionsContext = createContext<GreActions | null>(null)
|
||||
|
||||
// Keep the cell component identity stable while permissions and callbacks update.
|
||||
function GreActionsCell({ row }: { row: { original: GreTunnel } }) {
|
||||
const actions = useContext(GreActionsContext)
|
||||
if (!actions) throw new Error("GRE actions context is missing")
|
||||
const { onCodePreview, onEdit, onToggle, onDelete, mutationsLocked, canManage } = actions
|
||||
const t = row.original
|
||||
return (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"size-11 shrink-0",
|
||||
)}
|
||||
title="Предпросмотр кода RouterOS"
|
||||
aria-label={`Просмотр кода: ${t.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onCodePreview(t)
|
||||
}}
|
||||
>
|
||||
<CodeXmlIcon className="size-3.5" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"size-11 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
|
||||
"opacity-100",
|
||||
"data-popup-open:opacity-100",
|
||||
)}
|
||||
aria-label={`Действия: ${t.name}`}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>{t.name}</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onCodePreview(t)}>
|
||||
<CodeXmlIcon className="size-4" /> Просмотр кода
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={mutationsLocked || (canManage && !canManage(t.serverId)) || !onEdit} onClick={() => onEdit?.(t)}>
|
||||
<PencilIcon className="size-4" /> Редактировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={mutationsLocked || (canManage && !canManage(t.serverId)) || !onToggle} onClick={() => onToggle?.(t)}>
|
||||
<PowerIcon className="size-4" />
|
||||
{t.enabled ? "Выключить" : "Включить"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={mutationsLocked || (canManage && !canManage(t.serverId)) || !onDelete}
|
||||
onClick={() => onDelete?.(t)}
|
||||
>
|
||||
<Trash2Icon className="size-4" /> Удалить туннель
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GreTunnelsDataGrid({
|
||||
tunnels,
|
||||
servers,
|
||||
pools,
|
||||
onCodePreview,
|
||||
onEdit,
|
||||
onToggle,
|
||||
onDelete,
|
||||
mutationsLocked = false,
|
||||
unknownServerIds, emptyTitle, emptyDescription, canManage,
|
||||
}: GreTunnelsDataGridProps) {
|
||||
const serverMap = useMemo(() => new Map(servers.map((s) => [s.id, s])), [servers])
|
||||
const poolMap = useMemo(() => new Map(pools.map((p) => [p.id, p])), [pools])
|
||||
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, GreTunnel>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
size: 160,
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Интерфейс / Сервер" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const t = row.original
|
||||
const srv = serverMap.get(t.serverId)
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium font-mono text-[13px] break-all">{t.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1">
|
||||
{srv && <Flag code={srv.country} />}
|
||||
{srv?.name ?? t.serverId}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Интерфейс / Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "endpoints",
|
||||
size: 144,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Эндпоинты</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const t = row.original
|
||||
return (
|
||||
<div>
|
||||
<p className="font-mono text-xs">
|
||||
{t.localAddress === "0.0.0.0" ? (
|
||||
<span className="text-muted-foreground">авто</span>
|
||||
) : (
|
||||
t.localAddress
|
||||
)}
|
||||
</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">→ {t.remoteAddress}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "innerIp",
|
||||
size: 136,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Внутренний IP</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const t = row.original
|
||||
return (
|
||||
<div>
|
||||
<p className="font-mono text-xs">{t.localInnerIp}</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">{t.remoteInnerIp}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "pool",
|
||||
size: 96,
|
||||
accessorKey: "poolId",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Пул" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{poolMap.get(row.original.poolId)?.name ?? "—"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: "Пул", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "ipsec",
|
||||
size: 112,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">IPsec</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <IpsecBadge secured={!!row.original.ipsec} />,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "encryption",
|
||||
size: 160,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Шифрование</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const t = row.original
|
||||
if (!t.ipsec) return <span className="text-xs text-muted-foreground">—</span>
|
||||
const enc = t.ipsec.encAlg ? ENC_LABELS[t.ipsec.encAlg] : undefined
|
||||
const auth = t.ipsec.authAlg ? AUTH_LABELS[t.ipsec.authAlg] : undefined
|
||||
const dh = t.ipsec.dhGroup ? DH_LABELS[t.ipsec.dhGroup] : undefined
|
||||
const ike = t.ipsec.ikeVersion ? IKE_LABELS[t.ipsec.ikeVersion] : undefined
|
||||
if (!enc && !auth && !dh && !ike) {
|
||||
return <span className="text-xs text-muted-foreground">PSK · auto</span>
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-mono">
|
||||
{[enc, auth].filter(Boolean).join(" / ") || "PSK"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{[dh?.split(" ")[0], ike].filter(Boolean).join(" · ")}
|
||||
{t.ipsec.pfs ? " · PFS" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "mtu",
|
||||
size: 72,
|
||||
accessorKey: "mtu",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="MTU" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-center block">{row.original.mtu}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "MTU",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "keepalive",
|
||||
size: 104,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Keepalive</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const t = row.original
|
||||
return (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{t.keepaliveInterval === 0 ? "откл." : `${t.keepaliveInterval}с / ${t.keepaliveRetries}`}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
size: 112,
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => unknownServerIds?.has(row.original.serverId) ? <span className="text-sm text-muted-foreground">Неизвестно</span> : <TunnelStatus status={row.original.status} />,
|
||||
meta: { headerTitle: "Статус", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
enableSorting: false,
|
||||
cell: GreActionsCell,
|
||||
size: 112,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[unknownServerIds, poolMap, serverMap],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["name"], end: [] } },
|
||||
data: tunnels,
|
||||
columns,
|
||||
getRowId: row => JSON.stringify([row.serverId, row.id]),
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(tunnels.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [tunnels.length, pageIndex, pageSize, table])
|
||||
|
||||
if (tunnels.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<NetworkIcon className="size-4" />}
|
||||
title={emptyTitle ?? "Нет GRE-туннелей"}
|
||||
description={emptyDescription ?? "Измените фильтр или добавьте туннель"}
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<GreActionsContext.Provider value={{ onCodePreview, onEdit, onToggle, onDelete, mutationsLocked, canManage }}>
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={tunnels.length}
|
||||
pagination={tunnels.length > 50 || pageSize !== 50}
|
||||
tableLayout={{ dense: true, columnsPinnable: true }}
|
||||
tableClassNames={{ headerRow: "border-b border-border", bodyRow: "group/row" }}
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
</GreActionsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export { GreTunnelsDataGrid, type GreTunnelsDataGridProps }
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { IpRange } from "@/lib/data"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { FilterIcon, NetworkIcon } from "lucide-react"
|
||||
|
||||
interface IpRangesDataGridProps {
|
||||
ipRanges: IpRange[]
|
||||
isLoading?: boolean
|
||||
emptyDescription?: string
|
||||
pagination?: boolean
|
||||
}
|
||||
|
||||
function IpRangesDataGrid({ ipRanges, isLoading, pagination = false, emptyDescription }: IpRangesDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, IpRange>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "cidr",
|
||||
accessorKey: "cidr",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="CIDR" className="ml-1" />,
|
||||
cell: ({ row }) => <span className="font-mono font-medium">{row.original.cidr}</span>,
|
||||
meta: {
|
||||
headerTitle: "CIDR",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "asn",
|
||||
accessorKey: "asn",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="ASN" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.asn}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "ASN",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "country",
|
||||
accessorKey: "country",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Страна" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs border border-border rounded px-2 py-0.5">{row.original.country}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Страна",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "purpose",
|
||||
accessorKey: "purpose",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs border border-border rounded px-2 py-0.5">{row.original.purpose}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Назначение",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "filter",
|
||||
accessorKey: "filter",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Фильтр" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />
|
||||
{row.original.filter}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Фильтр",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
accessorKey: "updated",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Обновлён" />,
|
||||
cell: ({ row }) => <span className="text-xs text-muted-foreground">{row.original.updated}</span>,
|
||||
meta: {
|
||||
headerTitle: "Обновлён",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={`text-xs font-medium ${row.original.enabled ? "text-emerald-600" : "text-muted-foreground"}`}
|
||||
>
|
||||
{row.original.enabled ? "Активен" : "Отключён"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: !pagination,
|
||||
data: ipRanges,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={ipRanges.length}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
pagination={pagination}
|
||||
emptyMessage={
|
||||
<EmptyState
|
||||
icon={<NetworkIcon className="size-4" />}
|
||||
title="Нет IP-диапазонов"
|
||||
description={emptyDescription ?? "Добавьте CIDR-блоки или импортируйте каталог"}
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { IpRangesDataGrid, type IpRangesDataGridProps }
|
||||
@@ -0,0 +1,317 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useEffect, useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { BfdState } from "@/lib/ospf-state"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { ActivityIcon } from "lucide-react"
|
||||
|
||||
export interface BfdSessionRow {
|
||||
id: string
|
||||
dataUnknown?: boolean
|
||||
serverId: string
|
||||
serverLabel: string
|
||||
localAddr: string
|
||||
remoteAddr: string
|
||||
state: BfdState
|
||||
interval: number | null
|
||||
multiplier: number | null
|
||||
iface: string
|
||||
uptime: string | null
|
||||
multihop: boolean | null
|
||||
rxInterval: number | null
|
||||
holdTime: number | null
|
||||
packetsRx: number | null
|
||||
packetsTx: number | null
|
||||
stateChanges: number | null
|
||||
}
|
||||
|
||||
function stateClass(state: BfdSessionRow["state"]) {
|
||||
if (state === "Unknown") return "bg-muted text-muted-foreground border-border"
|
||||
if (state === "Up")
|
||||
return "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/25"
|
||||
if (state === "Init")
|
||||
return "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/25"
|
||||
return "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/25"
|
||||
}
|
||||
|
||||
function Chip({ children, color }: { children: React.ReactNode; color?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border",
|
||||
color ?? "bg-muted text-muted-foreground border-border",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtMs(ms: number | null) {
|
||||
if (ms === null) return "—"
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
interface OspfBfdDataGridProps {
|
||||
sessions: BfdSessionRow[]
|
||||
}
|
||||
|
||||
function OspfBfdDataGrid({ sessions }: OspfBfdDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, BfdSessionRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "serverLabel",
|
||||
size: 128,
|
||||
accessorKey: "serverLabel",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Роутер" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const b = row.original
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono whitespace-normal break-all">{b.serverLabel}</span>
|
||||
{b.multihop === null && <Chip>Режим неизвестен</Chip>}
|
||||
{b.multihop === true && (
|
||||
<Chip color="bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20">
|
||||
multihop
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Роутер",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "iface",
|
||||
accessorKey: "iface",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-muted-foreground whitespace-nowrap">
|
||||
{row.original.iface || "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Интерфейс",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "localAddr",
|
||||
accessorKey: "localAddr",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Локальный" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono whitespace-nowrap">{row.original.localAddr}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Локальный",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "remoteAddr",
|
||||
accessorKey: "remoteAddr",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Удалённый" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono whitespace-nowrap">{row.original.remoteAddr}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Удалённый",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
accessorKey: "state",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium",
|
||||
row.original.dataUnknown ? "bg-muted text-muted-foreground" : stateClass(row.original.state),
|
||||
)}
|
||||
>
|
||||
{row.original.dataUnknown || row.original.state === "Unknown" ? "Неизвестно" : row.original.state}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Состояние",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
accessorKey: "uptime",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Uptime" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground whitespace-nowrap tabular-nums">
|
||||
{row.original.uptime ?? "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Uptime",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "intervals",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">TX факт / RX минимум</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original
|
||||
return (
|
||||
<span className="font-mono tabular-nums text-muted-foreground whitespace-nowrap">
|
||||
{fmtMs(b.interval)} / {fmtMs(b.rxInterval)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "holdTime",
|
||||
accessorKey: "holdTime",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Hold" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-muted-foreground whitespace-nowrap">
|
||||
{fmtMs(row.original.holdTime)}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Hold",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "multiplier",
|
||||
accessorKey: "multiplier",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Mult" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-center block">{row.original.multiplier ?? "—"}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Mult",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "packetsRx",
|
||||
accessorKey: "packetsRx",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Пакеты Rx" className="ml-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-right text-muted-foreground block">
|
||||
{row.original.packetsRx?.toLocaleString() ?? "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Пакеты Rx",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "packetsTx",
|
||||
accessorKey: "packetsTx",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Пакеты Tx" className="ml-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-right text-muted-foreground block">
|
||||
{row.original.packetsTx?.toLocaleString() ?? "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Пакеты Tx",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "stateChanges",
|
||||
size: 112,
|
||||
accessorKey: "stateChanges",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Переходы" className="ml-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-right block">{row.original.stateChanges ?? "—"}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Переходы",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-right"),
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["serverLabel"], end: [] } },
|
||||
data: sessions,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(sessions.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [sessions.length, pageIndex, pageSize, table])
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<ActivityIcon className="size-4" />}
|
||||
title="BFD-сессий не обнаружено"
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={sessions.length}
|
||||
pagination={sessions.length > 50 || pageSize !== 50}
|
||||
tableClassNames={{ headerRow: "border-b border-border", bodyRow: "group/row hover:bg-muted/30" }}
|
||||
tableLayout={{ dense: true, columnsPinnable: true }}
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { OspfBfdDataGrid, type OspfBfdDataGridProps }
|
||||
@@ -0,0 +1,274 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { OspfNeighborState } from "@/lib/ospf-state"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CONTAINER_CLASS,
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { NetworkIcon } from "lucide-react"
|
||||
|
||||
export interface OspfNeighborRow {
|
||||
id: string
|
||||
dataUnknown?: boolean
|
||||
localRouter: string
|
||||
localLabel: string
|
||||
localIface: string
|
||||
remoteRouter: string
|
||||
remoteLabel: string
|
||||
remoteRouterId: string
|
||||
area: string
|
||||
state: OspfNeighborState
|
||||
cost: number | null
|
||||
uptime: string
|
||||
priority: number | null
|
||||
}
|
||||
|
||||
function stateClass(state: OspfNeighborRow["state"]) {
|
||||
if (state === "Full")
|
||||
return "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/25"
|
||||
if (state === "Unknown") return "bg-muted text-muted-foreground border-border"
|
||||
if (state !== "Down")
|
||||
return "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/25"
|
||||
return "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/25"
|
||||
}
|
||||
|
||||
interface OspfNeighborsDataGridProps {
|
||||
emptyTitle?: string
|
||||
neighbors: OspfNeighborRow[]
|
||||
highlightRouterId?: string | null
|
||||
selectedRouterId?: string | null
|
||||
onHighlight?: (routerId: string | null) => void
|
||||
onSelect?: (routerId: string | null) => void
|
||||
}
|
||||
|
||||
function OspfNeighborsDataGrid({
|
||||
neighbors,
|
||||
emptyTitle,
|
||||
highlightRouterId,
|
||||
selectedRouterId,
|
||||
onSelect,
|
||||
}: OspfNeighborsDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, OspfNeighborRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "localLabel",
|
||||
size: 144,
|
||||
accessorKey: "localLabel",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Роутер" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
onSelect ? <Button type="button" variant="ghost" size="sm" className="min-h-11" aria-label={`Открыть узел ${row.original.localLabel}`} aria-expanded={selectedRouterId === row.original.localRouter} onClick={event => { event.stopPropagation(); onSelect(selectedRouterId === row.original.localRouter ? null : row.original.localRouter) }}>{row.original.localLabel}</Button> : <span className="font-mono whitespace-nowrap">{row.original.localLabel}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Роутер",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "localIface",
|
||||
size: 160,
|
||||
accessorKey: "localIface",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-muted-foreground whitespace-nowrap">
|
||||
{row.original.localIface}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Интерфейс",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "remote",
|
||||
size: 192,
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground">Сосед (Router ID)</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const n = row.original
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono">
|
||||
{n.remoteLabel !== n.remoteRouterId ? n.remoteLabel : n.remoteRouterId}
|
||||
</span>
|
||||
{n.remoteLabel !== n.remoteRouterId && (
|
||||
<span className="text-[10px] font-mono text-muted-foreground">{n.remoteRouterId}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "area",
|
||||
size: 176,
|
||||
accessorKey: "area",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Область" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-muted-foreground">{row.original.area}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Область",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
size: 128,
|
||||
accessorKey: "state",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium",
|
||||
row.original.dataUnknown ? "bg-muted text-muted-foreground" : stateClass(row.original.state),
|
||||
)}
|
||||
>
|
||||
{row.original.dataUnknown || row.original.state === "Unknown" ? "Неизвестно" : row.original.state}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Состояние",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cost",
|
||||
size: 80,
|
||||
accessorKey: "cost",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Cost" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-center block">{row.original.cost ?? "—"}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Cost",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
size: 112,
|
||||
accessorKey: "uptime",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Uptime" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground whitespace-nowrap tabular-nums">
|
||||
{row.original.uptime}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Uptime",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "priority",
|
||||
size: 64,
|
||||
accessorKey: "priority",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Prio" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-center font-mono block">{row.original.priority ?? "—"}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Prio",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-center"),
|
||||
},
|
||||
},
|
||||
],
|
||||
[onSelect, selectedRouterId],
|
||||
)
|
||||
|
||||
const rowSelection = useMemo(() => {
|
||||
if (!selectedRouterId) return {}
|
||||
const match = neighbors.find(
|
||||
(n) => n.localRouter === selectedRouterId || n.remoteRouter === selectedRouterId || selectedRouterId === `remote-${n.remoteRouterId}`,
|
||||
)
|
||||
return match ? { [match.id]: true as const } : {}
|
||||
}, [neighbors, selectedRouterId])
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["localLabel"], end: [] } },
|
||||
data: neighbors,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
enableRowSelection: true,
|
||||
state: { rowSelection },
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(neighbors.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [neighbors.length, pageIndex, pageSize, table])
|
||||
const requestedNode = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!selectedRouterId) { requestedNode.current = null; return }
|
||||
if (requestedNode.current === selectedRouterId) return
|
||||
const index = table.getSortedRowModel().rows.findIndex(({ original: n }) => n.localRouter === selectedRouterId || n.remoteRouter === selectedRouterId || selectedRouterId === `remote-${n.remoteRouterId}`)
|
||||
if (index < 0) return
|
||||
requestedNode.current = selectedRouterId
|
||||
table.setPageIndex(Math.floor(index / pageSize))
|
||||
}, [selectedRouterId, neighbors, pageSize, table])
|
||||
|
||||
if (neighbors.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<NetworkIcon className="size-4" />}
|
||||
title={emptyTitle ?? "Нет OSPF-соседей"}
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={neighbors.length}
|
||||
pagination={neighbors.length > 50 || pageSize !== 50}
|
||||
onRowClick={(row) => {
|
||||
onSelect?.(selectedRouterId === row.localRouter ? null : row.localRouter)
|
||||
}}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn(
|
||||
"group/row cursor-pointer",
|
||||
"data-[state=selected]:bg-primary/5",
|
||||
highlightRouterId && "[&:hover]:bg-muted/30",
|
||||
),
|
||||
}}
|
||||
tableLayout={{ dense: true, columnsPinnable: true }}
|
||||
>
|
||||
<DataGridContainer border={false} className={DATA_GRID_CONTAINER_CLASS}>
|
||||
<DataGridScrollArea><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { OspfNeighborsDataGrid, type OspfNeighborsDataGridProps }
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useEffect, useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { RouteIcon } from "lucide-react"
|
||||
|
||||
export interface OspfRouteRow {
|
||||
id: string
|
||||
dataUnknown?: boolean
|
||||
destination: string
|
||||
type: "O" | "O IA" | "O E1" | "O E2" | "Unknown"
|
||||
cost: number | null
|
||||
nextHop: string
|
||||
via: string
|
||||
serverId: string
|
||||
serverLabel: string
|
||||
area: string
|
||||
}
|
||||
|
||||
function OspfRouteTypeBadge({ type, count }: { type: OspfRouteRow["type"]; count?: number }) {
|
||||
return <Badge variant="invert-light" size="sm">
|
||||
{type === "Unknown" ? "Тип неизвестен" : type}{count === undefined ? "" : ": " + count}
|
||||
</Badge>
|
||||
}
|
||||
|
||||
interface OspfRoutesDataGridProps {
|
||||
emptyTitle?: string
|
||||
routes: OspfRouteRow[]
|
||||
}
|
||||
|
||||
function OspfRoutesDataGrid({ routes, emptyTitle }: OspfRoutesDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, OspfRouteRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "destination",
|
||||
size: 180,
|
||||
accessorKey: "destination",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Назначение" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => <span className="font-mono whitespace-nowrap" title={row.original.destination}>{row.original.destination}</span>,
|
||||
meta: {
|
||||
headerTitle: "Назначение",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
size: 116,
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => (
|
||||
<OspfRouteTypeBadge type={row.original.type} />
|
||||
),
|
||||
meta: { headerTitle: "Тип", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "cost",
|
||||
size: 80,
|
||||
accessorKey: "cost",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Cost" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-center block">{row.original.cost ?? "—"}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Cost",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "nextHop",
|
||||
accessorKey: "nextHop",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Следующий хоп" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-muted-foreground">{row.original.nextHop}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Следующий хоп",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "via",
|
||||
size: 120,
|
||||
accessorKey: "via",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-muted-foreground">{row.original.via}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Интерфейс",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "serverLabel",
|
||||
accessorKey: "serverLabel",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Роутер" />,
|
||||
cell: ({ row }) => <span className="font-mono">{row.original.serverLabel}{row.original.dataUnknown ? " · Неизвестно" : ""}</span>,
|
||||
meta: {
|
||||
headerTitle: "Роутер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "area",
|
||||
size: 120,
|
||||
accessorKey: "area",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Область" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-muted-foreground" title={row.original.area}>{row.original.area}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Область",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-right"),
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["destination"], end: [] } },
|
||||
data: routes,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(routes.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [routes.length, pageIndex, pageSize, table])
|
||||
|
||||
if (routes.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<RouteIcon className="size-4" />}
|
||||
title={emptyTitle ?? "Нет OSPF-маршрутов"}
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={routes.length}
|
||||
pagination={routes.length > 50 || pageSize !== 50}
|
||||
tableClassNames={{ headerRow: "border-b border-border", bodyRow: "group/row hover:bg-muted/30" }}
|
||||
tableLayout={{ dense: true, columnsPinnable: true }}
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { OspfRoutesDataGrid, type OspfRoutesDataGridProps, OspfRouteTypeBadge }
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Server } from "@/lib/data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { ClockIcon, Trash2Icon } from "lucide-react"
|
||||
|
||||
export type SchedType = "ping" | "bandwidth" | "both"
|
||||
|
||||
export interface SchedRule {
|
||||
id: string
|
||||
srcId: string
|
||||
tunnelId: string
|
||||
type: SchedType
|
||||
intervalMin: number
|
||||
enabled: boolean
|
||||
lastRun: string | null
|
||||
nextRunMin: number | null
|
||||
}
|
||||
|
||||
const TYPE_LABEL: Record<SchedType, string> = {
|
||||
ping: "Ping",
|
||||
bandwidth: "BW-тест",
|
||||
both: "Ping + BW",
|
||||
}
|
||||
|
||||
interface ProbesScheduleDataGridProps {
|
||||
rules: SchedRule[]
|
||||
serverOptions: Server[]
|
||||
tunnelName: (srcId: string, tunnelId: string) => string | undefined
|
||||
onToggleEnabled: (id: string, enabled: boolean) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
function ProbesScheduleDataGrid({
|
||||
rules,
|
||||
serverOptions,
|
||||
tunnelName,
|
||||
onToggleEnabled,
|
||||
onDelete,
|
||||
}: ProbesScheduleDataGridProps) {
|
||||
const serverMap = useMemo(
|
||||
() => new Map(serverOptions.map((s) => [s.id, s])),
|
||||
[serverOptions],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, SchedRule>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
header: () => <span className="sr-only">Вкл</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<FormToggle
|
||||
checked={row.original.enabled}
|
||||
onChange={(v) => onToggleEnabled(row.original.id, v)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
size: 48,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tunnel",
|
||||
accessorKey: "tunnelId",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Туннель" />,
|
||||
cell: ({ row }) => (
|
||||
<code className="font-mono text-xs truncate block">
|
||||
{tunnelName(row.original.srcId, row.original.tunnelId) ?? row.original.tunnelId}
|
||||
</code>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Туннель",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "server",
|
||||
accessorKey: "srcId",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground truncate block">
|
||||
{serverMap.get(row.original.srcId)?.name ?? row.original.srcId}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] px-1.5 py-0.5 rounded border font-medium w-fit",
|
||||
row.original.type === "ping"
|
||||
? "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20"
|
||||
: row.original.type === "bandwidth"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
|
||||
)}
|
||||
>
|
||||
{TYPE_LABEL[row.original.type]}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Тип",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "intervalMin",
|
||||
accessorKey: "intervalMin",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Интервал" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">каждые {row.original.intervalMin} мин</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Интервал",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "lastRun",
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground">Последний / следующий</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const rule = row.original
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2 min-w-0">
|
||||
{rule.lastRun && <span className="truncate">{rule.lastRun}</span>}
|
||||
{rule.nextRunMin != null && rule.enabled && (
|
||||
<span className="text-sky-600 dark:text-sky-400 shrink-0">
|
||||
· через {rule.nextRunMin} мин
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Последний / следующий",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(row.original.id)}
|
||||
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-red-500 hover:bg-red-500/10 transition-colors opacity-0 group-hover/row:opacity-100 focus-visible:opacity-100"
|
||||
aria-label="Удалить правило"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</button>
|
||||
),
|
||||
size: 48,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[onDelete, onToggleEnabled, serverMap, tunnelName],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: true,
|
||||
data: rules,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
if (rules.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<ClockIcon className="size-4" />}
|
||||
title="Нет правил расписания"
|
||||
description="Добавьте правило ping или bandwidth-теста"
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <DataGridShell table={table} recordCount={rules.length} />
|
||||
}
|
||||
|
||||
export { ProbesScheduleDataGrid, type ProbesScheduleDataGridProps }
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo, useEffect } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { ClockIcon } from "lucide-react"
|
||||
|
||||
import type {SpeedProbeApiRow} from "@/lib/probe-schedule-response"
|
||||
export type {SpeedProbeApiRow} from "@/lib/probe-schedule-response"
|
||||
|
||||
interface ProbesSpeedProbesDataGridProps {
|
||||
rows: SpeedProbeApiRow[]
|
||||
serverName: (id: string) => string
|
||||
}
|
||||
|
||||
function ProbesSpeedProbesDataGrid({ rows, serverName }: ProbesSpeedProbesDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, SpeedProbeApiRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "src",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Источник" className="ml-1" />,
|
||||
accessorFn: (row) => row.srcServerId,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<span className="truncate font-mono text-xs block">
|
||||
{serverName(r.srcServerId)}
|
||||
{r.srcInterface ? ` · ${r.srcInterface}` : ""}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Источник",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dst",
|
||||
accessorFn: (row) => row.dstServerId,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Назначение" />,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<span className="truncate font-mono text-xs block">
|
||||
{serverName(r.dstServerId)}
|
||||
{r.dstInterface ? ` · ${r.dstInterface}` : ""}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Назначение",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "protocol",
|
||||
accessorKey: "protocol",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Протокол" />,
|
||||
cell: ({ row }) => <span className="text-xs">{row.original.protocol.toUpperCase()}</span>,
|
||||
meta: {
|
||||
headerTitle: "Протокол",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "durationSec",
|
||||
accessorKey: "durationSec",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сек" />,
|
||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.durationSec}s</span>,
|
||||
meta: {
|
||||
headerTitle: "Сек",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Вкл" />,
|
||||
cell: ({ row }) => <span className="text-xs">{row.original.enabled ? "да" : "нет"}</span>,
|
||||
meta: {
|
||||
headerTitle: "Вкл",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "lastRunAt",
|
||||
accessorKey: "lastRunAt",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Последний запуск" />,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground truncate block">
|
||||
{r.lastRunAt ?? "—"}
|
||||
{r.lastStatus === "done" && (
|
||||
<span className="text-emerald-600 dark:text-emerald-400 ml-1">
|
||||
TX: {r.lastTxAvgMbps==null?"неизвестно":r.lastTxAvgMbps.toFixed(1)+" Мбит/с"} · RX: {r.lastRxAvgMbps==null?"неизвестно":r.lastRxAvgMbps.toFixed(1)+" Мбит/с"}
|
||||
</span>
|
||||
)}
|
||||
{r.lastStatus === "error" && r.lastError && (
|
||||
<span className="text-destructive ml-1 truncate">{r.lastError}</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Последний запуск",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[serverName],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 } },
|
||||
data: rows,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(rows.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [rows.length, pageSize, pageIndex, table])
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<ClockIcon className="size-4" />}
|
||||
title="Нет записей speed-test"
|
||||
description="Добавьте пробы в разделе мониторинга."
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rows.length}
|
||||
pagination={rows.length > 50 || table.state.pagination.pageSize !== 50}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { ProbesSpeedProbesDataGrid, type ProbesSpeedProbesDataGridProps }
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { Filters } from "@/components/reui/filters/filters"
|
||||
import type { FilterField, FilterQuery } from "@/components/reui/filters/filters-types"
|
||||
import { FILTER_LABELS_RU, FILTER_OPERATOR_LABELS_RU } from "@/lib/data-filters/filter-labels-ru"
|
||||
|
||||
export function QueryFilters({ fields, query, onQueryChange }: {
|
||||
fields: FilterField[]
|
||||
query: FilterQuery
|
||||
onQueryChange: (query: FilterQuery) => void
|
||||
}) {
|
||||
return <Filters fields={fields} query={query} onQueryChange={onQueryChange}
|
||||
size="sm" showClear labels={FILTER_LABELS_RU} operatorLabels={FILTER_OPERATOR_LABELS_RU} />
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
PencilIcon,
|
||||
RouteIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export interface RecursiveRouteEndpoint {
|
||||
id: string
|
||||
gateway: string
|
||||
distance: number
|
||||
scope: number | null
|
||||
targetScope: number | null
|
||||
checkGateway: string
|
||||
country: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export interface RecursiveRouteGroup {
|
||||
id?: string
|
||||
key: string
|
||||
dstAddress: string
|
||||
routingTable: string
|
||||
comment: string
|
||||
endpoints: RecursiveRouteEndpoint[]
|
||||
}
|
||||
|
||||
const INFER_COUNTRIES = [
|
||||
{ code: "RU", keys: ["MSK", "SPB", "RTK", "MTS", "VPSVILLE", "IHOR"] },
|
||||
{ code: "SE", keys: ["SWE", "STO"] },
|
||||
{ code: "FI", keys: ["HEL", "FIN"] },
|
||||
{ code: "DE", keys: ["FRA", "GER", "DE"] },
|
||||
{ code: "NL", keys: ["AMS", "NLD", "NL"] },
|
||||
{ code: "SG", keys: ["SGP", "SIN", "SG"] },
|
||||
{ code: "TR", keys: ["TUR", "TR"] },
|
||||
{ code: "US", keys: ["USA", "US", "NYC", "LAX"] },
|
||||
]
|
||||
|
||||
function inferCountry(name: string): string | null {
|
||||
const upper = name.toUpperCase()
|
||||
for (const c of INFER_COUNTRIES) {
|
||||
if (c.keys.some((k) => upper.includes(k))) return c.code
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function RouteGroupExpandedDetail({ group }: { group: RecursiveRouteGroup }) {
|
||||
const [page, setPage] = useState(0)
|
||||
const sorted = useMemo(() => [...group.endpoints].sort((a, b) => a.distance - b.distance), [group.endpoints])
|
||||
const lastPage = Math.max(0, Math.ceil(sorted.length / 50) - 1)
|
||||
if (page > lastPage) setPage(lastPage)
|
||||
const currentPage = Math.min(page, lastPage)
|
||||
const offset = currentPage * 50
|
||||
return (
|
||||
<div role="region" aria-label={`Конечные точки: ${group.dstAddress} · ${group.routingTable}`} className="sticky left-0 w-full max-w-[calc(100vw-3rem)] md:max-w-none flex flex-col gap-4 px-5 py-4 bg-muted/20">
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
Route: <span className="font-mono text-foreground">{group.dstAddress}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Table:{" "}
|
||||
<span className="font-mono text-foreground">{group.routingTable || "main"}</span>
|
||||
</span>
|
||||
{group.comment && <span className="text-muted-foreground italic">{group.comment}</span>}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2.5">
|
||||
{sorted.slice(offset, offset + 50).map((ep, idx) => (
|
||||
<div key={ep.id} data-recursive-endpoint={ep.id} className="rounded-lg border border-border bg-background px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{ep.country && (
|
||||
<Flag code={ep.country} size={16} />
|
||||
)}
|
||||
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Endpoint {offset + idx + 1}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-mono">distance: {ep.distance}</span>
|
||||
</div>
|
||||
<p className="mt-1.5 font-mono text-sm break-all leading-tight">{ep.gateway}</p>
|
||||
<div className="mt-1.5 text-[11px] text-muted-foreground flex flex-wrap items-center gap-3">
|
||||
<span>scope: {ep.scope ?? "—"}</span>
|
||||
<span>t.scope: {ep.targetScope ?? "—"}</span>
|
||||
<span>check: {ep.checkGateway || "—"}</span>
|
||||
{ep.disabled && <span>Отключён</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{sorted.length > 50 && <div className="flex flex-wrap items-center gap-2">
|
||||
<span role="status" className="text-xs text-muted-foreground">Конечные точки {offset + 1}–{Math.min(offset + 50, sorted.length)} из {sorted.length}</span>
|
||||
<Button type="button" variant="outline" className="min-h-11" aria-label="Предыдущие конечные точки" disabled={currentPage === 0} onClick={() => setPage(currentPage - 1)}>Назад</Button>
|
||||
<Button type="button" variant="outline" className="min-h-11" aria-label="Следующие конечные точки" disabled={currentPage === lastPage} onClick={() => setPage(currentPage + 1)}>Далее</Button>
|
||||
</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface RecursiveRoutesDataGridProps {
|
||||
groups: RecursiveRouteGroup[]
|
||||
expandedKey?: string | null
|
||||
onExpandedChange?: (key: string | null) => void
|
||||
onEdit: (group: RecursiveRouteGroup) => void
|
||||
onDelete: (group: RecursiveRouteGroup) => void
|
||||
mutationsDisabled?: boolean
|
||||
emptyTitle?: string
|
||||
}
|
||||
|
||||
function RecursiveRoutesDataGrid({
|
||||
groups,
|
||||
expandedKey,
|
||||
onExpandedChange,
|
||||
onEdit,
|
||||
onDelete,
|
||||
mutationsDisabled = false,
|
||||
emptyTitle = "Нет маршрутов",
|
||||
}: RecursiveRoutesDataGridProps) {
|
||||
const [confirmDeleteKey, setConfirmDeleteKey] = useState<string | null>(null)
|
||||
|
||||
const expanded = useMemo(() => {
|
||||
if (!expandedKey) return {}
|
||||
const g = groups.find((x) => x.key === expandedKey)
|
||||
return g ? { [(g.id ?? g.key)]: true } : {}
|
||||
}, [expandedKey, groups])
|
||||
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, RecursiveRouteGroup>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "route",
|
||||
size: 176,
|
||||
accessorKey: "dstAddress",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Route / Comment" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const g = row.original
|
||||
const isExpanded = expandedKey === g.key
|
||||
return (
|
||||
<button type="button" aria-label={`Раскрыть маршрут ${g.dstAddress} · ${g.routingTable}`} aria-expanded={isExpanded} onClick={event => { event.stopPropagation(); onExpandedChange?.(isExpanded ? null : g.key) }} className="flex items-start gap-2 min-h-11 min-w-0 text-left rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon aria-hidden="true" className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRightIcon aria-hidden="true" className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{g.dstAddress}</p>
|
||||
<p className="text-xs font-mono text-muted-foreground">{g.comment || "—"}</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Route / Comment",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
expandedContent: (group: RecursiveRouteGroup) => (
|
||||
<RouteGroupExpandedDetail key={group.key} group={group} />
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gateways",
|
||||
size: 208,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Gateways</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const sorted = [...row.original.endpoints].sort((a, b) => a.distance - b.distance)
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{sorted.slice(0, 3).map(ep => {
|
||||
const code = ep.country
|
||||
return (
|
||||
<div key={ep.id} className="flex items-center gap-1.5 text-[11px] font-mono">
|
||||
<span aria-hidden="true"
|
||||
className={cn(
|
||||
"size-1.5 rounded-full shrink-0",
|
||||
ep.disabled ? "bg-muted-foreground" : "bg-info",
|
||||
)}
|
||||
/>
|
||||
{code ? (
|
||||
<Flag code={code} size={14} className="shrink-0" />
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground w-3.5 text-center shrink-0">
|
||||
?
|
||||
</span>
|
||||
)}
|
||||
<span className="font-semibold text-info truncate min-w-0">
|
||||
{ep.gateway}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{sorted.length > 3 && <span className="text-xs text-muted-foreground">Ещё {sorted.length - 3} · раскройте маршрут</span>}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "epCount",
|
||||
size: 64,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">EP</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs tabular-nums">{row.original.endpoints.length}</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "priority",
|
||||
size: 96,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Priority</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const bestDistance = Math.min(...row.original.endpoints.map((ep) => ep.distance))
|
||||
return <span className="font-mono text-xs">d{bestDistance}</span>
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "table",
|
||||
size: 128,
|
||||
accessorKey: "routingTable",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Table" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{row.original.routingTable || "main"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Table",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const g = row.original
|
||||
const confirmDel = confirmDeleteKey === g.key
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-0.5 justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button type="button" size="sm"
|
||||
variant="ghost"
|
||||
className="size-11 p-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={`Редактировать маршрут ${g.dstAddress} · ${g.routingTable}`}
|
||||
disabled={mutationsDisabled}
|
||||
onClick={() => onEdit(g)}
|
||||
>
|
||||
<PencilIcon aria-hidden="true" className="size-3.5" />
|
||||
</Button>
|
||||
<Button type="button" size="sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"size-11 p-0 transition-colors",
|
||||
confirmDel
|
||||
? "text-destructive bg-destructive/10 hover:bg-destructive/20"
|
||||
: "text-muted-foreground hover:text-destructive",
|
||||
)}
|
||||
aria-label={`${confirmDel ? "Подтвердить удаление" : "Удалить маршрут"} ${g.dstAddress} · ${g.routingTable}`}
|
||||
disabled={mutationsDisabled}
|
||||
onClick={() => {
|
||||
if (mutationsDisabled) return
|
||||
if (!confirmDel) setConfirmDeleteKey(g.key)
|
||||
else {
|
||||
onDelete(g)
|
||||
setConfirmDeleteKey(null)
|
||||
}
|
||||
}}
|
||||
onBlur={() => setConfirmDeleteKey(null)}
|
||||
>
|
||||
{confirmDel ? (
|
||||
<AlertCircleIcon aria-hidden="true" className="size-3.5" />
|
||||
) : (
|
||||
<TrashIcon aria-hidden="true" className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 112,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[confirmDeleteKey, expandedKey, onDelete, onEdit, onExpandedChange, mutationsDisabled],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["route"], end: [] } },
|
||||
data: groups,
|
||||
columns,
|
||||
getRowId: (row) => row.id ?? row.key,
|
||||
getRowCanExpand: () => true,
|
||||
state: { expanded },
|
||||
})
|
||||
|
||||
const requestedExpansion = useRef<string | null>(null)
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(groups.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
if (!expandedKey) { requestedExpansion.current = null; return }
|
||||
const index = table.getSortedRowModel().rows.findIndex(row => row.original.key === expandedKey)
|
||||
if (index < 0) { requestedExpansion.current = null; return }
|
||||
if (requestedExpansion.current !== expandedKey) {
|
||||
requestedExpansion.current = expandedKey
|
||||
table.setPageIndex(Math.floor(index / pageSize))
|
||||
}
|
||||
}, [groups.length, expandedKey, pageIndex, pageSize, table])
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<RouteIcon aria-hidden="true" className="size-4" />}
|
||||
title={emptyTitle}
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={groups.length}
|
||||
pagination={groups.length > 50 || pageSize !== 50}
|
||||
tableLayout={{ dense: true, columnsPinnable: true }}
|
||||
onRowClick={(row) => {
|
||||
onExpandedChange?.(expandedKey === row.key ? null : row.key)
|
||||
}}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row cursor-pointer", expandedKey && "data-[state=selected]:bg-muted/30"),
|
||||
}}
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { RecursiveRoutesDataGrid, type RecursiveRoutesDataGridProps, inferCountry }
|
||||
@@ -0,0 +1,296 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable } from "@/components/reui/data-grid"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { CommRec } from "@/lib/route-optimizer-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { ArrowRightIcon, PinIcon, RefreshCwIcon } from "lucide-react"
|
||||
|
||||
function Chip({ children, color }: { children: React.ReactNode; color?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border",
|
||||
color ?? "bg-muted text-muted-foreground border-border",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ProbChip({ prob, best }: { prob: number | null; best?: boolean }) {
|
||||
if (prob === null) return <span className="text-muted-foreground" aria-label="Индекс неизвестен">—</span>
|
||||
return (
|
||||
<Chip
|
||||
color={
|
||||
best
|
||||
? "bg-success/10 text-success border-success/20"
|
||||
: "bg-muted text-muted-foreground border-border"
|
||||
}
|
||||
>
|
||||
{prob} б.
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
|
||||
interface RouteOptimizerCommRecsDataGridProps {
|
||||
recs: CommRec[]
|
||||
homeId: string
|
||||
pinned: Set<string>
|
||||
applied: Set<string>
|
||||
applying: Set<string>
|
||||
onPin: (key: string) => void
|
||||
simulation?: boolean
|
||||
onApply: (community: string, homeId: string) => void
|
||||
}
|
||||
|
||||
function RouteOptimizerCommRecsDataGrid({
|
||||
recs,
|
||||
homeId,
|
||||
pinned,
|
||||
applied,
|
||||
applying,
|
||||
onPin,
|
||||
onApply,
|
||||
simulation = false,
|
||||
}: RouteOptimizerCommRecsDataGridProps) {
|
||||
const [query, setQuery] = useState("")
|
||||
const rows = useMemo(
|
||||
() => recs.map((r, idx) => ({ ...r, _rowKey: `${homeId}::${r.community}::${idx}` })),
|
||||
[homeId, recs],
|
||||
)
|
||||
|
||||
const filtered = useMemo(() => rows.filter(row =>
|
||||
[row.community, row.communityName, row.current?.wan, row.current?.jh, row.current?.exit, row.current?.gateway,
|
||||
row.recommended?.wan, row.recommended?.jh, row.recommended?.exit, row.recommended?.gateway]
|
||||
.join(" ").toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())), [rows, query])
|
||||
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, (typeof rows)[number]>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "community",
|
||||
size: 180,
|
||||
accessorKey: "community",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Community" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<div className="font-mono text-xs font-medium">{row.original.community}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{row.original.communityName}</div>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Community",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "current",
|
||||
size: 260,
|
||||
header: () => (
|
||||
<span className="text-[11px] font-medium text-muted-foreground">
|
||||
Базовый расчёт (WAN → JH → Exit)
|
||||
</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
if (!r.current) return <span className="text-muted-foreground text-xs">—</span>
|
||||
return (
|
||||
<div className="text-xs flex items-center gap-1 flex-wrap">
|
||||
<span className="font-mono font-medium text-info ">
|
||||
{r.current.wan}
|
||||
</span>
|
||||
<ArrowRightIcon aria-hidden="true" className="size-3 text-muted-foreground shrink-0" />
|
||||
<span>{r.current.jh}</span>
|
||||
<ArrowRightIcon aria-hidden="true" className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">{r.current.exit}</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
({r.current.gateway})
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "recommended",
|
||||
size: 260,
|
||||
header: () => <span className="text-[11px] font-medium text-muted-foreground">Рекомендуемый</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const pinKey = `${homeId}::${r.community}`
|
||||
const isPinned = pinned.has(pinKey)
|
||||
if (!r.recommended) return <span className="text-muted-foreground text-xs">—</span>
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs flex items-center gap-1 flex-wrap",
|
||||
r.shouldSwitch && !isPinned && "text-warning ",
|
||||
)}
|
||||
>
|
||||
<span className="font-mono font-medium">{r.recommended.wan}</span>
|
||||
<ArrowRightIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" />
|
||||
<span>{r.recommended.jh}</span>
|
||||
<ArrowRightIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" />
|
||||
<span>{r.recommended.exit}</span>
|
||||
{r.shouldSwitch && r.current && !isPinned && (
|
||||
<span className="ml-1 text-[10px] font-bold bg-warning/10 border border-warning/20 px-1.5 py-0.5 rounded">
|
||||
+{r.recommended.prob - r.current.prob} б.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "probability",
|
||||
size: 160,
|
||||
header: () => (
|
||||
<span className="text-[11px] font-medium text-muted-foreground block text-center">
|
||||
Индекс (баз / рек)
|
||||
</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const pinKey = `${homeId}::${r.community}`
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<ProbChip prob={r.current?.prob ?? null} />
|
||||
<span className="text-muted-foreground text-[10px]">/</span>
|
||||
<ProbChip prob={r.recommended?.prob ?? null} best={r.shouldSwitch && !pinned.has(pinKey)} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 240,
|
||||
header: () => (
|
||||
<span className="text-[11px] font-medium text-muted-foreground block text-right">
|
||||
Действие
|
||||
</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const pinKey = `${homeId}::${r.community}`
|
||||
const isPinned = pinned.has(pinKey)
|
||||
const isApplied = simulation && applied.has(pinKey)
|
||||
const isApplying = simulation && applying.has(pinKey)
|
||||
const canApply = simulation && r.shouldSwitch && !isPinned && !isApplied
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
{isPinned && <PinIcon aria-hidden="true" className="size-3 text-info fill-info" />}
|
||||
<Button type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn("h-7 text-xs", isPinned && "text-info border-info/30")}
|
||||
onClick={() => onPin(pinKey)}
|
||||
>
|
||||
<PinIcon aria-hidden="true" className={cn("size-3", isPinned && "fill-current")} />
|
||||
{isPinned ? "Открепить" : "Закрепить"}
|
||||
</Button>
|
||||
{canApply && (
|
||||
<Button type="button"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={isApplying}
|
||||
onClick={() => onApply(r.community, homeId)}
|
||||
>
|
||||
{isApplying ? (
|
||||
<RefreshCwIcon aria-hidden="true" className="size-3 animate-spin" />
|
||||
) : (
|
||||
"Симулировать"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{isApplied && (
|
||||
<span className="text-[10px] text-success font-medium">
|
||||
✓ симуляция завершена
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-right"),
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[applied, applying, homeId, onApply, onPin, pinned, simulation],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["community"], end: [] } },
|
||||
data: filtered,
|
||||
columns,
|
||||
getRowId: (row) => row._rowKey,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(filtered.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [filtered.length, pageIndex, pageSize, table])
|
||||
|
||||
return (
|
||||
<section aria-label="Рекомендации BGP Community" className="min-w-0">
|
||||
<div className="px-5 py-3 space-y-2">
|
||||
<label className="block text-xs">Поиск Community
|
||||
<Input className="mt-1" value={query} onChange={event => { setQuery(event.target.value); table.setPageIndex(0) }} placeholder="Community, название или шлюз" />
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">Community: {filtered.length} из {rows.length}. Индексы и пути — расчётная модель.</p>
|
||||
</div>
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={filtered.length}
|
||||
pagination={filtered.length > 50 || pageSize !== 50}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b bg-muted/20",
|
||||
bodyRow: cn(
|
||||
"group/row hover:bg-muted/30",
|
||||
"[&:has([data-comm-switch=true])]:bg-warning/5",
|
||||
"[&:has([data-comm-applied=true])]:bg-success/5",
|
||||
),
|
||||
}}
|
||||
tableLayout={{ dense: true, width: "fixed", columnsPinnable: true }}
|
||||
>
|
||||
<DataGridContainer border={false}>
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export { RouteOptimizerCommRecsDataGrid, type RouteOptimizerCommRecsDataGridProps }
|
||||
@@ -0,0 +1,314 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { FullRoute } from "@/lib/route-optimizer-data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { ArrowRightIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
function Chip({ children, color }: { children: React.ReactNode; color?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border",
|
||||
color ?? "bg-muted text-muted-foreground border-border",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ProbChip({ prob, best }: { prob: number; best?: boolean }) {
|
||||
return (
|
||||
<Chip
|
||||
color={
|
||||
best
|
||||
? "bg-success/10 text-success border-success/20"
|
||||
: "bg-muted text-muted-foreground border-border"
|
||||
}
|
||||
>
|
||||
{prob} б.
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfChip({ conf }: { conf: string }) {
|
||||
const map: Record<string, string> = {
|
||||
HIGH: "bg-info/10 text-info border-info/20",
|
||||
MEDIUM: "bg-warning/10 text-warning border-warning/20",
|
||||
LOW: "bg-destructive/10 text-destructive border-destructive/20",
|
||||
}
|
||||
return <Chip color={map[conf] ?? map.LOW}>{conf}</Chip>
|
||||
}
|
||||
|
||||
interface RouteOptimizerFullRoutesDataGridProps {
|
||||
routes: FullRoute[]
|
||||
bestId?: string
|
||||
}
|
||||
|
||||
function RouteOptimizerFullRoutesDataGrid({
|
||||
routes,
|
||||
bestId,
|
||||
}: RouteOptimizerFullRoutesDataGridProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const visibleRoutes = expanded ? routes : routes.slice(0, 5)
|
||||
|
||||
const indexed = useMemo(
|
||||
() => visibleRoutes.map((r, index) => ({ ...r, _index: index })),
|
||||
[visibleRoutes],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, FullRoute & { _index: number }>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "index",
|
||||
size: 112,
|
||||
header: () => <span className="text-[11px] font-medium text-muted-foreground"># Маршрут</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const isBest = row.original.id === bestId || row.original._index === 0
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-mono text-muted-foreground min-w-6 tabular-nums">
|
||||
{row.original._index + 1}
|
||||
</span>
|
||||
{isBest && (
|
||||
<span className="text-[9px] font-bold uppercase tracking-wide text-success bg-success/10 border border-success/20 px-1.5 py-0.5 rounded">
|
||||
Лучший
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||
},
|
||||
{
|
||||
id: "wanJh",
|
||||
size: 264,
|
||||
header: () => <span className="text-[11px] font-medium text-muted-foreground">WAN → JH</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="font-mono font-semibold text-info ">{r.wan.name}</span>
|
||||
<ArrowRightIcon aria-hidden="true" className="size-3 text-muted-foreground shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium">{r.jh.label}</div>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">
|
||||
Модель: {r.hw.pingMs} мс · ↓{r.hw.dlMbps} ↑{r.hw.ulMbps}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "jhExit",
|
||||
size: 240,
|
||||
header: () => <span className="text-[11px] font-medium text-muted-foreground">JH → Exit</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<div className="text-xs">
|
||||
<div className="flex items-center gap-1 font-medium">
|
||||
<Flag code={r.exit.country} />
|
||||
{r.exit.label}
|
||||
<span className="text-[10px] text-muted-foreground">({r.exit.site})</span>
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">
|
||||
Модель: {r.je.pingMs} мс · ↓{r.je.dlMbps} ↑{r.je.ulMbps}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "totalPing",
|
||||
size: 144,
|
||||
accessorFn: (row) => row.hw.pingMs + row.je.pingMs,
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Ping модели" className="mx-auto" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const totalPing = row.original.hw.pingMs + row.original.je.pingMs
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-xs text-center block",
|
||||
totalPing < 40
|
||||
? "text-success "
|
||||
: totalPing < 80
|
||||
? "text-warning "
|
||||
: "text-destructive",
|
||||
)}
|
||||
>
|
||||
{totalPing} мс
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Ping модели",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bw",
|
||||
size: 144,
|
||||
header: () => (
|
||||
<span className="text-[11px] font-medium text-muted-foreground block text-center">
|
||||
BW (мин)
|
||||
</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const minDl = Math.min(r.hw.dlMbps, r.je.dlMbps)
|
||||
const minUl = Math.min(r.hw.ulMbps, r.je.ulMbps)
|
||||
return (
|
||||
<div className="font-mono text-xs text-muted-foreground text-center">
|
||||
<div>↓{minDl}</div>
|
||||
<div>↑{minUl}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "score",
|
||||
size: 80,
|
||||
accessorKey: "score",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Score" className="mx-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs font-semibold text-center block">
|
||||
{row.original.score}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Score",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prob",
|
||||
size: 104,
|
||||
accessorKey: "probabilityOptimal",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Индекс" className="mx-auto" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const isBest = row.original.id === bestId || row.original._index === 0
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<ProbChip prob={row.original.probabilityOptimal} best={isBest} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Индекс",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "confidence",
|
||||
size: 176,
|
||||
accessorKey: "confidence",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Категория модели" className="mx-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-center">
|
||||
<ConfChip conf={row.original.confidence} />
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Категория модели",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-center"),
|
||||
},
|
||||
},
|
||||
],
|
||||
[bestId],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["index"], end: [] } },
|
||||
data: indexed,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(indexed.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [indexed.length, pageIndex, pageSize, table])
|
||||
|
||||
return (
|
||||
<section aria-label="Полные маршруты оптимизатора" className="min-w-0">
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={visibleRoutes.length}
|
||||
pagination={expanded && (visibleRoutes.length > 50 || pageSize !== 50)}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b bg-muted/20",
|
||||
bodyRow: cn("group/row hover:bg-muted/30", "has-[[data-route-best=true]]:bg-success/5"),
|
||||
}}
|
||||
tableLayout={{ dense: true, width: "fixed", columnsPinnable: true }}
|
||||
>
|
||||
<DataGridContainer border={false}>
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
{routes.length > 5 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setExpanded((v) => !v); table.setPageIndex(0) }}
|
||||
className="w-full min-h-11 py-2 text-xs text-muted-foreground hover:text-foreground transition-colors border-t flex items-center justify-center gap-1"
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
<ChevronUpIcon aria-hidden="true" className="size-3" />
|
||||
Свернуть
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDownIcon aria-hidden="true" className="size-3" />
|
||||
{routes.length > 50 ? `Открыть все ${routes.length} комбинаций` : `Показать все ${routes.length} комбинаций`}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export { RouteOptimizerFullRoutesDataGrid, type RouteOptimizerFullRoutesDataGridProps }
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import type {OspfOptimizerObserved} from "@mmapp/contracts"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { NetworkIcon } from "lucide-react"
|
||||
|
||||
function describeObserved(metric:OspfOptimizerObserved["ping"]|undefined,unit:string){
|
||||
if(!metric || metric.value===null)return "Измерение: —"
|
||||
const source=metric.source==="speed-probe"?"Проба":"Трафик интерфейса"
|
||||
const date=metric.measuredAt?new Date(metric.measuredAt).toLocaleString("ru-RU"):"дата неизвестна"
|
||||
return source+": "+metric.value+" "+unit+" · "+date
|
||||
}
|
||||
|
||||
export interface OspfPreviewInterfaceRow {
|
||||
observed?: OspfOptimizerObserved
|
||||
id: string
|
||||
interface: string
|
||||
currentCost: number
|
||||
optimalCost: number
|
||||
score: number
|
||||
pingMs: number
|
||||
dlMbps: number
|
||||
ulMbps: number
|
||||
}
|
||||
|
||||
interface RouteOptimizerOspfPreviewDataGridProps {
|
||||
rows: OspfPreviewInterfaceRow[]
|
||||
error?: string | null
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
function RouteOptimizerOspfPreviewDataGrid({
|
||||
rows,
|
||||
error,
|
||||
loading,
|
||||
}: RouteOptimizerOspfPreviewDataGridProps) {
|
||||
const [query, setQuery] = useState("")
|
||||
const filteredRows = useMemo(() => {
|
||||
const value = query.trim().toLowerCase()
|
||||
return value ? rows.filter(row => `${row.interface} ${row.id}`.toLowerCase().includes(value)) : rows
|
||||
}, [rows, query])
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, OspfPreviewInterfaceRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "interface",
|
||||
size: 120,
|
||||
accessorKey: "interface",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Интерфейс" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => <span className="block truncate font-mono" title={row.original.interface}>{row.original.interface}</span>,
|
||||
meta: {
|
||||
headerTitle: "Интерфейс",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cost",
|
||||
size: 144,
|
||||
accessorKey: "currentCost",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Cost" />,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<span className="font-mono">
|
||||
<span className="text-info ">{r.currentCost}</span>
|
||||
{" → "}
|
||||
<span
|
||||
className={
|
||||
r.currentCost === r.optimalCost
|
||||
? "text-success "
|
||||
: "text-warning "
|
||||
}
|
||||
>
|
||||
{r.optimalCost}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: "Cost", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "score",
|
||||
size: 96,
|
||||
accessorKey: "score",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Score" />,
|
||||
cell: ({ row }) => <span className="font-mono">{row.original.score}</span>,
|
||||
meta: { headerTitle: "Score", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "pingMs",
|
||||
size: 288,
|
||||
accessorKey: "pingMs",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Ping модели" />,
|
||||
cell: ({ row }) => <div><span className="font-mono">{row.original.pingMs}ms</span><p className="text-[10px] text-muted-foreground">{describeObserved(row.original.observed?.ping,"мс")}</p></div>,
|
||||
meta: { headerTitle: "Ping модели", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "speed",
|
||||
size: 336,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Speed (dl/ul) · модель</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<span className="font-mono">
|
||||
↓{r.dlMbps} / ↑{r.ulMbps}<span className="block text-[10px] text-muted-foreground">↓ {describeObserved(r.observed?.dl,"Мбит/с")}<br />↑ {describeObserved(r.observed?.ul,"Мбит/с")}</span>
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["interface"], end: [] } },
|
||||
data: filteredRows,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(filteredRows.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [filteredRows.length, pageIndex, pageSize, table])
|
||||
|
||||
if (error && rows.length === 0) {
|
||||
return (
|
||||
<p role="alert" className="px-3 py-2 text-xs text-destructive">Ошибка preview: {error}</p>
|
||||
)
|
||||
}
|
||||
|
||||
if (!loading && rows.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<NetworkIcon aria-hidden="true" className="size-4" />}
|
||||
title="Интерфейсы OSPF не найдены для выбранного сервера"
|
||||
className="border-0 py-6 text-xs"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="Preview интерфейсов OSPF" className="min-w-0">
|
||||
{error && <p role="alert" className="px-3 py-2 text-xs text-warning">Ошибка preview: {error}. Последний снимок; актуальность неизвестна.</p>}
|
||||
{loading && rows.length > 0 && <p role="status" className="px-3 py-2 text-xs text-muted-foreground">Обновление preview OSPF…</p>}
|
||||
<div className="flex min-w-0 flex-col gap-2 px-3 py-3">
|
||||
<Input aria-label="Поиск интерфейсов preview OSPF" placeholder="Имя интерфейса…" value={query} onChange={event => { setQuery(event.target.value); table.setPageIndex(0) }} />
|
||||
<p role="status" className="text-xs text-muted-foreground">Найдено {filteredRows.length} из {rows.length} интерфейсов. Фильтр меняет только отображение preview.</p>
|
||||
</div>
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={filteredRows.length}
|
||||
pagination={filteredRows.length > 50 || pageSize !== 50}
|
||||
emptyMessage="Нет интерфейсов по запросу"
|
||||
isLoading={loading && rows.length === 0}
|
||||
loadingMode="skeleton"
|
||||
tableClassNames={{ headerRow: "border-b bg-muted/30", bodyRow: "group/row" }}
|
||||
tableLayout={{ dense: true, width: "fixed", columnsPinnable: true }}
|
||||
>
|
||||
<DataGridContainer border={false}>
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export { RouteOptimizerOspfPreviewDataGrid, type RouteOptimizerOspfPreviewDataGridProps }
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable } from "@/components/reui/data-grid"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { HomeRouter, JumpHost, WanJhLeg } from "@/lib/route-optimizer-data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { WifiIcon } from "lucide-react"
|
||||
|
||||
function LossChip({ loss }: { loss: number }) {
|
||||
if (loss === 0) return <span className="text-success text-[11px] font-mono">0%</span>
|
||||
return (
|
||||
<span className={cn("text-[11px] font-mono", loss > 1 ? "text-destructive" : "text-warning")}>
|
||||
{loss}%
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface RouteOptimizerWanMatrixDataGridProps {
|
||||
home: HomeRouter
|
||||
legs: WanJhLeg[]
|
||||
jumpHosts: JumpHost[]
|
||||
}
|
||||
|
||||
function RouteOptimizerWanMatrixDataGrid({
|
||||
home,
|
||||
legs,
|
||||
jumpHosts,
|
||||
}: RouteOptimizerWanMatrixDataGridProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const [wanQuery, setWanQuery] = useState("")
|
||||
const [jhQuery, setJhQuery] = useState("")
|
||||
const [jhPage, setJhPage] = useState(0)
|
||||
const filteredWans = useMemo(() => home.wans.filter(wan =>
|
||||
[wan.name, wan.iface, wan.isp, wan.ip, wan.id].join(" ").toLocaleLowerCase().includes(wanQuery.trim().toLocaleLowerCase())), [home.wans, wanQuery])
|
||||
const filteredHosts = useMemo(() => jumpHosts.filter(jh =>
|
||||
[jh.label, jh.site, jh.ip, jh.country, jh.id].join(" ").toLocaleLowerCase().includes(jhQuery.trim().toLocaleLowerCase())), [jumpHosts, jhQuery])
|
||||
const lastHostPage = Math.max(0, Math.ceil(filteredHosts.length / 5) - 1)
|
||||
const hostPage = Math.min(jhPage, lastHostPage)
|
||||
if (jhPage !== hostPage) setJhPage(hostPage)
|
||||
const visibleHosts = useMemo(() => filteredHosts.slice(hostPage * 5, hostPage * 5 + 5), [filteredHosts, hostPage])
|
||||
const { bestScore, legMap } = useMemo(() => {
|
||||
let bestScore = legs.length ? -Infinity : 0
|
||||
const legMap = new Map<string, Map<string, WanJhLeg>>()
|
||||
for (const leg of legs) {
|
||||
bestScore = Math.max(bestScore, leg.score)
|
||||
let byHost = legMap.get(leg.wanId)
|
||||
if (!byHost) { byHost = new Map(); legMap.set(leg.wanId, byHost) }
|
||||
// Preserve the first matching leg, as the former Array.find did.
|
||||
if (!byHost.has(leg.jhId)) byHost.set(leg.jhId, leg)
|
||||
}
|
||||
return { bestScore, legMap }
|
||||
}, [legs])
|
||||
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, HomeRouter["wans"][number]>[]>(() => {
|
||||
const base: ColumnDef<DataGridFeatures, HomeRouter["wans"][number]>[] = [
|
||||
{
|
||||
id: "wan",
|
||||
size: isMobile ? 112 : 180,
|
||||
accessorKey: "name",
|
||||
header: () => <span className="text-[11px] font-medium text-muted-foreground">WAN-аплинк</span>,
|
||||
cell: ({ row }) => {
|
||||
const wan = row.original
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<WifiIcon aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p title={wan.name} className="truncate font-mono text-xs font-semibold">{wan.name}</p>
|
||||
<p title={wan.iface} className="truncate text-[10px] text-muted-foreground">{wan.iface}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||
},
|
||||
{
|
||||
id: "isp",
|
||||
size: 160,
|
||||
header: () => <span className="text-[11px] font-medium text-muted-foreground">ISP / IP</span>,
|
||||
cell: ({ row }) => {
|
||||
const wan = row.original
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-medium">{wan.isp}</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">{wan.ip}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "bandwidth",
|
||||
size: 112,
|
||||
header: () => (
|
||||
<span className="text-[11px] font-medium text-muted-foreground block text-right">
|
||||
Лимит модели
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const wan = row.original
|
||||
return (
|
||||
<div className="text-right">
|
||||
<p className="font-mono text-xs">↓{wan.maxDl}</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">↑{wan.maxUl} Мбит/с</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const jhCols: ColumnDef<DataGridFeatures, HomeRouter["wans"][number]>[] = visibleHosts.map((jh) => ({
|
||||
id: `jh-${jh.id}`,
|
||||
size: isMobile ? 160 : 200,
|
||||
header: () => (
|
||||
<div className="text-center">
|
||||
<div className="text-[11px] font-medium text-muted-foreground">{jh.label}</div>
|
||||
<div className="font-mono font-normal text-[10px] opacity-60 flex items-center justify-center gap-1">
|
||||
<Flag code={jh.country} />
|
||||
{jh.site} · {jh.ip}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const wan = row.original
|
||||
const leg = legMap.get(wan.id)?.get(jh.id)
|
||||
if (!leg) return <span className="text-center text-muted-foreground text-xs block">—</span>
|
||||
const isBest = leg.score === bestScore
|
||||
return (
|
||||
<div className={cn("text-center", isBest && "bg-success/5")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-0.5 rounded-md px-2 py-1.5 transition-colors",
|
||||
isBest ? "border border-success/20 bg-success/8" : "border border-transparent",
|
||||
)}
|
||||
>
|
||||
{isBest && (
|
||||
<span className="text-[9px] font-bold uppercase tracking-wide text-success mb-0.5">
|
||||
★ ЛУЧШИЙ
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-xs font-semibold",
|
||||
leg.observed && leg.observed.pingMs === null ? "text-muted-foreground" : (leg.observed?.pingMs ?? leg.pingMs) < 10
|
||||
? "text-success "
|
||||
: (leg.observed?.pingMs ?? leg.pingMs) < 25
|
||||
? "text-foreground"
|
||||
: "text-warning ",
|
||||
)}
|
||||
>
|
||||
{leg.observed ? (leg.observed.pingMs ?? "—") : leg.pingMs} мс
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
↓{leg.observed ? (leg.observed.dlMbps ?? "—") : leg.dlMbps} ↑{leg.observed ? (leg.observed.ulMbps ?? "—") : leg.ulMbps} Мбит/с
|
||||
</span>
|
||||
{leg.observed && <span className="text-[10px] text-muted-foreground">
|
||||
{leg.observed.probeId ? <>Снимок пробы · Ping: {leg.observed.pingAt ? <time dateTime={leg.observed.pingAt}>{new Date(leg.observed.pingAt).toLocaleString("ru-RU")}</time> : "дата неизвестна"}. Скорость: дата неизвестна.</> : "Измерения отсутствуют"}
|
||||
</span>}
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-[10px] font-mono text-foreground/70">модель {leg.score}</span>
|
||||
{(leg.observed ? (leg.observed.lossPct ?? 0) : leg.loss) > 0 && <LossChip loss={leg.observed?.lossPct ?? leg.loss} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "min-w-[130px] text-center"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "min-w-[130px]"),
|
||||
},
|
||||
}))
|
||||
|
||||
const last = jhCols[jhCols.length - 1]
|
||||
if (last?.meta) {
|
||||
last.meta.headerClassName = cn(last.meta.headerClassName, DATA_GRID_CELL_PAD_LAST.replace("py-3", ""))
|
||||
last.meta.cellClassName = DATA_GRID_CELL_PAD_LAST
|
||||
}
|
||||
|
||||
return [...base, ...jhCols]
|
||||
}, [bestScore, visibleHosts, legMap, isMobile])
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["wan"], end: [] } },
|
||||
manualSorting: true,
|
||||
data: filteredWans,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(filteredWans.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [filteredWans.length, pageIndex, pageSize, table])
|
||||
|
||||
return (
|
||||
<section aria-label="Матрица WAN и Jump Host" className="min-w-0">
|
||||
<div className="flex flex-wrap gap-3 px-5 py-3">
|
||||
<label className="min-w-0 flex-1 basis-48 text-xs">Поиск WAN
|
||||
<Input className="mt-1" value={wanQuery} onChange={event => { setWanQuery(event.target.value); table.setPageIndex(0) }} placeholder="Название, интерфейс, ISP или IP" />
|
||||
</label>
|
||||
<label className="min-w-0 flex-1 basis-48 text-xs">Поиск Jump Host
|
||||
<Input className="mt-1" value={jhQuery} onChange={event => { setJhQuery(event.target.value); setJhPage(0) }} placeholder="Название, площадка или IP" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 px-5 pb-3 text-xs text-muted-foreground">
|
||||
<span>WAN: {filteredWans.length} из {home.wans.length}. Jump Host: {filteredHosts.length} из {jumpHosts.length}.</span>
|
||||
{filteredHosts.length > 5 && <>
|
||||
<span>Столбцы {hostPage * 5 + 1}–{Math.min((hostPage + 1) * 5, filteredHosts.length)} из {filteredHosts.length}</span>
|
||||
<Button variant="outline" size="sm" aria-label="Предыдущие Jump Host" disabled={hostPage === 0} onClick={() => setJhPage(hostPage - 1)}>Назад</Button>
|
||||
<Button variant="outline" size="sm" aria-label="Следующие Jump Host" disabled={hostPage === lastHostPage} onClick={() => setJhPage(hostPage + 1)}>Далее</Button>
|
||||
</>}
|
||||
</div>
|
||||
{legs.some(leg=>leg.observed?.probeId) && <p className="px-5 pb-3 text-xs text-muted-foreground">Скорость — снимок пары устройств. Сопоставление с WAN расчётное. ↓ к Home, ↑ от Home.</p>}
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={filteredWans.length}
|
||||
pagination={filteredWans.length > 50 || pageSize !== 50}
|
||||
tableClassNames={{ headerRow: "border-b bg-muted/20", bodyRow: "group/row hover:bg-muted/30" }}
|
||||
tableLayout={{ dense: true, width: "fixed", columnsPinnable: true }}
|
||||
>
|
||||
<DataGridContainer border={false}>
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export { RouteOptimizerWanMatrixDataGrid, type RouteOptimizerWanMatrixDataGridProps }
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client"
|
||||
|
||||
import type { Server } from "@/lib/data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { RosBadge, rosVer } from "@/components/data-grids/servers-data-grid"
|
||||
import { CheckCircleIcon, XCircleIcon, RefreshCwIcon } from "lucide-react"
|
||||
|
||||
const ROS_FEATURES = [
|
||||
{ name: "WireGuard", minVer: 701, label: "7.1+", desc: "WireGuard VPN туннели" },
|
||||
{ name: "Container", minVer: 704, label: "7.4+", desc: "Docker-совместимые контейнеры" },
|
||||
{ name: "BFD", minVer: 705, label: "7.5+", desc: "Bidirectional Forwarding Detection" },
|
||||
{ name: "Large Communities", minVer: 707, label: "7.7+", desc: "BGP Large Communities (RFC 8092)" },
|
||||
{ name: "VXLAN", minVer: 710, label: "7.10+", desc: "VXLAN overlay туннели" },
|
||||
{ name: "RPKI", minVer: 713, label: "7.13+", desc: "Route Origin Validation" },
|
||||
{ name: "BGP Flowspec", minVer: 714, label: "7.14+", desc: "BGP Flow Spec (RFC 8955)" },
|
||||
{ name: "IPv6 Firewall", minVer: 715, label: "7.15+", desc: "Расширенный IPv6 Firewall" },
|
||||
{ name: "REST API v2", minVer: 716, label: "7.16+", desc: "Обновлённый REST API" },
|
||||
{ name: "VRF Enhanced", minVer: 717, label: "7.17+", desc: "Расширенная поддержка VRF" },
|
||||
]
|
||||
|
||||
interface ServerExpandedDetailProps {
|
||||
server: Server
|
||||
isLive: boolean
|
||||
canWrite: boolean
|
||||
isPolling: boolean
|
||||
onPoll: () => void
|
||||
}
|
||||
|
||||
function ServerExpandedDetail({ server: s, isLive, isPolling, canWrite, onPoll }: ServerExpandedDetailProps) {
|
||||
const ver = rosVer(s.os)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 px-5 py-5 bg-muted/20">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
|
||||
{s.model && s.model !== "—" && (
|
||||
<span className="text-muted-foreground">Модель: <span className="font-mono text-foreground">{s.model}</span></span>
|
||||
)}
|
||||
{s.uptime && (
|
||||
<span className="text-muted-foreground">Uptime: <span className="font-mono text-foreground">{s.uptime}</span></span>
|
||||
)}
|
||||
{s.cpuLoad != null && (
|
||||
<span className="text-muted-foreground">CPU: <span className={cn("font-mono font-semibold", s.cpuLoad > 80 ? "text-red-400" : s.cpuLoad > 50 ? "text-amber-400" : "text-emerald-400")}>{s.cpuLoad}%</span></span>
|
||||
)}
|
||||
{s.asn && (
|
||||
<span className="text-muted-foreground">ASN: <span className="font-mono text-foreground">{s.asn}</span></span>
|
||||
)}
|
||||
{s.ipv6Address && (
|
||||
<span className="text-muted-foreground">IPv6: <span className="font-mono text-sky-400">{s.ipv6Address}</span></span>
|
||||
)}
|
||||
{s.vrfNames?.map((v) => (
|
||||
<span key={v} className="text-muted-foreground">VRF: <span className="font-mono text-foreground">{v}</span></span>
|
||||
))}
|
||||
{s.comment && <span className="text-muted-foreground italic">{s.comment}</span>}
|
||||
{s.polledAt ? (
|
||||
<span className="text-muted-foreground/50 text-[11px]">
|
||||
Опрошен: {new Date(s.polledAt).toLocaleString("ru")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-500/70 text-[11px]">⚠ Ещё не опрашивался</span>
|
||||
)}
|
||||
</div>
|
||||
{isLive && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs shrink-0"
|
||||
disabled={!canWrite || isPolling}
|
||||
onClick={(e) => { e.stopPropagation(); onPoll() }}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-3.5", isPolling && "animate-spin")} />
|
||||
{isPolling ? "Опрос…" : "Опросить сейчас"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Возможности RouterOS
|
||||
</p>
|
||||
<RosBadge os={s.os} />
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{ver >= 715
|
||||
? "✓ Актуальная версия — все ключевые фичи доступны"
|
||||
: ver >= 710
|
||||
? "⚠ Рекомендуется обновление до 7.15+"
|
||||
: s.os !== "—"
|
||||
? "✗ Устаревшая версия — требуется обновление"
|
||||
: "Нет данных — нажмите «Опросить сейчас»"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-2">
|
||||
{ROS_FEATURES.map((f) => {
|
||||
const ok = ver >= f.minVer
|
||||
return (
|
||||
<div
|
||||
key={f.name}
|
||||
className={cn(
|
||||
"flex items-start gap-2 rounded-md border px-3 py-2.5 transition-colors",
|
||||
ok ? "border-emerald-500/25 bg-emerald-500/5" : "border-border/40 bg-background/40 opacity-60",
|
||||
)}
|
||||
>
|
||||
{ok
|
||||
? <CheckCircleIcon className="size-3.5 text-emerald-500 shrink-0 mt-0.5" />
|
||||
: <XCircleIcon className="size-3.5 text-muted-foreground/40 shrink-0 mt-0.5" />}
|
||||
<div className="min-w-0">
|
||||
<p className={cn("text-xs font-medium leading-tight truncate", ok ? "text-foreground" : "text-muted-foreground")}>
|
||||
{f.name}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight mt-0.5">
|
||||
{f.label} · {f.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { ServerExpandedDetail }
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { Filters } from "@/components/reui/filters/filters"
|
||||
import type { FilterQuery } from "@/components/reui/filters/filters-types"
|
||||
import { SERVER_FILTER_FIELDS } from "@/lib/data-filters/server-filter-fields"
|
||||
import { FILTER_LABELS_RU, FILTER_OPERATOR_LABELS_RU } from "@/lib/data-filters/filter-labels-ru"
|
||||
|
||||
export function ServerFilters({ query, onQueryChange }: {
|
||||
query: FilterQuery
|
||||
onQueryChange: (query: FilterQuery) => void
|
||||
}) {
|
||||
return <Filters fields={SERVER_FILTER_FIELDS} query={query} onQueryChange={onQueryChange}
|
||||
size="sm" showClear labels={FILTER_LABELS_RU} operatorLabels={FILTER_OPERATOR_LABELS_RU} />
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Server, ServerType } from "@/lib/data"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import { Badge, type BadgeProps } from "@/components/reui/badge"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { ServerExpandedDetail } from "@/components/data-grids/server-expanded-detail"
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
ChevronDownIcon,
|
||||
HomeIcon,
|
||||
ServerIcon,
|
||||
NetworkIcon,
|
||||
ShieldIcon,
|
||||
WifiIcon,
|
||||
MoreHorizontalIcon,
|
||||
ExternalLinkIcon,
|
||||
PencilIcon,
|
||||
RefreshCwIcon,
|
||||
PowerIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
const TYPE_LABELS: Record<ServerType, string> = {
|
||||
"jump-host": "JumpHost",
|
||||
"exit-node": "Exit Node",
|
||||
"home-router": "Home Router",
|
||||
}
|
||||
|
||||
const TYPE_VARIANTS: Record<ServerType, BadgeProps["variant"]> = {
|
||||
"jump-host": "primary-light",
|
||||
"exit-node": "info-light",
|
||||
"home-router": "success-light",
|
||||
}
|
||||
|
||||
function rosVer(os: string): number {
|
||||
const m = os.match(/(\d+)\.(\d+)/)
|
||||
if (!m) return 0
|
||||
return parseInt(m[1], 10) * 100 + parseInt(m[2], 10)
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: ServerType }) {
|
||||
const icon =
|
||||
type === "jump-host" ? <ServerIcon aria-hidden="true" />
|
||||
: type === "exit-node" ? <NetworkIcon aria-hidden="true" />
|
||||
: <HomeIcon aria-hidden="true" />
|
||||
return (
|
||||
<Badge variant={TYPE_VARIANTS[type]} className="text-foreground">
|
||||
{icon}{TYPE_LABELS[type]}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function RosBadge({ os }: { os: string }) {
|
||||
const v = rosVer(os)
|
||||
const cls = v >= 715
|
||||
? "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/20"
|
||||
: v >= 710
|
||||
? "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/20"
|
||||
: "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/20"
|
||||
return <span className={cn("text-xs font-mono border rounded px-2 py-0.5", cls)}>{os}</span>
|
||||
}
|
||||
|
||||
interface ServerRowActionsProps {
|
||||
canWrite: boolean
|
||||
busy: boolean
|
||||
server: Server
|
||||
isLive: boolean
|
||||
isPolling: boolean
|
||||
onEdit: (server: Server) => void
|
||||
onDelete: (id: string) => void
|
||||
onPoll: (id: string) => void
|
||||
onToggleStatus: (id: string) => void
|
||||
}
|
||||
|
||||
function ServerRowActions({
|
||||
canWrite,
|
||||
busy,
|
||||
server,
|
||||
isLive,
|
||||
isPolling,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onPoll,
|
||||
onToggleStatus,
|
||||
}: ServerRowActionsProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
disabled={busy}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"size-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
|
||||
"opacity-0 transition-[opacity,background-color,color,border-color]",
|
||||
"group-hover/row:opacity-100 focus-visible:opacity-100",
|
||||
"data-popup-open:opacity-100 data-popup-open:bg-muted",
|
||||
"hover:bg-muted hover:text-foreground hover:border-border",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Действия: ${server.name}`}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" side="bottom" className="w-52">
|
||||
<DropdownMenuItem onClick={() => window.open(`https://${server.host}`, "_blank")}>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
Открыть WebFig
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!canWrite} onClick={() => onEdit(server)}>
|
||||
<PencilIcon className="size-4" />
|
||||
Редактировать
|
||||
</DropdownMenuItem>
|
||||
{isLive && (
|
||||
<DropdownMenuItem onClick={() => onPoll(server.id)} disabled={!canWrite || isPolling}>
|
||||
<RefreshCwIcon className={cn("size-4", isPolling && "animate-spin")} />
|
||||
{isPolling ? "Опрос…" : "Опросить"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem disabled={!canWrite} onClick={() => onToggleStatus(server.id)}>
|
||||
<PowerIcon className="size-4" />
|
||||
{server.enabled ? "Отключить" : "Включить"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={!canWrite} variant="destructive" onClick={() => onDelete(server.id)}>
|
||||
<Trash2Icon className="size-4" />
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ServersDataGridProps {
|
||||
hasUnfilteredRows?: boolean
|
||||
canWrite: boolean
|
||||
busy?: boolean
|
||||
servers: Server[]
|
||||
isLive: boolean
|
||||
pollingIds: Set<string>
|
||||
onPoll: (id: string) => void
|
||||
onEdit: (server: Server) => void
|
||||
onDelete: (id: string) => void
|
||||
onToggleStatus: (id: string) => void
|
||||
}
|
||||
|
||||
function ServersDataGrid({
|
||||
canWrite,
|
||||
hasUnfilteredRows = false,
|
||||
busy = false,
|
||||
servers,
|
||||
isLive,
|
||||
pollingIds,
|
||||
onPoll,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleStatus,
|
||||
}: ServersDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, Server>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Имя / Хост" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const s = row.original
|
||||
const expanded = row.getIsExpanded()
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<Button type="button" variant="ghost" size="icon-xs"
|
||||
aria-label={`${expanded ? "Свернуть" : "Раскрыть"}: ${s.name}`}
|
||||
aria-expanded={expanded}
|
||||
onClick={event => { event.stopPropagation(); row.toggleExpanded() }}>
|
||||
{expanded
|
||||
? <ChevronDownIcon aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRightIcon aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground/40" />}
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{s.name}</p>
|
||||
<p className="text-xs font-mono text-muted-foreground">{s.host}</p>
|
||||
{s.ipv6Address && (
|
||||
<p className="text-[10px] font-mono text-sky-500/70 truncate max-w-[150px]" title={s.ipv6Address}>
|
||||
{s.ipv6Address}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Имя / Хост",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
expandedContent: (row: Server) => (
|
||||
<ServerExpandedDetail
|
||||
canWrite={canWrite}
|
||||
server={row}
|
||||
isLive={isLive}
|
||||
isPolling={pollingIds.has(row.id)}
|
||||
onPoll={() => onPoll(row.id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => <TypeBadge type={row.original.type} />,
|
||||
meta: { headerTitle: "Тип", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "model",
|
||||
accessorKey: "model",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Модель" />,
|
||||
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.model}</span>,
|
||||
meta: { headerTitle: "Модель", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "os",
|
||||
accessorKey: "os",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="RouterOS" />,
|
||||
cell: ({ row }) => <RosBadge os={row.original.os} />,
|
||||
meta: { headerTitle: "RouterOS", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "site",
|
||||
accessorKey: "site",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Площадка" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Flag code={row.original.country} />
|
||||
<span className="font-medium text-sm">{row.original.site}</span>
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: "Площадка", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "wan",
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground">WAN / LAN</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const s = row.original
|
||||
if (s.type === "home-router" && s.wanUplinks?.length) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{s.wanUplinks.map((w) => (
|
||||
<div key={w.id} className="flex items-center gap-1.5 text-[11px] font-mono">
|
||||
<WifiIcon className="size-3 text-sky-400 shrink-0" />
|
||||
<span className="font-semibold text-sky-600 dark:text-sky-400">{w.name}</span>
|
||||
<span className="text-muted-foreground">{w.isp}</span>
|
||||
<span className="text-muted-foreground">↓{w.maxDl}↑{w.maxUl}</span>
|
||||
</div>
|
||||
))}
|
||||
{s.lanSubnet && (
|
||||
<div className="text-[10px] font-mono text-muted-foreground mt-0.5">LAN {s.lanSubnet}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{s.wireGuardIfaces && s.wireGuardIfaces.length > 0 && (
|
||||
<div className="text-[11px] font-mono text-violet-500 dark:text-violet-400 flex items-center gap-1">
|
||||
<ShieldIcon className="size-3" />
|
||||
WG: {s.wireGuardIfaces.length} iface · {s.wireGuardIfaces.reduce((n, i) => n + i.peers.length, 0)} peers
|
||||
</div>
|
||||
)}
|
||||
{s.rpkiEnabled && (
|
||||
<div className="text-[10px] font-mono text-emerald-600 dark:text-emerald-400">RPKI ✓</div>
|
||||
)}
|
||||
{!s.wireGuardIfaces?.length && !s.rpkiEnabled && (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: "WAN / LAN", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "latency",
|
||||
accessorKey: "latency",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Задержка" className="w-full justify-end" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const s = row.original
|
||||
return (
|
||||
<span className={cn(
|
||||
"font-mono text-sm block text-right tabular-nums",
|
||||
s.latency == null ? "text-muted-foreground" : s.latency > 60 ? "text-[var(--status-degraded-fg)]" : "",
|
||||
)}>
|
||||
{s.latency == null ? "—" : `${s.latency} мс`}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Задержка",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => <div className="flex flex-col gap-1"><StatusBadge status={row.original.status} />{!row.original.enabled && <span className="text-xs text-muted-foreground">Отключён</span>}</div>,
|
||||
meta: { headerTitle: "Статус", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => {
|
||||
const s = row.original
|
||||
return (
|
||||
<ServerRowActions
|
||||
canWrite={canWrite}
|
||||
busy={busy}
|
||||
server={s}
|
||||
isLive={isLive}
|
||||
isPolling={pollingIds.has(s.id)}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onPoll={onPoll}
|
||||
onToggleStatus={onToggleStatus}
|
||||
/>
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
size: 56,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[canWrite, busy, isLive, onDelete, onEdit, onPoll, onToggleStatus, pollingIds],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
data: servers,
|
||||
columns,
|
||||
manualPagination: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 } },
|
||||
getRowId: (row) => row.id,
|
||||
getRowCanExpand: () => true,
|
||||
})
|
||||
|
||||
if (servers.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<ServerIcon className="size-4" />}
|
||||
title={hasUnfilteredRows ? "Нет совпадений" : "Нет серверов"}
|
||||
description={hasUnfilteredRows ? "Измените поиск или сбросьте фильтры" : canWrite ? "Добавьте первый MikroTik-сервер для мониторинга" : "Серверов нет. Добавление доступно пользователю с правом записи."}
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={servers.length}
|
||||
pagination={servers.length > 50 || table.state.pagination.pageSize !== 50}
|
||||
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { ServersDataGrid, rosVer, RosBadge, TypeBadge }
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { DataGridProps, DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||
|
||||
const DATA_GRID_CELL_PAD = "py-3"
|
||||
const DATA_GRID_CELL_PAD_FIRST = "pl-5 py-3"
|
||||
const DATA_GRID_CELL_PAD_LAST = "pr-4 py-3"
|
||||
|
||||
const DEFAULT_DATA_GRID_LAYOUT: NonNullable<DataGridProps<DataGridFeatures, object>["tableLayout"]> = {
|
||||
rowBorder: true,
|
||||
headerBackground: true,
|
||||
headerBorder: true,
|
||||
columnsResizable: false,
|
||||
}
|
||||
|
||||
const DEFAULT_DATA_GRID_CLASS_NAMES: NonNullable<DataGridProps<DataGridFeatures, object>["tableClassNames"]> = {
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: "group/row",
|
||||
}
|
||||
|
||||
const DATA_GRID_CONTAINER_CLASS = "w-full rounded-none border-0"
|
||||
|
||||
const DATA_PAGE_CARD_CLASS = "flex w-full flex-col overflow-hidden py-0 gap-0"
|
||||
|
||||
const DATA_PAGE_TOOLBAR_CLASS = "flex items-center gap-3 px-5 py-3 border-b flex-wrap"
|
||||
|
||||
export {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
DEFAULT_DATA_GRID_LAYOUT,
|
||||
DEFAULT_DATA_GRID_CLASS_NAMES,
|
||||
DATA_GRID_CONTAINER_CLASS,
|
||||
DATA_PAGE_CARD_CLASS,
|
||||
DATA_PAGE_TOOLBAR_CLASS,
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import type { Table } from "@tanstack/react-table"
|
||||
import { DATA_GRID_LABELS_RU } from "@/lib/data-grid-labels-ru"
|
||||
import {
|
||||
DataGrid,
|
||||
DataGridContainer,
|
||||
DataGridPagination,
|
||||
DataGridTable,
|
||||
type DataGridProps,
|
||||
type DataGridFeatures,
|
||||
} from "@/components/reui/data-grid"
|
||||
import {
|
||||
DATA_GRID_CONTAINER_CLASS,
|
||||
DEFAULT_DATA_GRID_CLASS_NAMES,
|
||||
DEFAULT_DATA_GRID_LAYOUT,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
|
||||
interface DataGridShellProps<TData extends object> {
|
||||
table: Table<DataGridFeatures, TData>
|
||||
recordCount: number
|
||||
children?: ReactNode
|
||||
pagination?: boolean
|
||||
isLoading?: boolean
|
||||
loadingMode?: DataGridProps<DataGridFeatures, TData>["loadingMode"]
|
||||
emptyMessage?: ReactNode
|
||||
onRowClick?: DataGridProps<DataGridFeatures, TData>["onRowClick"]
|
||||
tableLayout?: DataGridProps<DataGridFeatures, TData>["tableLayout"]
|
||||
tableClassNames?: DataGridProps<DataGridFeatures, TData>["tableClassNames"]
|
||||
}
|
||||
|
||||
function DataGridShell<TData extends object>({
|
||||
table,
|
||||
recordCount,
|
||||
children,
|
||||
pagination = false,
|
||||
isLoading,
|
||||
loadingMode,
|
||||
emptyMessage,
|
||||
onRowClick,
|
||||
tableLayout = DEFAULT_DATA_GRID_LAYOUT,
|
||||
tableClassNames = DEFAULT_DATA_GRID_CLASS_NAMES,
|
||||
}: DataGridShellProps<TData>) {
|
||||
return (
|
||||
<DataGrid
|
||||
className="w-full"
|
||||
i18n={{ labels: DATA_GRID_LABELS_RU }}
|
||||
table={table}
|
||||
recordCount={recordCount}
|
||||
isLoading={isLoading}
|
||||
loadingMode={loadingMode}
|
||||
emptyMessage={emptyMessage}
|
||||
onRowClick={onRowClick}
|
||||
tableLayout={tableLayout}
|
||||
tableClassNames={tableClassNames}
|
||||
>
|
||||
{children ?? (
|
||||
<DataGridContainer border={false} className={DATA_GRID_CONTAINER_CLASS}>
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
)}
|
||||
{pagination && recordCount > 0 && (
|
||||
<DataGridPagination className="px-5 pb-3" />
|
||||
)}
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridShell, type DataGridShellProps }
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client"
|
||||
|
||||
import type { Column } from "@tanstack/react-table"
|
||||
import type { DataGridFeatures } from "@/components/reui/data-grid"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon } from "lucide-react"
|
||||
|
||||
interface DataGridSortHeaderProps<TData extends object> {
|
||||
column: Column<DataGridFeatures, TData, unknown>
|
||||
title: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
function DataGridSortHeader<TData extends object>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: DataGridSortHeaderProps<TData>) {
|
||||
const sorted = column.getIsSorted()
|
||||
const canSort = column.getCanSort()
|
||||
|
||||
if (!canSort) {
|
||||
return (
|
||||
<span className={cn("text-xs font-medium text-muted-foreground", className)}>
|
||||
{title}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground",
|
||||
"hover:text-foreground transition-colors rounded-md -ml-1 px-1 py-0.5 focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2",
|
||||
className,
|
||||
)}
|
||||
onClick={column.getToggleSortingHandler()}
|
||||
>
|
||||
{title}
|
||||
{sorted === "asc" ? (
|
||||
<ArrowUpIcon aria-hidden="true" className="size-3 text-foreground" />
|
||||
) : sorted === "desc" ? (
|
||||
<ArrowDownIcon aria-hidden="true" className="size-3 text-foreground" />
|
||||
) : (
|
||||
<ArrowUpDownIcon aria-hidden="true" className="size-3 opacity-35" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridSortHeader, type DataGridSortHeaderProps }
|
||||
@@ -0,0 +1,498 @@
|
||||
"use client"
|
||||
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
CompactDataGrid,
|
||||
type CompactDataGridColumn,
|
||||
type CompactDataGridProps,
|
||||
} from "@/components/data-grids/compact-data-grid"
|
||||
import type {
|
||||
AlertEngineRuleDiagSnapshot,
|
||||
PingProbeSnapshot,
|
||||
ResourceServerSnapshot,
|
||||
ServerRestPingSnapshot,
|
||||
SpeedRunSnapshot,
|
||||
TrafficServerSnapshot,
|
||||
} from "@/lib/scheduler-run-snapshot"
|
||||
|
||||
type SnapshotRow = { id: string }
|
||||
|
||||
function withRowId<T extends { serverId?: number; probeId?: string; ruleId?: string; target?: string }>(
|
||||
rows: T[],
|
||||
idFn: (row: T, index: number) => string,
|
||||
): (T & SnapshotRow)[] {
|
||||
return rows.map((row, index) => ({ ...row, id: idFn(row, index) }))
|
||||
}
|
||||
|
||||
function SnapshotOkBadge({
|
||||
ok,
|
||||
okLabel = "ok",
|
||||
errLabel = "ошибка",
|
||||
}: {
|
||||
ok: boolean
|
||||
okLabel?: string
|
||||
errLabel?: string
|
||||
}) {
|
||||
return ok ? (
|
||||
<Badge variant="success-outline" size="sm" className="text-[10px]">
|
||||
{okLabel}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive-outline" size="sm" className="text-[10px]">
|
||||
{errLabel}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function SnapshotErrorCell({ error, className }: { error?: string; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn("text-destructive max-w-[220px] truncate block", className)}
|
||||
title={error}
|
||||
>
|
||||
{error ?? "—"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SnapshotDataGrid<T extends SnapshotRow>(
|
||||
props: CompactDataGridProps<T>,
|
||||
) {
|
||||
return <CompactDataGrid {...props} />
|
||||
}
|
||||
|
||||
function TrafficSnapshotGrid({ servers }: { servers: TrafficServerSnapshot[] }) {
|
||||
const data = withRowId(servers, (s) => String(s.serverId))
|
||||
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
|
||||
{ id: "name", header: "Сервер", accessorKey: "name", cell: (r) => <span className="font-medium">{r.name}</span> },
|
||||
{
|
||||
id: "host",
|
||||
header: "Хост",
|
||||
accessorKey: "host",
|
||||
cell: (r) => <span className="font-mono text-muted-foreground">{r.host}</span>,
|
||||
},
|
||||
{
|
||||
id: "ok",
|
||||
header: "Результат",
|
||||
enableSorting: false,
|
||||
cell: (r) => <SnapshotOkBadge ok={r.ok} />,
|
||||
},
|
||||
{
|
||||
id: "interfaces",
|
||||
header: "IF",
|
||||
accessorKey: "interfaces",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => r.interfaces ?? "—",
|
||||
},
|
||||
{
|
||||
id: "sumRxMbps",
|
||||
header: "Σ RX",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.sumRxMbps != null ? `${r.sumRxMbps} Мбит/с` : "—"),
|
||||
},
|
||||
{
|
||||
id: "sumTxMbps",
|
||||
header: "Σ TX",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.sumTxMbps != null ? `${r.sumTxMbps} Мбит/с` : "—"),
|
||||
},
|
||||
{
|
||||
id: "error",
|
||||
header: "Ошибка",
|
||||
enableSorting: false,
|
||||
cell: (r) => <SnapshotErrorCell error={r.error} />,
|
||||
},
|
||||
]
|
||||
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет сэмплов трафика" />
|
||||
}
|
||||
|
||||
function ResourcesSnapshotGrid({ servers }: { servers: ResourceServerSnapshot[] }) {
|
||||
const data = withRowId(servers, (s) => String(s.serverId))
|
||||
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
|
||||
{
|
||||
id: "name",
|
||||
header: "Сервер",
|
||||
enableSorting: false,
|
||||
cell: (r) => (
|
||||
<div>
|
||||
<span className="font-medium">{r.name}</span>
|
||||
<span className="block font-mono text-[10px] text-muted-foreground">{r.host}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Статус",
|
||||
enableSorting: false,
|
||||
cell: (r) => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
r.status === "online" && "border-emerald-500/40 text-emerald-700 dark:text-emerald-400",
|
||||
r.status === "offline" && "border-destructive/50 text-destructive",
|
||||
)}
|
||||
>
|
||||
{r.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "cpuLoadPct",
|
||||
header: "CPU %",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => r.cpuLoadPct ?? "—",
|
||||
},
|
||||
{
|
||||
id: "memory",
|
||||
header: "Память",
|
||||
enableSorting: false,
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums whitespace-nowrap",
|
||||
cell: (r) =>
|
||||
r.memUsedMb != null && r.memTotalMb != null ? `${r.memUsedMb} / ${r.memTotalMb} МБ` : "—",
|
||||
},
|
||||
{
|
||||
id: "memUsedPct",
|
||||
header: "% RAM",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.memUsedPct != null ? `${r.memUsedPct}%` : "—"),
|
||||
},
|
||||
{
|
||||
id: "disk",
|
||||
header: "Диск своб.",
|
||||
enableSorting: false,
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums whitespace-nowrap",
|
||||
cell: (r) =>
|
||||
r.diskFreeMb != null && r.diskTotalMb != null ? `${r.diskFreeMb} / ${r.diskTotalMb} МБ` : "—",
|
||||
},
|
||||
{
|
||||
id: "uptimeSeconds",
|
||||
header: "Uptime",
|
||||
cellClassName: "tabular-nums",
|
||||
cell: (r) => (r.uptimeSeconds != null ? fmtUptimeSec(r.uptimeSeconds) : "—"),
|
||||
},
|
||||
{
|
||||
id: "board",
|
||||
header: "Плата / ROS",
|
||||
enableSorting: false,
|
||||
cell: (r) => (
|
||||
<div className="max-w-[140px]">
|
||||
<span className="block truncate" title={r.boardName}>{r.boardName || "—"}</span>
|
||||
<span className="block truncate text-muted-foreground font-mono text-[10px]" title={r.rosVersion}>
|
||||
{r.rosVersion || ""}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "error",
|
||||
header: "Ошибка",
|
||||
enableSorting: false,
|
||||
cell: (r) => <SnapshotErrorCell error={r.error} className="max-w-[160px]" />,
|
||||
},
|
||||
]
|
||||
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет сэмплов ресурсов" />
|
||||
}
|
||||
|
||||
function ServersRestPingSnapshotGrid({ servers }: { servers: ServerRestPingSnapshot[] }) {
|
||||
const data = withRowId(servers, (s) => String(s.serverId))
|
||||
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
|
||||
{ id: "name", header: "Сервер", accessorKey: "name", cell: (r) => <span className="font-medium">{r.name}</span> },
|
||||
{
|
||||
id: "host",
|
||||
header: "Хост",
|
||||
accessorKey: "host",
|
||||
cell: (r) => <span className="font-mono text-muted-foreground">{r.host}</span>,
|
||||
},
|
||||
{
|
||||
id: "ok",
|
||||
header: "Результат",
|
||||
enableSorting: false,
|
||||
cell: (r) => <SnapshotOkBadge ok={r.ok} errLabel="недоступен" />,
|
||||
},
|
||||
{
|
||||
id: "latencyMs",
|
||||
header: "RTT REST",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.latencyMs != null ? `${r.latencyMs} мс` : "—"),
|
||||
},
|
||||
{
|
||||
id: "error",
|
||||
header: "Ошибка",
|
||||
enableSorting: false,
|
||||
cell: (r) => <SnapshotErrorCell error={r.error} />,
|
||||
},
|
||||
]
|
||||
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет сэмплов REST ping" />
|
||||
}
|
||||
|
||||
function PingSnapshotGrid({ probes }: { probes: PingProbeSnapshot[] }) {
|
||||
const data = withRowId(probes, (p) => `${p.probeId}-${p.target}`)
|
||||
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
|
||||
{
|
||||
id: "name",
|
||||
header: "Проба",
|
||||
enableSorting: false,
|
||||
cell: (r) => (
|
||||
<div>
|
||||
<span className="font-medium">{r.name}</span>
|
||||
<span className="block font-mono text-[10px] text-muted-foreground">{r.probeId}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "target",
|
||||
header: "Цель",
|
||||
accessorKey: "target",
|
||||
cell: (r) => <span className="font-mono">{r.target}</span>,
|
||||
},
|
||||
{ id: "srcServerName", header: "Источник", accessorKey: "srcServerName" },
|
||||
{
|
||||
id: "srcInterface",
|
||||
header: "IF",
|
||||
accessorKey: "srcInterface",
|
||||
cell: (r) => <span className="font-mono text-muted-foreground">{r.srcInterface || "—"}</span>,
|
||||
},
|
||||
{
|
||||
id: "rttMs",
|
||||
header: "RTT",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.rttMs != null ? `${r.rttMs} мс` : "—"),
|
||||
},
|
||||
{
|
||||
id: "lossPct",
|
||||
header: "Loss",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => `${r.lossPct}%`,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Статус",
|
||||
enableSorting: false,
|
||||
cell: (r) => <Badge variant="outline" className="text-[10px]">{r.status}</Badge>,
|
||||
},
|
||||
{
|
||||
id: "error",
|
||||
header: "Ошибка",
|
||||
enableSorting: false,
|
||||
cell: (r) => <SnapshotErrorCell error={r.error} className="max-w-[180px]" />,
|
||||
},
|
||||
]
|
||||
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет сэмплов ping" />
|
||||
}
|
||||
|
||||
function SpeedSnapshotGrid({ runs }: { runs: SpeedRunSnapshot[] }) {
|
||||
const data = withRowId(runs, (r) => r.probeId)
|
||||
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
|
||||
{
|
||||
id: "probeId",
|
||||
header: "Проба",
|
||||
accessorKey: "probeId",
|
||||
cell: (r) => <span className="font-mono">{r.probeId}</span>,
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: "Маршрут",
|
||||
enableSorting: false,
|
||||
cell: (r) => (
|
||||
<span className="whitespace-nowrap">
|
||||
{r.srcServerName} <span className="text-muted-foreground">→</span> {r.dstServerName}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "interfaces",
|
||||
header: "Интерфейсы",
|
||||
enableSorting: false,
|
||||
cell: (r) => (
|
||||
<span className="font-mono text-[10px]">
|
||||
<span className="block">{r.srcInterface || "—"}</span>
|
||||
<span className="block text-muted-foreground">{r.dstInterface || "—"}</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "protocol",
|
||||
header: "Протокол",
|
||||
enableSorting: false,
|
||||
cell: (r) => `${r.protocol} / ${r.direction} / ${r.durationSec}s`,
|
||||
},
|
||||
{
|
||||
id: "txAvgMbps",
|
||||
header: "TX",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.txAvgMbps != null ? `${Number(r.txAvgMbps).toFixed(1)}` : "—"),
|
||||
},
|
||||
{
|
||||
id: "rxAvgMbps",
|
||||
header: "RX",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.rxAvgMbps != null ? `${Number(r.rxAvgMbps).toFixed(1)}` : "—"),
|
||||
},
|
||||
{
|
||||
id: "pingRttMs",
|
||||
header: "Ping RTT",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.pingRttMs != null ? `${r.pingRttMs} мс` : "—"),
|
||||
},
|
||||
{
|
||||
id: "pingLossPct",
|
||||
header: "Loss",
|
||||
headerClassName: "text-right",
|
||||
cellClassName: "text-right tabular-nums",
|
||||
cell: (r) => (r.pingLossPct != null ? `${r.pingLossPct}%` : "—"),
|
||||
},
|
||||
{
|
||||
id: "ok",
|
||||
header: "Результат",
|
||||
enableSorting: false,
|
||||
cell: (r) => <SnapshotOkBadge ok={r.ok} />,
|
||||
},
|
||||
{
|
||||
id: "error",
|
||||
header: "Ошибка",
|
||||
enableSorting: false,
|
||||
cell: (r) => (
|
||||
<div className="max-w-[200px]">
|
||||
<span className="text-destructive block truncate" title={r.error}>{r.error ?? ""}</span>
|
||||
{r.pingError ? (
|
||||
<span className="text-[10px] text-amber-600 dark:text-amber-400 block truncate" title={r.pingError ?? ""}>
|
||||
ping: {r.pingError}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет прогонов speed" />
|
||||
}
|
||||
|
||||
function transitionRu(t: AlertEngineRuleDiagSnapshot["hitTransition"]): string {
|
||||
switch (t) {
|
||||
case "problem":
|
||||
return "проблема"
|
||||
case "recovery":
|
||||
return "восстановление"
|
||||
case "neutral":
|
||||
return "нейтрально"
|
||||
default:
|
||||
return "—"
|
||||
}
|
||||
}
|
||||
|
||||
function blockedRu(b: AlertEngineRuleDiagSnapshot["blocked"]): string {
|
||||
switch (b) {
|
||||
case "no_hit":
|
||||
return "условие не выполнено"
|
||||
case "stability":
|
||||
return "стабильность (confirmStabilitySec)"
|
||||
case "cooldown":
|
||||
return "cooldown"
|
||||
case "no_telegram":
|
||||
return "нет Telegram"
|
||||
case "dedupe_positive":
|
||||
return "дедуп восстановления"
|
||||
case "in_group":
|
||||
return "в группе (отдельно не шлём)"
|
||||
default:
|
||||
return "—"
|
||||
}
|
||||
}
|
||||
|
||||
function AlertEngineRuleDiagGrid({ ruleDiag }: { ruleDiag: AlertEngineRuleDiagSnapshot[] }) {
|
||||
const data = withRowId(ruleDiag, (d) => d.ruleId)
|
||||
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
|
||||
{
|
||||
id: "ruleId",
|
||||
header: "ID правила",
|
||||
accessorKey: "ruleId",
|
||||
cell: (r) => (
|
||||
<span className="font-mono max-w-[140px] truncate block" title={r.ruleId}>
|
||||
{r.ruleId}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "evalHit",
|
||||
header: "Сработало",
|
||||
cell: (r) => (r.evalHit ? "Да" : "Нет"),
|
||||
},
|
||||
{
|
||||
id: "hitTransition",
|
||||
header: "Тип срабатывания",
|
||||
cell: (r) => transitionRu(r.hitTransition),
|
||||
},
|
||||
{
|
||||
id: "stabilityOk",
|
||||
header: "Стабильность",
|
||||
cell: (r) => (r.stabilityOk ? "Да" : "Нет"),
|
||||
},
|
||||
{
|
||||
id: "cooldownOk",
|
||||
header: "Кулдаун",
|
||||
cell: (r) => (r.cooldownOk ? "Да" : "Нет"),
|
||||
},
|
||||
{
|
||||
id: "telegramOk",
|
||||
header: "Telegram",
|
||||
cell: (r) => (r.telegramOk ? "Да" : "Нет"),
|
||||
},
|
||||
{
|
||||
id: "hitMessage",
|
||||
header: "Сообщение",
|
||||
enableSorting: false,
|
||||
cell: (r) => (
|
||||
<span className="font-mono max-w-[280px] truncate text-muted-foreground block" title={r.hitMessage ?? ""}>
|
||||
{r.hitMessage ?? "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "blocked",
|
||||
header: "Причина блока",
|
||||
cell: (r) => <span className="text-muted-foreground">{blockedRu(r.blocked)}</span>,
|
||||
},
|
||||
]
|
||||
return (
|
||||
<SnapshotDataGrid
|
||||
data={data}
|
||||
columns={columns}
|
||||
emptyTitle="Нет диагностики по правилам"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtUptimeSec(sec: number): string {
|
||||
if (sec <= 0) return "—"
|
||||
const d = Math.floor(sec / 86400)
|
||||
const h = Math.floor((sec % 86400) / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
if (d > 0) return `${d}д ${h}ч`
|
||||
if (h > 0) return `${h}ч ${m}м`
|
||||
return `${m}м`
|
||||
}
|
||||
|
||||
export {
|
||||
SnapshotDataGrid,
|
||||
TrafficSnapshotGrid,
|
||||
ResourcesSnapshotGrid,
|
||||
ServersRestPingSnapshotGrid,
|
||||
PingSnapshotGrid,
|
||||
SpeedSnapshotGrid,
|
||||
AlertEngineRuleDiagGrid,
|
||||
type SnapshotRow,
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client"
|
||||
|
||||
import { CompactDataGrid, type CompactDataGridColumn } from "@/components/data-grids/compact-data-grid"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { fmtBps, formatBytes } from "@/lib/fmt-rate"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
STATISTICS_DUP_MARK,
|
||||
STATISTICS_UNBOUND_USER_ID,
|
||||
STATISTICS_WAN_MARK,
|
||||
type StatisticsBreakdownRow,
|
||||
} from "@mmapp/contracts/statistics"
|
||||
|
||||
export type StatisticsSliceKind = "users" | "servers" | "interfaces" | "countries" | "services" | "asns"
|
||||
|
||||
export function StatisticsBreakdownDataGrid({
|
||||
rows,
|
||||
kind,
|
||||
selectedId,
|
||||
onRowClick,
|
||||
isLoading,
|
||||
density = "full",
|
||||
}: {
|
||||
rows: StatisticsBreakdownRow[]
|
||||
kind: StatisticsSliceKind
|
||||
selectedId?: string
|
||||
onRowClick?: (row: StatisticsBreakdownRow) => void
|
||||
isLoading?: boolean
|
||||
density?: "full" | "mini"
|
||||
}) {
|
||||
const mini = density === "mini"
|
||||
const columns: CompactDataGridColumn<StatisticsBreakdownRow>[] = [
|
||||
{
|
||||
id: "label",
|
||||
header: "Имя",
|
||||
accessorKey: "label",
|
||||
cell: (row) => (
|
||||
<span className={cn("flex items-center gap-2", selectedId === row.id && "font-medium")}>
|
||||
{kind === "countries" && row.id !== "XX" && row.id !== STATISTICS_UNBOUND_USER_ID ? (
|
||||
<Flag code={row.id} size={16} />
|
||||
) : null}
|
||||
<span className="truncate">{row.label || row.id}</span>
|
||||
{selectedId === row.id ? (
|
||||
<Badge variant="outline" size="sm">
|
||||
слайс
|
||||
</Badge>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "bytes",
|
||||
header: "Байты",
|
||||
accessorKey: "bytes",
|
||||
cell: (row) => <span className="tabular-nums">{formatBytes(row.bytes)}</span>,
|
||||
},
|
||||
...(!mini
|
||||
? [
|
||||
{
|
||||
id: "packets",
|
||||
header: "Пакеты",
|
||||
accessorKey: "packets" as const,
|
||||
cell: (row: StatisticsBreakdownRow) => (
|
||||
<span className="tabular-nums">{row.packets.toLocaleString("ru-RU")}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "bps",
|
||||
header: "Средний bitrate",
|
||||
accessorKey: "bps" as const,
|
||||
cell: (row: StatisticsBreakdownRow) => <span className="tabular-nums">{fmtBps(row.bps)}</span>,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "percent",
|
||||
header: "Доля",
|
||||
accessorKey: "percent",
|
||||
cell: (row) => (
|
||||
<span className="tabular-nums">
|
||||
{row.percent === 0
|
||||
&& (row.label.includes(STATISTICS_WAN_MARK) || row.label.includes(`· ${STATISTICS_DUP_MARK}`))
|
||||
? "—"
|
||||
: `${row.percent.toFixed(1)}%`}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<CompactDataGrid
|
||||
data={rows}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
emptyTitle="Нет трафика"
|
||||
emptyDescription="За выбранный период и слайсы нет данных куба."
|
||||
onRowClick={onRowClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Server } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { CableIcon, CopyIcon, EyeIcon, EyeOffIcon, Trash2Icon } from "lucide-react"
|
||||
|
||||
export interface SubUserRow {
|
||||
id: string
|
||||
login: string
|
||||
password: string
|
||||
description: string
|
||||
jhServerIds: string[]
|
||||
clientIp: string
|
||||
active: boolean
|
||||
lastSeen: string | null
|
||||
}
|
||||
|
||||
interface SubusersDataGridProps {
|
||||
subUsers: SubUserRow[]
|
||||
servers: Server[]
|
||||
revealedIds: Set<string>
|
||||
onToggleReveal: (id: string) => void
|
||||
onToggleActive: (id: string) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
function SubusersDataGrid({
|
||||
subUsers,
|
||||
servers,
|
||||
revealedIds,
|
||||
onToggleReveal,
|
||||
onToggleActive,
|
||||
onRemove,
|
||||
}: SubusersDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, SubUserRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "login",
|
||||
accessorKey: "login",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Логин / описание" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const su = row.original
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-mono font-medium truncate">{su.login}</p>
|
||||
{su.description && (
|
||||
<p className="text-[11px] text-muted-foreground truncate">{su.description}</p>
|
||||
)}
|
||||
{su.lastSeen && (
|
||||
<p className="text-[10px] text-muted-foreground/50">{su.lastSeen}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Логин / описание",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "password",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Пароль</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const su = row.original
|
||||
const revealed = revealedIds.has(su.id)
|
||||
return (
|
||||
<div className="flex items-center gap-1 min-w-0" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="font-mono text-[11px] truncate flex-1">
|
||||
{revealed ? su.password : "••••••••••••"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleReveal(su.id)}
|
||||
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0 transition-colors"
|
||||
>
|
||||
{revealed ? <EyeOffIcon className="size-3" /> : <EyeIcon className="size-3" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard.writeText(su.password).catch(() => {})}
|
||||
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0 transition-colors"
|
||||
>
|
||||
<CopyIcon className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Пароль",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "jhServers",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">JH-серверы</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const jhs = servers.filter((s) => row.original.jhServerIds.includes(s.id))
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 min-w-0">
|
||||
{jhs.length === 0 ? (
|
||||
<span className="text-[11px] text-muted-foreground/40">—</span>
|
||||
) : (
|
||||
jhs.map((jh) => (
|
||||
<span
|
||||
key={jh.id}
|
||||
className="inline-flex items-center gap-1 text-[10px] font-medium bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20 rounded px-1 py-0.5"
|
||||
>
|
||||
<Flag code={jh.country} size={10} />
|
||||
{jh.name.split("-").slice(-1)[0]}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "JH-серверы",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "clientIp",
|
||||
accessorKey: "clientIp",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="IP-клиента" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-[11px] text-muted-foreground truncate">
|
||||
{row.original.clientIp || "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "IP-клиента",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "active",
|
||||
accessorKey: "active",
|
||||
header: () => <span className="sr-only">Активен</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<FormToggle
|
||||
checked={row.original.active}
|
||||
onChange={() => onToggleActive(row.original.id)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
size: 48,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Удалить</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(row.original.id)}
|
||||
className="text-muted-foreground/50 hover:text-destructive transition-colors opacity-0 group-hover/row:opacity-100 focus-visible:opacity-100"
|
||||
aria-label="Удалить GRE-клиента"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</button>
|
||||
),
|
||||
size: 40,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[onRemove, onToggleActive, onToggleReveal, revealedIds, servers],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: true,
|
||||
data: subUsers,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
if (subUsers.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<CableIcon className="size-4" />}
|
||||
title="Нет GRE-клиентов"
|
||||
description="Добавьте учётки для подключения устройств"
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={subUsers.length}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { SubusersDataGrid, type SubusersDataGridProps }
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { type ColumnDef, useTable } from "@tanstack/react-table"
|
||||
import type { FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
|
||||
import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from "lucide-react"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { fmtRate } from "@/lib/fmt-rate"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)} ГБ`
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)} КБ`
|
||||
return `${n} Б`
|
||||
}
|
||||
|
||||
function formatEndpoint(ip: string | undefined, port: number | undefined): string {
|
||||
const host = String(ip ?? "").trim()
|
||||
if (!host) return "—"
|
||||
return port ? `${host.includes(":") ? `[${host}]` : host}:${port}` : host
|
||||
}
|
||||
|
||||
function packetTuple(row: FlowTalkerDto): string {
|
||||
const src = formatEndpoint(row.src, row.srcPort)
|
||||
const dst = formatEndpoint(row.dst, row.dstPort)
|
||||
return `${src} → ${dst}`
|
||||
}
|
||||
|
||||
function FlowDirectionMark({ direction }: { direction: FlowTalkerDto["direction"] }) {
|
||||
if (direction === "to_client") {
|
||||
return (
|
||||
<span className="inline-flex text-muted-foreground" title="Получение: Интернет → клиент">
|
||||
<ArrowDownIcon className="size-3" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (direction === "from_client") {
|
||||
return (
|
||||
<span className="inline-flex text-muted-foreground" title="Отправка: клиент → Интернет">
|
||||
<ArrowUpIcon className="size-3" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex text-muted-foreground" title="Транзит">
|
||||
<MinusIcon className="size-3" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function TrafficFlowsDataGrid({
|
||||
rows,
|
||||
emptyHint,
|
||||
}: {
|
||||
rows: FlowTalkerDto[]
|
||||
emptyHint?: string
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, FlowTalkerDto>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "client",
|
||||
accessorFn: (r) => r.clientName || r.clientIp || "",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Клиент</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-xs truncate">{row.original.clientName || "—"}</span>
|
||||
{row.original.clientIp ? (
|
||||
<span className="font-mono text-[10px] text-muted-foreground truncate">{row.original.clientIp}</span>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||
},
|
||||
{
|
||||
id: "server",
|
||||
accessorKey: "serverName",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Экспортёр</span>,
|
||||
cell: ({ row }) => <span className="text-sm font-medium">{row.original.serverName}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "direction",
|
||||
accessorFn: (r) => r.direction ?? "",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Направление</span>,
|
||||
cell: ({ row }) => <FlowDirectionMark direction={row.original.direction} />,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "internet",
|
||||
accessorFn: (r) => r.internetPeer ?? r.dst,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Интернет</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const label = formatEndpoint(r.internetPeer, r.internetPeerPort)
|
||||
const tuple = packetTuple(r)
|
||||
return (
|
||||
<span className="font-mono text-xs truncate max-w-[220px]" title={tuple}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "app",
|
||||
accessorFn: (r) => r.application ?? r.protoName,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Приложение</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="flex min-w-0 flex-col gap-0.5 text-xs">
|
||||
<span>{row.original.application ?? row.original.protoName}</span>
|
||||
{row.original.category || row.original.service ? (
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{[row.original.category, row.original.service].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "proto",
|
||||
accessorKey: "protoName",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Протокол</span>,
|
||||
cell: ({ row }) => <span className="text-xs">{row.original.protoName}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "rate",
|
||||
accessorFn: (r) => r.bps,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Скорость</span>,
|
||||
cell: ({ row }) => <span className="text-xs tabular-nums">{fmtRate(row.original.bps / 1_000_000)}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "bytes",
|
||||
accessorKey: "bytes",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Байты</span>,
|
||||
cell: ({ row }) => <span className="text-xs tabular-nums">{formatBytes(row.original.bytes)}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "en",
|
||||
accessorFn: (r) => r.enName ?? "",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Выходной узел</span>,
|
||||
cell: ({ row }) => <span className="text-xs">{row.original.enName || "—"}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "plane",
|
||||
accessorFn: (r) => r.plane ?? "",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Плоскость</span>,
|
||||
cell: ({ row }) => <span className="text-xs text-muted-foreground">{row.original.plane || "—"}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "iface",
|
||||
accessorKey: "inIface",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Интерфейс</span>,
|
||||
cell: ({ row }) => <span className="font-mono text-xs text-muted-foreground">{row.original.inIface || "—"}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: cn(DATA_GRID_CELL_PAD_LAST) },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
data: rows,
|
||||
columns,
|
||||
getRowId: (row, i) => `${row.serverId}-${row.src}-${row.dst}-${row.proto}-${row.srcPort}-${row.dstPort}-${i}`,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rows.length}
|
||||
emptyMessage={
|
||||
emptyHint
|
||||
|| "Пока нет IPFIX. Поднимите wg-flow на хосте MM и подключите jump-host одним кликом."
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { TrafficFlowsDataGrid }
|
||||
@@ -0,0 +1,399 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo, useEffect } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Server } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { Sparkline } from "@/components/sparkline"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
ClockIcon,
|
||||
CpuIcon,
|
||||
HardDriveIcon,
|
||||
SearchIcon,
|
||||
ThermometerIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export interface UptimeResourceRow {
|
||||
serverId: string
|
||||
server: Server
|
||||
hasData: boolean
|
||||
cpu: number
|
||||
cpuHistory: number[]
|
||||
ramUsed: number
|
||||
ramTotal: number
|
||||
ramPct: number
|
||||
hddUsed: number
|
||||
hddTotal: number
|
||||
hddPct: number
|
||||
uptimeSeconds: number
|
||||
boardName: string
|
||||
temp?: number
|
||||
}
|
||||
|
||||
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
|
||||
return (
|
||||
<span className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
|
||||
type === "home-router"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: type === "jump-host"
|
||||
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
|
||||
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
)}>
|
||||
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtMB(mb: number): string {
|
||||
if (mb >= 1024) return `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} ГБ`
|
||||
return `${mb.toFixed(1)} МБ`
|
||||
}
|
||||
|
||||
function fmtUptime(sec: number): string {
|
||||
const d = Math.floor(sec / 86400)
|
||||
const h = Math.floor((sec % 86400) / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
if (d > 0) return `${d}д ${h}ч`
|
||||
if (h > 0) return `${h}ч ${m}м`
|
||||
return `${m}м`
|
||||
}
|
||||
|
||||
function resPctColor(pct: number, warn = 70, crit = 85): string {
|
||||
if (pct >= crit) return "text-red-600 dark:text-red-400"
|
||||
if (pct >= warn) return "text-amber-600 dark:text-amber-400"
|
||||
return "text-emerald-600 dark:text-emerald-400"
|
||||
}
|
||||
|
||||
function resBarColor(pct: number, warn = 70, crit = 85): string {
|
||||
if (pct >= crit) return "bg-red-500"
|
||||
if (pct >= warn) return "bg-amber-500"
|
||||
return "bg-emerald-500"
|
||||
}
|
||||
|
||||
function MiniBar({
|
||||
pct,
|
||||
warn = 70,
|
||||
crit = 85,
|
||||
className,
|
||||
}: {
|
||||
pct: number
|
||||
warn?: number
|
||||
crit?: number
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("h-1.5 rounded-full bg-muted overflow-hidden", className)}>
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-700", resBarColor(pct, warn, crit))}
|
||||
style={{ width: `${Math.min(100, Math.max(0, pct))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface UptimeResourcesDataGridProps {
|
||||
rows: UptimeResourceRow[]
|
||||
}
|
||||
|
||||
function UptimeResourcesDataGrid({ rows }: UptimeResourcesDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, UptimeResourceRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
accessorFn: (row) => row.server.name,
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Сервер" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const srv = r.server
|
||||
const offline = srv.status !== "online"
|
||||
const noMetrics = offline || !r.hasData
|
||||
const isCrit =
|
||||
!noMetrics &&
|
||||
(r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{isCrit && <AlertCircleIcon className="size-3.5 text-red-500 shrink-0" />}
|
||||
{!isCrit && <StatusDot status={srv.status} pulse={!offline} />}
|
||||
<Flag code={srv.country} size={16} />
|
||||
<span className="font-mono font-semibold">{srv.name}</span>
|
||||
<TypeChip type={srv.type} />
|
||||
<span className="text-xs text-muted-foreground hidden xl:inline">{srv.site}</span>
|
||||
{!offline && !r.hasData && (
|
||||
<span className="text-[10px] rounded border border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400 px-1.5 py-0.5">
|
||||
нет данных
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "board",
|
||||
accessorKey: "boardName",
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground hidden md:inline">
|
||||
Модель · ROS
|
||||
</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<div className="hidden md:flex flex-col leading-tight">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{r.hasData ? r.boardName : "—"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">{r.server.os}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: "hidden md:table-cell", cellClassName: "hidden md:table-cell px-4 py-3" },
|
||||
},
|
||||
{
|
||||
id: "cpu",
|
||||
accessorKey: "cpu",
|
||||
header: ({ column }) => (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CpuIcon className="size-3.5 text-muted-foreground" />
|
||||
<DataGridSortHeader column={column} title="CPU" />
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const offline = r.server.status !== "online"
|
||||
const noMetrics = offline || !r.hasData
|
||||
if (noMetrics) {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground/30">
|
||||
{offline ? "—" : "нет опроса"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const cpuColor =
|
||||
r.cpu >= 85 ? "hsl(0 84% 60%)" : r.cpu >= 70 ? "hsl(38 92% 50%)" : "hsl(142 76% 36%)"
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 min-w-[140px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("font-mono text-sm font-semibold tabular-nums w-10 shrink-0", resPctColor(r.cpu))}>
|
||||
{r.cpu}%
|
||||
</span>
|
||||
<MiniBar pct={r.cpu} className="flex-1" />
|
||||
</div>
|
||||
<Sparkline data={r.cpuHistory} width={120} height={18} color={cpuColor} filled />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "CPU",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "min-w-[160px]"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "min-w-[160px]"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ram",
|
||||
accessorKey: "ramPct",
|
||||
header: ({ column }) => (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<HardDriveIcon className="size-3.5 text-muted-foreground" />
|
||||
<DataGridSortHeader column={column} title="RAM" />
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const offline = r.server.status !== "online"
|
||||
const noMetrics = offline || !r.hasData
|
||||
if (noMetrics) {
|
||||
return <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 min-w-[155px]">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className={cn("font-mono font-semibold", resPctColor(r.ramPct))}>{r.ramPct}%</span>
|
||||
<span className="text-muted-foreground/60 font-mono text-[10px]">
|
||||
{fmtMB(r.ramUsed)}/{fmtMB(r.ramTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<MiniBar pct={r.ramPct} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "RAM",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "min-w-[175px]"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "min-w-[175px]"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "hdd",
|
||||
accessorKey: "hddPct",
|
||||
header: ({ column }) => (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<HardDriveIcon className="size-3.5 text-muted-foreground" />
|
||||
<DataGridSortHeader column={column} title="Диск" />
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const offline = r.server.status !== "online"
|
||||
const noMetrics = offline || !r.hasData
|
||||
if (noMetrics) {
|
||||
return <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 min-w-[155px]">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className={cn("font-mono font-semibold", resPctColor(r.hddPct))}>{r.hddPct}%</span>
|
||||
<span className="text-muted-foreground/60 font-mono text-[10px]">
|
||||
{fmtMB(r.hddUsed)}/{fmtMB(r.hddTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<MiniBar pct={r.hddPct} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Диск",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "min-w-[175px]"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "min-w-[175px]"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
accessorKey: "uptimeSeconds",
|
||||
header: ({ column }) => (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ClockIcon className="size-3.5 text-muted-foreground" />
|
||||
<DataGridSortHeader column={column} title="Uptime" />
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const offline = r.server.status !== "online"
|
||||
const noMetrics = offline || !r.hasData
|
||||
return (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{noMetrics ? "—" : fmtUptime(r.uptimeSeconds)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Uptime",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "temp",
|
||||
accessorKey: "temp",
|
||||
header: ({ column }) => (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ThermometerIcon className="size-3.5 text-muted-foreground" />
|
||||
<DataGridSortHeader column={column} title="°C" />
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const offline = r.server.status !== "online"
|
||||
const noMetrics = offline || !r.hasData
|
||||
if (r.temp !== undefined && !noMetrics) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-sm font-semibold tabular-nums",
|
||||
r.temp >= 70
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: r.temp >= 55
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{r.temp}°C
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <span className="text-muted-foreground/30 text-xs">—</span>
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "°C",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 } },
|
||||
data: rows,
|
||||
columns,
|
||||
getRowId: (row) => row.serverId,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(rows.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [rows.length, pageSize, pageIndex, table])
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<SearchIcon className="size-4" />}
|
||||
title="Ничего не найдено"
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rows.length}
|
||||
pagination={rows.length > 50 || table.state.pagination.pageSize !== 50}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border bg-muted/30",
|
||||
bodyRow: cn(
|
||||
"group/row hover:bg-muted/30",
|
||||
"[&:has([data-resource-offline=true])]:opacity-50",
|
||||
"[&:has([data-resource-crit=true])]:bg-red-500/3",
|
||||
),
|
||||
}}
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal">
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { UptimeResourcesDataGrid, type UptimeResourcesDataGridProps }
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo, useEffect } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Server } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ArrowRightIcon, RefreshCwIcon } from "lucide-react"
|
||||
|
||||
export interface SpeedTestRunRow {
|
||||
id: string
|
||||
startedAt: number
|
||||
srcServerId: string
|
||||
dstServerId: string
|
||||
srcInterface?: string
|
||||
dstInterface?: string
|
||||
protocol: "tcp" | "udp"
|
||||
direction: "transmit" | "receive" | "both"
|
||||
durationSec: number
|
||||
txAvgMbps: number | null
|
||||
rxAvgMbps: number | null
|
||||
status: "running" | "done" | "error" | "unknown"
|
||||
afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } | null
|
||||
srcInterfaceAddress?: string | null
|
||||
dstInterfaceAddress?: string | null
|
||||
}
|
||||
|
||||
interface UptimeSpeedHistoryDataGridProps {
|
||||
runs: SpeedTestRunRow[]
|
||||
servers: Server[]
|
||||
}
|
||||
|
||||
function UptimeSpeedHistoryDataGrid({ runs, servers }: UptimeSpeedHistoryDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, SpeedTestRunRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "startedAt",
|
||||
accessorKey: "startedAt",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Время" className="ml-1" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground whitespace-nowrap tabular-nums font-mono text-xs">
|
||||
{new Date(row.original.startedAt).toLocaleString("ru-RU", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Время",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
accessorFn: (row) => `${row.srcServerId}-${row.dstServerId}`,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Маршрут" />,
|
||||
cell: ({ row }) => {
|
||||
const run = row.original
|
||||
const src = servers.find((s) => s.id === run.srcServerId)
|
||||
const dst = servers.find((s) => s.id === run.dstServerId)
|
||||
return (
|
||||
<div className="font-mono whitespace-nowrap text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Flag code={src?.country ?? "UN"} size={13} />
|
||||
<span>{src?.name ?? run.srcServerId}</span>
|
||||
<ArrowRightIcon className="size-3 text-muted-foreground" />
|
||||
<Flag code={dst?.country ?? "UN"} size={13} />
|
||||
<span>{dst?.name ?? run.dstServerId}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
|
||||
{run.srcInterfaceAddress && run.dstInterfaceAddress
|
||||
? `${run.srcInterfaceAddress} → ${run.dstInterfaceAddress}`
|
||||
: "внутренние IP: auto/не указаны"}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: "Маршрут", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "params",
|
||||
accessorFn: (row) => `${row.protocol}-${row.direction}-${row.durationSec}`,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Параметры" />,
|
||||
cell: ({ row }) => {
|
||||
const run = row.original
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-muted-foreground whitespace-nowrap text-xs">
|
||||
<span className="inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold bg-muted/60 border-border/60">
|
||||
{run.protocol.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span>{run.direction}</span>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span>{run.durationSec}s</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: "Параметры", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status
|
||||
if (status === "running") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[var(--status-degraded-fg)] text-xs">
|
||||
<RefreshCwIcon className="size-3 animate-spin" />
|
||||
running
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (status === "error") {
|
||||
return <span className="text-[var(--status-offline-fg)] text-xs">error</span>
|
||||
}
|
||||
if (status === "unknown") {
|
||||
return (
|
||||
<div className="max-w-64 text-xs text-[var(--status-degraded-fg)]">
|
||||
<span>Сохранение не подтверждено</span>
|
||||
<div className="mt-1 font-mono wrap-anywhere">runId: {row.original.id}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <span className="text-[var(--status-online-fg)] text-xs">done</span>
|
||||
},
|
||||
meta: { headerTitle: "Статус", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "txAvgMbps",
|
||||
accessorFn: row => row.txAvgMbps ?? undefined,
|
||||
sortUndefined: "last",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="TX avg" />,
|
||||
cell: ({ row }) => {
|
||||
const run = row.original
|
||||
if (run.txAvgMbps === null) return <span className="text-xs text-muted-foreground">Неизвестно</span>
|
||||
const maxVal = Math.max(run.txAvgMbps ?? 0, run.rxAvgMbps ?? 0, 1)
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-[120px]">
|
||||
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--chart-tx)]"
|
||||
style={{ width: `${(run.txAvgMbps / maxVal) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono tabular-nums text-[var(--chart-tx)] font-medium whitespace-nowrap text-xs">
|
||||
{run.txAvgMbps} Мбит/с
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: "TX avg", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "rxAvgMbps",
|
||||
accessorFn: row => row.rxAvgMbps ?? undefined,
|
||||
sortUndefined: "last",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="RX avg" />,
|
||||
cell: ({ row }) => {
|
||||
const run = row.original
|
||||
if (run.rxAvgMbps === null) return <span className="text-xs text-muted-foreground">Неизвестно</span>
|
||||
const maxVal = Math.max(run.txAvgMbps ?? 0, run.rxAvgMbps ?? 0, 1)
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-[120px]">
|
||||
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--chart-rx)]"
|
||||
style={{ width: `${(run.rxAvgMbps / maxVal) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono tabular-nums text-[var(--chart-rx)] font-medium whitespace-nowrap text-xs">
|
||||
{run.rxAvgMbps} Мбит/с
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: "RX avg", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "afterBtPing",
|
||||
accessorFn: (row) => row.afterBtPing?.rttMs ?? -1,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Ping после BT" />,
|
||||
cell: ({ row }) => {
|
||||
const run = row.original
|
||||
if (run.status !== "done") return <span className="text-xs">—</span>
|
||||
if (!run.afterBtPing) return <span className="text-xs text-muted-foreground">Нет данных</span>
|
||||
if (run.afterBtPing.error) {
|
||||
return (
|
||||
<span className="text-[var(--status-offline-fg)] text-xs font-mono" title={run.afterBtPing.error}>
|
||||
ошибка
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (run.afterBtPing?.rttMs != null) {
|
||||
return (
|
||||
<span className="font-mono tabular-nums whitespace-nowrap text-xs text-violet-600 dark:text-violet-400">
|
||||
{run.afterBtPing.rttMs} мс
|
||||
{run.afterBtPing.lossPct != null && run.afterBtPing.lossPct > 0 && (
|
||||
<span className="text-amber-600 dark:text-amber-400"> · {run.afterBtPing.lossPct}%</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="font-mono tabular-nums whitespace-nowrap text-xs text-amber-600 dark:text-amber-400">
|
||||
{run.afterBtPing.lossPct === 100 ? "Нет ответов" : "RTT неизвестен"}
|
||||
{run.afterBtPing?.lossPct != null && <span> · {run.afterBtPing.lossPct}%</span>}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Ping после BT",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[servers],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
data: runs,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, sorting: [{ id: "startedAt", desc: true }] },
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(runs.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [runs.length, pageSize, pageIndex, table])
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={runs.length}
|
||||
pagination={runs.length > 50 || pageSize !== 50}
|
||||
tableClassNames={{ bodyRow: cn("group/row text-xs") }}
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal">
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { UptimeSpeedHistoryDataGrid, type UptimeSpeedHistoryDataGridProps }
|
||||
@@ -0,0 +1,274 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo, useEffect } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
DEFAULT_DATA_GRID_LAYOUT,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { UsersExpandedDetail } from "@/components/data-grids/users-expanded-detail"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { ALL_SECTIONS, ROLE_LABEL, type AppUser } from "@/lib/users"
|
||||
import {
|
||||
CableIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
|
||||
return (
|
||||
<div aria-hidden="true"
|
||||
className={cn(
|
||||
"size-8 rounded-full hidden sm:flex items-center justify-center text-foreground text-[10px] font-semibold shrink-0 bg-muted",
|
||||
!active && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{avatar}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface UsersDataGridProps {
|
||||
users: AppUser[]
|
||||
serversCount: number | null
|
||||
allSectionsCount?: number
|
||||
isLoading?: boolean
|
||||
actionsDisabled?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
onEdit: (user: AppUser) => void
|
||||
onDelete: (user: AppUser) => void
|
||||
}
|
||||
|
||||
function UsersDataGrid({
|
||||
users,
|
||||
serversCount,
|
||||
allSectionsCount = ALL_SECTIONS.length,
|
||||
isLoading,
|
||||
actionsDisabled,
|
||||
emptyTitle = "Нет пользователей",
|
||||
emptyDescription = "Добавьте пользователя, чтобы привязать интерфейсы и учитывать трафик",
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: UsersDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, AppUser>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
size: 180,
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Пользователь" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const u = row.original
|
||||
const expanded = row.getIsExpanded()
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<Button type="button" size="sm" variant="ghost" aria-label={"Доступ и привязки пользователя " + u.name} aria-expanded={expanded} className="size-8 shrink-0 p-0" onClick={event => { event.stopPropagation(); row.toggleExpanded() }}>
|
||||
{expanded ? (
|
||||
<ChevronDownIcon aria-hidden="true" className="size-3.5 mt-2 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRightIcon aria-hidden="true" className="size-3.5 mt-2 shrink-0 text-muted-foreground/40" />
|
||||
)}
|
||||
</Button>
|
||||
<AvatarCircle avatar={u.avatar} active={u.active} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate" title={u.name}>{u.name}</p>
|
||||
<p className="text-xs font-mono text-muted-foreground truncate" title={u.email || u.login}>{u.email || u.login}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Пользователь",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
expandedContent: (row: AppUser) => (
|
||||
<UsersExpandedDetail
|
||||
user={row}
|
||||
serversCount={serversCount}
|
||||
allSectionsCount={allSectionsCount}
|
||||
/>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "role",
|
||||
accessorKey: "role",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Роль" />,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={row.original.role === "admin" ? "primary-light" : row.original.role === "operator" ? "info-light" : "outline"}
|
||||
size="sm"
|
||||
>
|
||||
{ROLE_LABEL[row.original.role]}
|
||||
</Badge>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Роль",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "last",
|
||||
accessorKey: "last",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Последний вход" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">{row.original.last}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Последний вход",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ifaces",
|
||||
accessorFn: (u) => u.bindings.length,
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейсы" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground tabular-nums">
|
||||
<CableIcon aria-hidden="true" className="size-3.5" />
|
||||
{row.original.bindings.length}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Интерфейсы",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "active",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-medium",
|
||||
row.original.active ? "text-success" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{row.original.active ? "Активен" : "Заблокирован"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 106,
|
||||
enableSorting: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => {
|
||||
const u = row.original
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-end gap-0.5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="size-11 md:size-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onEdit(u)}
|
||||
type="button"
|
||||
disabled={actionsDisabled}
|
||||
aria-label={"Редактировать пользователя " + u.name}
|
||||
title="Редактировать"
|
||||
>
|
||||
<PencilIcon aria-hidden="true" className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="size-7 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onDelete(u)}
|
||||
type="button"
|
||||
disabled={actionsDisabled}
|
||||
aria-label={"Удалить пользователя " + u.name}
|
||||
title="Удалить"
|
||||
>
|
||||
<TrashIcon aria-hidden="true" className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Действия",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[allSectionsCount, actionsDisabled, onDelete, onEdit, serversCount],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
autoResetExpanded: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["name"], end: [] } },
|
||||
data: users,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
getRowCanExpand: () => true,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(users.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [users.length, pageSize, pageIndex, table])
|
||||
|
||||
if (!isLoading && users.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<UsersIcon aria-hidden="true" className="size-4" />}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={users.length}
|
||||
pagination={users.length > 50 || pageSize !== 50}
|
||||
tableLayout={{ ...DEFAULT_DATA_GRID_LAYOUT, columnsPinnable: true }}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { UsersDataGrid, type UsersDataGridProps }
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
ALL_SECTIONS,
|
||||
groupBindingsByServer,
|
||||
IFACE_TYPE_LABEL,
|
||||
summarizeUserAccess,
|
||||
type AppUser,
|
||||
type InterfaceType,
|
||||
} from "@/lib/users"
|
||||
import { CableIcon, KeyRoundIcon, LockIcon, NetworkIcon, ShieldIcon } from "lucide-react"
|
||||
|
||||
const TYPE_VARIANT: Record<InterfaceType, "outline" | "info-light" | "success-light" | "secondary"> = {
|
||||
ether: "outline",
|
||||
bridge: "info-light",
|
||||
vlan: "info-light",
|
||||
vxlan: "info-light",
|
||||
gre: "info-light",
|
||||
wg: "success-light",
|
||||
ipsec: "info-light",
|
||||
other: "secondary",
|
||||
}
|
||||
|
||||
const TYPE_ICON: Record<InterfaceType, { icon: typeof CableIcon; className: string }> = {
|
||||
ether: { icon: CableIcon, className: "text-muted-foreground" },
|
||||
bridge: { icon: NetworkIcon, className: "text-info" },
|
||||
vlan: { icon: NetworkIcon, className: "text-info" },
|
||||
vxlan: { icon: NetworkIcon, className: "text-info" },
|
||||
gre: { icon: NetworkIcon, className: "text-info" },
|
||||
wg: { icon: ShieldIcon, className: "text-success" },
|
||||
ipsec: { icon: LockIcon, className: "text-info" },
|
||||
other: { icon: CableIcon, className: "text-muted-foreground" },
|
||||
}
|
||||
|
||||
interface UsersExpandedDetailProps {
|
||||
user: AppUser
|
||||
serversCount: number | null
|
||||
allSectionsCount?: number
|
||||
}
|
||||
|
||||
function UsersExpandedDetail({
|
||||
user,
|
||||
serversCount,
|
||||
allSectionsCount = ALL_SECTIONS.length,
|
||||
}: UsersExpandedDetailProps) {
|
||||
const access = summarizeUserAccess(user, serversCount ?? 0, allSectionsCount)
|
||||
const groups = groupBindingsByServer(user.bindings)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 px-5 py-5 bg-muted/20">
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
Разделы:{" "}
|
||||
<span className="font-mono text-foreground">
|
||||
{access.sectionsGranted}/{access.sectionsTotal}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Серверы:{" "}
|
||||
<span className="font-mono text-foreground">
|
||||
{serversCount === null ? "каталог неизвестен" : access.serversGranted + "/" + access.serversTotal}
|
||||
</span>
|
||||
</span>
|
||||
{access.writeKind === "full" ? (
|
||||
<Badge variant="success-light" size="sm">Полный доступ</Badge>
|
||||
) : access.writeKind === "none" ? (
|
||||
<Badge variant="outline" size="sm">Только просмотр</Badge>
|
||||
) : (
|
||||
<span className="inline-flex flex-wrap items-center gap-1">
|
||||
{access.writeSections.map((section) => (
|
||||
<Badge key={section} variant="info-light" size="sm">{section}</Badge>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Нет привязанных интерфейсов</p>
|
||||
) : (
|
||||
groups.map((g) => {
|
||||
const items = g.types.flatMap((tg) => tg.items)
|
||||
return (
|
||||
<div key={g.serverId}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{g.serverName}
|
||||
</p>
|
||||
<Flag code={g.serverCountry} size={12} />
|
||||
<span className="text-[11px] font-mono text-muted-foreground">{g.serverSite}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-2">
|
||||
{items.map((b) => {
|
||||
const meta = TYPE_ICON[b.interfaceType]
|
||||
const Icon = b.interfaceType === "wg" && b.peerPublicKey ? KeyRoundIcon : meta.icon
|
||||
const iconClass = b.interfaceType === "wg" && b.peerPublicKey ? "text-success" : meta.className
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
className="flex items-start gap-2 rounded-md border px-3 py-2.5"
|
||||
>
|
||||
<IconTile variant="elevated" className={cn("size-10.5 shrink-0", iconClass)}>
|
||||
<Icon />
|
||||
</IconTile>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-mono font-medium leading-tight truncate">
|
||||
{b.interfaceType === "wg" && (b.peerName || b.peerPublicKey)
|
||||
? `${b.peerName || "peer"} · ${b.interfaceName}`
|
||||
: b.interfaceName}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1 mt-0.5">
|
||||
<Badge variant={TYPE_VARIANT[b.interfaceType]} size="sm">
|
||||
{IFACE_TYPE_LABEL[b.interfaceType]}
|
||||
</Badge>
|
||||
{b.interfaceType === "wg" && !(b.peerPublicKey ?? "") ? (
|
||||
<Badge variant="warning-light" size="sm">весь интерфейс</Badge>
|
||||
) : null}
|
||||
{b.comment ? (
|
||||
<span className="text-[10px] text-muted-foreground leading-tight truncate">
|
||||
{b.comment}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { UsersExpandedDetail, TYPE_VARIANT, type UsersExpandedDetailProps }
|
||||
@@ -0,0 +1,315 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo } from "react"
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Server, VxlanTunnel } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
CodeXmlIcon,
|
||||
MoreHorizontalIcon,
|
||||
NetworkIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
interface VxlanDataGridProps {
|
||||
tunnels: VxlanTunnel[]
|
||||
servers: Server[]
|
||||
failedServerIds?: string[]
|
||||
onExport: (tunnel: VxlanTunnel) => void
|
||||
}
|
||||
|
||||
const VxlanActionsContext = createContext<VxlanDataGridProps["onExport"] | null>(null)
|
||||
function VxlanActionsCell({ row }: { row: { original: VxlanTunnel } }) {
|
||||
const onExport = useContext(VxlanActionsContext)
|
||||
if (!onExport) throw new Error("VXLAN actions context is missing")
|
||||
const tunnel = row.original
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"size-11 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
|
||||
"transition-colors",
|
||||
"data-popup-open:opacity-100",
|
||||
)}
|
||||
aria-label={`Действия: ${tunnel.name}`}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem onClick={() => onExport(tunnel)}>
|
||||
<CodeXmlIcon className="size-4" />
|
||||
Экспорт .rsc
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
function VxlanDataGrid({ tunnels, servers, onExport, failedServerIds = [] }: VxlanDataGridProps) {
|
||||
const serverMap = useMemo(
|
||||
() => new Map(servers.map((s) => [s.id, s])),
|
||||
[servers],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, VxlanTunnel>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
size: 176,
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Имя / VTEP" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const t = row.original
|
||||
return (
|
||||
<div className="min-w-0 flex items-start gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 rounded-full shrink-0 mt-1.5",
|
||||
failedServerIds.includes(t.serverId) ? "bg-muted-foreground" : t.status === "up" ? "bg-success" : "bg-destructive",
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono font-medium text-sm break-all">{t.name}</p>
|
||||
<p className="text-[11px] text-muted-foreground font-mono break-all">VTEP: {t.vtepIp}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Имя / VTEP",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "server",
|
||||
size: 128,
|
||||
accessorKey: "serverId",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||
cell: ({ row }) => {
|
||||
const srv = serverMap.get(row.original.serverId)
|
||||
if (!srv) return <span className="text-muted-foreground">—</span>
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
|
||||
<Flag code={srv.country} size={12} />
|
||||
<span className="font-mono truncate">{srv.name}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "vni",
|
||||
size: 80,
|
||||
accessorKey: "vni",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="VNI" />,
|
||||
cell: ({ row }) => <span className="font-mono text-sm">{row.original.vni}</span>,
|
||||
meta: {
|
||||
headerTitle: "VNI",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dstPort",
|
||||
size: 104,
|
||||
accessorKey: "dstPort",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Port" />,
|
||||
cell: ({ row }) => <span className="font-mono text-sm">{row.original.dstPort}</span>,
|
||||
meta: {
|
||||
headerTitle: "Port",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "mtu",
|
||||
size: 88,
|
||||
accessorKey: "mtu",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="MTU" />,
|
||||
cell: ({ row }) => <span className="font-mono text-sm">{row.original.mtu ?? "—"}</span>,
|
||||
meta: {
|
||||
headerTitle: "MTU",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "remoteVteps",
|
||||
size: 160,
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground">Remote</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm text-center block">{row.original.remoteVteps.length}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Remote",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "arpProxy",
|
||||
size: 104,
|
||||
accessorKey: "arpProxy",
|
||||
header: () => <span className="sr-only">ARP</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-mono px-1.5 py-0.5 rounded",
|
||||
row.original.arpProxy
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
ARP {row.original.arpProxy ? "✓" : "✗"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "macLearning",
|
||||
size: 128,
|
||||
accessorKey: "macLearning",
|
||||
header: () => <span className="sr-only">MAC</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-mono px-1.5 py-0.5 rounded",
|
||||
row.original.macLearning
|
||||
? "bg-sky-500/10 text-sky-600 dark:text-sky-400"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
MAC {row.original.macLearning ? "✓" : "✗"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
size: 104,
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap",
|
||||
failedServerIds.includes(row.original.serverId)
|
||||
? "bg-muted text-muted-foreground border-border"
|
||||
: row.original.status === "up"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||
)}
|
||||
>
|
||||
{failedServerIds.includes(row.original.serverId) ? "Неизвестно" : row.original.status === "up" ? "UP" : "DOWN"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: VxlanActionsCell,
|
||||
enableSorting: false,
|
||||
size: 72,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[serverMap, failedServerIds],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["name"], end: [] } },
|
||||
data: tunnels,
|
||||
columns,
|
||||
getRowId: (row) => JSON.stringify([row.serverId, row.id]),
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(tunnels.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [tunnels.length, pageIndex, pageSize, table])
|
||||
|
||||
if (tunnels.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<NetworkIcon className="size-4" />}
|
||||
title="Нет VXLAN-туннелей"
|
||||
description="Измените поиск или проверьте конфигурацию VXLAN в RouterOS"
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<VxlanActionsContext.Provider value={onExport}>
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={tunnels.length}
|
||||
pagination={tunnels.length > 50 || pageSize !== 50}
|
||||
tableLayout={{ dense: true, columnsPinnable: true }}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"),
|
||||
}}
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
</VxlanActionsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export { VxlanDataGrid, type VxlanDataGridProps }
|
||||
@@ -0,0 +1,336 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, type ReactNode } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { WireGuardInterface } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { WireGuardPeersDetail } from "@/components/data-grids/wireguard-peers-detail"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
CodeXmlIcon,
|
||||
MoreHorizontalIcon,
|
||||
PlusIcon,
|
||||
PowerIcon,
|
||||
ShieldCheckIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
export interface WgIfaceWithServer extends WireGuardInterface {
|
||||
serverId: string
|
||||
serverName: string
|
||||
serverCountry: string
|
||||
}
|
||||
|
||||
interface WireguardDataGridProps {
|
||||
interfaces: WgIfaceWithServer[]
|
||||
compactServer?: boolean
|
||||
canWrite?: boolean
|
||||
emptyAction?: ReactNode
|
||||
onExport: (iface: WgIfaceWithServer) => void
|
||||
onAddPeer?: (iface: WgIfaceWithServer) => void
|
||||
toggleBusy?: boolean
|
||||
onToggle?: (iface: WgIfaceWithServer) => void
|
||||
onDelete?: (iface: WgIfaceWithServer) => void
|
||||
onDeletePeer?: (iface: WgIfaceWithServer, peerId: string) => void
|
||||
onExportPeer?: (iface: WgIfaceWithServer, peerId: string) => void
|
||||
}
|
||||
|
||||
type WireguardActions = Pick<WireguardDataGridProps, "onExport" | "onAddPeer" | "onToggle" | "toggleBusy" | "onDelete" | "canWrite">
|
||||
const WireguardActionsContext = createContext<WireguardActions | null>(null)
|
||||
|
||||
// Preserve menu state while callbacks and the current row data update.
|
||||
function WireguardActionsCell({ row }: { row: { original: WgIfaceWithServer } }) {
|
||||
const actions = useContext(WireguardActionsContext)
|
||||
if (!actions) throw new Error("WireGuard actions context is missing")
|
||||
const { onExport, onAddPeer, onToggle, toggleBusy, onDelete, canWrite = true } = actions
|
||||
const iface = row.original
|
||||
return (
|
||||
<div
|
||||
className="flex justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"size-11 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
|
||||
"transition-[background-color,color,border-color]",
|
||||
"group-hover/row:opacity-100 focus-visible:opacity-100",
|
||||
"data-popup-open:opacity-100 data-popup-open:bg-muted",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Действия: ${iface.name}`}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem disabled={!canWrite} onClick={() => onExport(iface)}>
|
||||
<CodeXmlIcon className="size-4" />
|
||||
Экспорт
|
||||
</DropdownMenuItem>
|
||||
{onAddPeer && (
|
||||
<DropdownMenuItem disabled={!canWrite} onClick={() => onAddPeer(iface)}>
|
||||
<PlusIcon className="size-4" />
|
||||
Добавить пира
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onToggle && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={!canWrite || toggleBusy} onClick={() => onToggle(iface)}>
|
||||
<PowerIcon className="size-4" />
|
||||
{iface.enabled ? "Отключить" : "Включить"}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{onDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={!canWrite} variant="destructive" onClick={() => onDelete(iface)}>
|
||||
<Trash2Icon className="size-4" />
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WireguardDataGrid({
|
||||
interfaces,
|
||||
compactServer = false,
|
||||
canWrite = true,
|
||||
emptyAction,
|
||||
onExport,
|
||||
onAddPeer,
|
||||
onToggle,
|
||||
toggleBusy = false,
|
||||
onDelete,
|
||||
onDeletePeer,
|
||||
onExportPeer,
|
||||
}: WireguardDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, WgIfaceWithServer>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
size: 176,
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader
|
||||
column={column}
|
||||
title={compactServer ? "Интерфейс" : "Интерфейс / Сервер"}
|
||||
className="ml-1"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const iface = row.original
|
||||
const expanded = row.getIsExpanded()
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<Button type="button" variant="ghost" size="icon" className="size-11 shrink-0" aria-label={"Пиры: " + iface.name} aria-expanded={expanded} onClick={event => { event.stopPropagation(); row.toggleExpanded() }}>
|
||||
{expanded ? <ChevronDownIcon aria-hidden="true" className="size-3.5" /> : <ChevronRightIcon aria-hidden="true" className="size-3.5" />}
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 rounded-full shrink-0",
|
||||
iface.status === "up" ? "bg-success animate-pulse" : "bg-destructive",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 break-all font-mono font-semibold text-sm">{iface.name}</span>
|
||||
</div>
|
||||
{!compactServer ? (
|
||||
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
|
||||
<Flag code={iface.serverCountry || "UN"} size={12} />
|
||||
{iface.serverName}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="sr-only">
|
||||
{iface.peers.length} пиров
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: compactServer ? "Интерфейс" : "Интерфейс / Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
expandedContent: (row: WgIfaceWithServer) => (
|
||||
<WireGuardPeersDetail
|
||||
peers={row.peers}
|
||||
interfaceName={row.name}
|
||||
onDeletePeer={onDeletePeer ? (peerId) => onDeletePeer(row, peerId) : undefined}
|
||||
onExportPeer={onExportPeer ? (peerId) => onExportPeer(row, peerId) : undefined}
|
||||
/>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "listenPort",
|
||||
size: 100,
|
||||
accessorKey: "listenPort",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Порт" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm">{row.original.listenPort}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Порт",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "mtu",
|
||||
size: 80,
|
||||
accessorKey: "mtu",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="MTU" />,
|
||||
cell: ({ row }) => <span className="font-mono text-sm">{row.original.mtu}</span>,
|
||||
meta: {
|
||||
headerTitle: "MTU",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "peers",
|
||||
size: 80,
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground">Пиры</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const iface = row.original
|
||||
return (
|
||||
<span className="font-mono text-sm text-center block">
|
||||
{iface.peers.length}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Пиры",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
size: 104,
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={row.original.status === "up" ? "success-light" : "destructive-light"}
|
||||
size="sm"
|
||||
className="font-mono"
|
||||
>
|
||||
{row.original.status === "up" ? "UP" : "DOWN"}
|
||||
</Badge>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: WireguardActionsCell,
|
||||
enableSorting: false,
|
||||
size: 72,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[compactServer, onDeletePeer, onExportPeer],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
autoResetExpanded: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["name"], end: [] } },
|
||||
data: interfaces,
|
||||
columns,
|
||||
getRowId: (row) => JSON.stringify([row.serverId, row.id]),
|
||||
getRowCanExpand: () => true,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(interfaces.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [interfaces.length, pageIndex, pageSize, table])
|
||||
|
||||
if (interfaces.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<ShieldCheckIcon className="size-4" />}
|
||||
title="Нет WireGuard интерфейсов"
|
||||
description="Добавьте первый интерфейс или сбросьте фильтры"
|
||||
action={emptyAction}
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<WireguardActionsContext.Provider value={{ onExport, onAddPeer, onToggle, toggleBusy, onDelete, canWrite }}>
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={interfaces.length}
|
||||
pagination={interfaces.length > 50 || pageSize !== 50}
|
||||
tableLayout={{ dense: true, columnsPinnable: true }}
|
||||
onRowClick={(row) => table.getRow(JSON.stringify([row.serverId, row.id])).toggleExpanded()}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"),
|
||||
}}
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
</WireguardActionsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export { WireguardDataGrid, type WireguardDataGridProps }
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import type { WireGuardPeer } from "@/lib/data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CodeXmlIcon,
|
||||
KeyRoundIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
function fmtBytes(n: number | undefined): string {
|
||||
if (n === undefined || !Number.isFinite(n) || n < 0) return "—"
|
||||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)} ГБ`
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)} КБ`
|
||||
return `${n} Б`
|
||||
}
|
||||
|
||||
function truncKey(key: string): string {
|
||||
if (key.length <= 20) return key
|
||||
return `${key.slice(0, 8)}…${key.slice(-8)}`
|
||||
}
|
||||
|
||||
function peerKey(peer: WireGuardPeer, index: number): string {
|
||||
return peer.id ?? peer.rosId ?? peer.publicKey ?? String(index)
|
||||
}
|
||||
|
||||
function WireGuardPeersDetail({
|
||||
peers,
|
||||
interfaceName,
|
||||
onDeletePeer,
|
||||
onExportPeer,
|
||||
}: {
|
||||
peers: WireGuardPeer[]
|
||||
interfaceName: string
|
||||
onDeletePeer?: (peerId: string) => void
|
||||
onExportPeer?: (peerId: string) => void
|
||||
}) {
|
||||
const [page, setPage] = useState(0)
|
||||
const lastPage = Math.max(0, Math.ceil(peers.length / 50) - 1)
|
||||
const currentPage = Math.min(page, lastPage)
|
||||
const offset = currentPage * 50
|
||||
if (page > lastPage) setPage(lastPage)
|
||||
if (peers.length === 0) {
|
||||
return (
|
||||
<div className="px-5 py-4 text-xs text-muted-foreground text-center border-t border-border/50">
|
||||
Нет пиров
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label={"Пиры: " + interfaceName} className="sticky left-0 w-full max-w-[calc(100vw-2rem)] border-t border-border/50 md:max-w-none">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 px-4 py-2 bg-muted/10">
|
||||
<p className="text-xs text-muted-foreground" aria-live="polite">Пиры {offset + 1}–{Math.min(offset + 50, peers.length)} из {peers.length}</p>
|
||||
{peers.length > 50 && <div className="flex gap-1">
|
||||
<Button type="button" variant="outline" className="min-h-11" disabled={currentPage === 0} aria-label="Предыдущая страница пиров" onClick={() => setPage(currentPage - 1)}>Назад</Button>
|
||||
<Button type="button" variant="outline" className="min-h-11" disabled={currentPage >= lastPage} aria-label="Следующая страница пиров" onClick={() => setPage(currentPage + 1)}>Далее</Button>
|
||||
</div>}
|
||||
</div>
|
||||
{peers.slice(offset, offset + 50).map((peer, index) => {
|
||||
const id = peerKey(peer, offset + index)
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
data-wireguard-peer={id}
|
||||
className="grid grid-cols-2 gap-3 px-4 py-3 items-center text-xs border-t border-border/50 bg-muted/20 md:grid-cols-[1fr_1fr_auto_auto_auto_auto]"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-muted-foreground break-all" title={peer.publicKey}>
|
||||
{peer.name || truncKey(peer.publicKey)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 break-all font-mono text-muted-foreground">
|
||||
<span className="sr-only">Allowed IPs: </span><span>{peer.allowedIps.join(", ")}</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-[11px] whitespace-nowrap",
|
||||
"text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Последнее рукопожатие: </span><span>{peer.latestHandshake?.trim() || "Нет данных"}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
|
||||
<span aria-label="RX" className="flex items-center gap-0.5">
|
||||
<ArrowDownIcon aria-hidden="true" className="size-3 text-success" />
|
||||
{fmtBytes(peer.transferRx)}
|
||||
</span>
|
||||
<span aria-label="TX" className="flex items-center gap-0.5">
|
||||
<ArrowUpIcon aria-hidden="true" className="size-3 text-info" />
|
||||
{fmtBytes(peer.transferTx)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="min-w-0 break-all font-mono text-muted-foreground/60 text-[11px]"><span>Endpoint: </span>{peer.endpoint ?? "—"}</span>
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
{onExportPeer && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
aria-label="Экспорт peer .conf"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onExportPeer(id)
|
||||
}}
|
||||
>
|
||||
<CodeXmlIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{onDeletePeer && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 text-destructive"
|
||||
aria-label="Удалить пира"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDeletePeer(id)
|
||||
}}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export { WireGuardPeersDetail, fmtBytes, truncKey }
|
||||
@@ -0,0 +1,289 @@
|
||||
"use client"
|
||||
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useEffect, useMemo, type ReactNode } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { WireGuardPeer } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { fmtBytes, truncKey } from "@/components/data-grids/wireguard-peers-detail"
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CodeXmlIcon,
|
||||
KeyRoundIcon,
|
||||
Trash2Icon,
|
||||
UsersIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export interface WgPeerRow extends WireGuardPeer {
|
||||
id: string
|
||||
ifaceId: string
|
||||
ifaceName: string
|
||||
serverId: string
|
||||
serverName: string
|
||||
serverCountry: string
|
||||
}
|
||||
|
||||
interface WireguardPeersGridProps {
|
||||
peers: WgPeerRow[]
|
||||
compactServer?: boolean
|
||||
emptyAction?: ReactNode
|
||||
onDeletePeer?: (row: WgPeerRow) => void
|
||||
onExportPeer?: (row: WgPeerRow) => void
|
||||
}
|
||||
|
||||
function WireguardPeersGrid({
|
||||
peers,
|
||||
compactServer = false,
|
||||
emptyAction,
|
||||
onDeletePeer,
|
||||
onExportPeer,
|
||||
}: WireguardPeersGridProps) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, WgPeerRow>[]>(() => {
|
||||
const cols: ColumnDef<DataGridFeatures, WgPeerRow>[] = [
|
||||
{
|
||||
id: "peer",
|
||||
size: 144,
|
||||
accessorKey: "publicKey",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Пир" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const peer = row.original
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<KeyRoundIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-mono text-xs" title={peer.publicKey}>
|
||||
{peer.name || truncKey(peer.publicKey)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Пир",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "iface",
|
||||
size: 120,
|
||||
accessorKey: "ifaceName",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm">{row.original.ifaceName}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Интерфейс",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (!compactServer) {
|
||||
cols.push({
|
||||
id: "server",
|
||||
size: 128,
|
||||
accessorKey: "serverName",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground">
|
||||
<Flag code={row.original.serverCountry || "UN"} size={12} />
|
||||
{row.original.serverName}
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
cols.push(
|
||||
{
|
||||
id: "allowedIps",
|
||||
size: 136,
|
||||
accessorFn: (row) => row.allowedIps.join(", "),
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Allowed IPs" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="block truncate font-mono text-xs text-muted-foreground">
|
||||
{row.original.allowedIps.join(", ") || "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Allowed IPs",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "handshake",
|
||||
size: 144,
|
||||
accessorKey: "latestHandshake",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Handshake" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"whitespace-nowrap font-mono text-[11px]",
|
||||
"text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{row.original.latestHandshake?.trim() || "Нет данных"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Handshake",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "transfer",
|
||||
size: 160,
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground">RX / TX</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2 whitespace-nowrap text-xs text-muted-foreground">
|
||||
<span aria-label="RX" className="flex items-center gap-0.5">
|
||||
<ArrowDownIcon aria-hidden="true" className="size-3 text-success" />
|
||||
{fmtBytes(row.original.transferRx)}
|
||||
</span>
|
||||
<span aria-label="TX" className="flex items-center gap-0.5">
|
||||
<ArrowUpIcon aria-hidden="true" className="size-3 text-info" />
|
||||
{fmtBytes(row.original.transferTx)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "RX / TX",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "endpoint",
|
||||
size: 144,
|
||||
accessorKey: "endpoint",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Endpoint" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-[11px] text-muted-foreground">
|
||||
{row.original.endpoint ?? "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Endpoint",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
enableSorting: false,
|
||||
size: 112,
|
||||
cell: ({ row }) => {
|
||||
const peer = row.original
|
||||
return (
|
||||
<div className="flex justify-end gap-0.5">
|
||||
{onExportPeer ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
aria-label="Экспорт peer .conf"
|
||||
onClick={() => onExportPeer(peer)}
|
||||
>
|
||||
<CodeXmlIcon className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
{onDeletePeer ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 text-destructive"
|
||||
aria-label="Удалить пира"
|
||||
onClick={() => onDeletePeer(peer)}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return cols
|
||||
}, [compactServer, onDeletePeer, onExportPeer])
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["peer"], end: [] } },
|
||||
data: peers,
|
||||
columns,
|
||||
getRowId: (row) => JSON.stringify([row.serverId, row.ifaceId, row.id]),
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(peers.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [peers.length, pageIndex, pageSize, table])
|
||||
|
||||
if (peers.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<UsersIcon className="size-4" />}
|
||||
title="Нет пиров"
|
||||
description="Добавьте пира к интерфейсу или сбросьте поиск"
|
||||
action={emptyAction}
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={peers.length}
|
||||
pagination={peers.length > 50 || pageSize !== 50}
|
||||
tableLayout={{ dense: true, columnsPinnable: true }}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row"),
|
||||
}}
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
export { WireguardPeersGrid, type WireguardPeersGridProps }
|
||||
Reference in new issue
Block a user