fix(ui): заменить таблицы на карточки данных и улучшить функциональность поиска
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m37s
Docker images / frontend-image (push) Successful in 1m50s
Docker images / updater-image (push) Successful in 44s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s

This commit is contained in:
Denozordec
2026-06-30 22:22:51 +07:00
parent a1a9124f3d
commit d3a2d38b37
63 changed files with 8509 additions and 3652 deletions
+146
View File
@@ -0,0 +1,146 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} 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
pagination?: boolean
}
function AsnsDataGrid({ asns, isLoading, pagination = false }: AsnsDataGridProps) {
const columns = useMemo<ColumnDef<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 = useReactTable({
data: asns,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
...(pagination ? { getPaginationRowModel: getPaginationRowModel() } : {}),
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="Добавьте автономные системы или импортируйте каталог"
className="border-0 py-12"
/>
}
/>
)
}
export { AsnsDataGrid, type AsnsDataGridProps }
+49 -20
View File
@@ -10,12 +10,13 @@ import {
import type { Backup } from "@/lib/data"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DataGrid,
DataGridColumnHeader,
DataGridContainer,
DataGridTable,
} from "@/components/reui/data-grid"
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"
@@ -37,31 +38,46 @@ function BackupsDataGrid({
{
id: "filename",
accessorKey: "filename",
header: ({ column }) => <DataGridColumnHeader column={column} title="Файл" />,
header: ({ column }) => <DataGridSortHeader column={column} title="Файл" />,
cell: ({ row }) => (
<span className="font-mono text-xs font-medium">{row.original.filename}</span>
),
meta: {
headerTitle: "Файл",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "server",
accessorKey: "server",
header: ({ column }) => <DataGridColumnHeader column={column} title="Сервер" />,
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",
accessorKey: "size",
header: ({ column }) => <DataGridColumnHeader column={column} title="Размер" />,
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 }) => <DataGridColumnHeader column={column} title="Тип" />,
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
cell: ({ row }) => {
const kind = row.original.kind
return (
@@ -77,32 +93,47 @@ function BackupsDataGrid({
</span>
)
},
meta: {
headerTitle: "Тип",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "notes",
accessorKey: "notes",
header: ({ column }) => <DataGridColumnHeader column={column} title="Заметки" />,
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 }) => <DataGridColumnHeader column={column} title="Создан" />,
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: () => null,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const b = row.original
return (
<div className="flex items-center gap-1 justify-end">
<div className="flex items-center gap-1 justify-end opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
<Button
variant="ghost"
size="icon"
@@ -135,6 +166,10 @@ function BackupsDataGrid({
},
enableSorting: false,
size: 120,
meta: {
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[onDelete, onDownload, onRestore],
@@ -159,13 +194,7 @@ function BackupsDataGrid({
)
}
return (
<DataGrid table={table} recordCount={backups.length} isLoading={false}>
<DataGridContainer>
<DataGridTable />
</DataGridContainer>
</DataGrid>
)
return <DataGridShell table={table} recordCount={backups.length} />
}
export { BackupsDataGrid, type BackupsDataGridProps }
@@ -9,12 +9,12 @@ import {
useReactTable,
} from "@tanstack/react-table"
import { cn } from "@/lib/utils"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DataGrid,
DataGridColumnHeader,
DataGridContainer,
DataGridTable,
} from "@/components/reui/data-grid"
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 { BgpSessionDetail } from "@/components/data-grids/bgp-session-detail"
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
@@ -109,7 +109,9 @@ function BgpSessionsDataGrid({ sessions }: BgpSessionsDataGridProps) {
{
id: "serverLabel",
accessorKey: "serverLabel",
header: ({ column }) => <DataGridColumnHeader column={column} title="Роутер" />,
header: ({ column }) => (
<DataGridSortHeader column={column} title="Роутер" className="ml-1" />
),
cell: ({ row }) => {
const expanded = row.getIsExpanded()
return (
@@ -125,19 +127,26 @@ function BgpSessionsDataGrid({ sessions }: BgpSessionsDataGridProps) {
},
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 }) => <DataGridColumnHeader column={column} title="Peer IP" />,
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 }) => <DataGridColumnHeader column={column} title="Remote AS" />,
header: ({ column }) => <DataGridSortHeader column={column} title="Remote AS" />,
cell: ({ row }) => {
const as = row.original.remoteAs
return (
@@ -149,43 +158,68 @@ function BgpSessionsDataGrid({ sessions }: BgpSessionsDataGridProps) {
</div>
)
},
meta: {
headerTitle: "Remote AS",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "description",
accessorKey: "description",
header: ({ column }) => <DataGridColumnHeader column={column} title="Описание" />,
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 }) => <DataGridColumnHeader column={column} title="Тип" />,
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 }) => <DataGridColumnHeader column={column} title="Состояние" />,
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 }) => <DataGridColumnHeader column={column} title="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 }) => <DataGridColumnHeader column={column} title="Prefixes ↓" />,
header: ({ column }) => <DataGridSortHeader column={column} title="Prefixes ↓" />,
cell: ({ row }) => {
const n = row.original.prefixesRx
return n > 0 ? (
@@ -196,11 +230,16 @@ function BgpSessionsDataGrid({ sessions }: BgpSessionsDataGridProps) {
<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 }) => <DataGridColumnHeader column={column} title="Prefixes ↑" />,
header: ({ column }) => <DataGridSortHeader column={column} title="Prefixes ↑" />,
cell: ({ row }) => {
const n = row.original.prefixesTx
return n > 0 ? (
@@ -211,6 +250,11 @@ function BgpSessionsDataGrid({ sessions }: BgpSessionsDataGridProps) {
<span className="text-muted-foreground"></span>
)
},
meta: {
headerTitle: "Prefixes ↑",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
],
[],
@@ -223,6 +267,7 @@ function BgpSessionsDataGrid({ sessions }: BgpSessionsDataGridProps) {
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getRowId: (row) => row.id,
getRowCanExpand: () => true,
})
if (sessions.length === 0) {
@@ -237,16 +282,11 @@ function BgpSessionsDataGrid({ sessions }: BgpSessionsDataGridProps) {
}
return (
<DataGrid
<DataGridShell
table={table}
recordCount={sessions.length}
isLoading={false}
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
>
<DataGridContainer>
<DataGridTable />
</DataGridContainer>
</DataGrid>
/>
)
}
@@ -0,0 +1,61 @@
"use client"
import type { CertificateDto } from "@mmapp/contracts/certificates"
import { cn } from "@/lib/utils"
function daysLeftBar(days: number, total = 365): number {
if (days <= 0) return 0
return Math.min(100, Math.round((days / total) * 100))
}
function CertificateExpandedDetail({ cert }: { cert: CertificateDto }) {
const pct = daysLeftBar(cert.daysLeft)
return (
<div className="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">Key size</p>
<p className="font-mono font-medium">{cert.keySize} bit</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 / Alt Names</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">Trusted</p>
<p className={cert.trusted ? "text-emerald-600 dark:text-emerald-400" : "text-red-500"}>
{cert.trusted ? "Да (доверенный)" : "Нет (не доверенный)"}
</p>
</div>
<div className="col-span-2 sm:col-span-4">
<div className="h-1.5 bg-muted rounded-full overflow-hidden max-w-xs">
<div
className={cn(
"h-full rounded-full",
cert.daysLeft < 0 || cert.daysLeft <= 7
? "bg-red-500"
: cert.daysLeft <= 30
? "bg-amber-500"
: "bg-emerald-500",
)}
style={{ width: `${pct}%` }}
/>
</div>
</div>
</div>
)
}
export { CertificateExpandedDetail, daysLeftBar }
@@ -0,0 +1,279 @@
"use client"
import { useMemo, type ReactNode } from "react"
import {
type ColumnDef,
getCoreRowModel,
getExpandedRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { CertStatus, Server } from "@/lib/data"
import type { CertificateDto } from "@mmapp/contracts/certificates"
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 {
CertificateExpandedDetail,
daysLeftBar,
} from "@/components/data-grids/certificate-expanded-detail"
import { EmptyState } from "@/components/empty-state"
import {
BadgeCheckIcon,
ChevronDownIcon,
ChevronRightIcon,
ServerIcon,
ShieldAlertIcon,
ShieldCheckIcon,
ShieldOffIcon,
} from "lucide-react"
const STATUS_CONFIG: Record<
CertStatus,
{ label: string; icon: ReactNode; badge: string; row: string }
> = {
valid: {
label: "Действителен",
icon: <BadgeCheckIcon className="size-4 text-emerald-500" />,
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
row: "",
},
expired: {
label: "Истёк",
icon: <ShieldOffIcon className="size-4 text-red-500" />,
badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
row: "bg-red-500/5",
},
revoked: {
label: "Отозван",
icon: <ShieldAlertIcon className="size-4 text-amber-500" />,
badge: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
row: "bg-amber-500/5",
},
}
function daysLeftColor(days: number): string {
if (days < 0) return "text-red-500"
if (days <= 7) return "text-red-500"
if (days <= 30) return "text-amber-500"
return "text-emerald-600 dark:text-emerald-400"
}
function CertDaysCell({ cert }: { cert: CertificateDto }) {
const pct = daysLeftBar(cert.daysLeft)
return (
<>
<div className="flex items-center justify-between text-xs mb-1">
<span className={cn("font-mono font-medium", daysLeftColor(cert.daysLeft))}>
{cert.daysLeft < 0 ? `Истёк ${-cert.daysLeft}д назад` : `${cert.daysLeft}д осталось`}
</span>
<span className="text-muted-foreground text-[10px]">{cert.validUntil}</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full",
cert.daysLeft < 0 || cert.daysLeft <= 7
? "bg-red-500"
: cert.daysLeft <= 30
? "bg-amber-500"
: "bg-emerald-500",
)}
style={{ width: `${pct}%` }}
/>
</div>
</>
)
}
interface CertificatesDataGridProps {
certificates: CertificateDto[]
serverMap: Map<string, Server>
isLoading?: boolean
}
function CertificatesDataGrid({ certificates, serverMap, isLoading }: CertificatesDataGridProps) {
const columns = useMemo<ColumnDef<CertificateDto>[]>(
() => [
{
id: "name",
accessorKey: "name",
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">
{expanded ? (
<ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />
)}
<div className="min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className="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: CertificateDto) => <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",
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 = useReactTable({
data: certificates,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getRowId: (row) => row.id,
getRowCanExpand: () => true,
})
if (!isLoading && certificates.length === 0) {
return (
<EmptyState
icon={<ShieldCheckIcon className="size-4" />}
title="Нет сертификатов"
description="Выпустите или импортируйте сертификат"
className="border-0 py-12"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={certificates.length}
isLoading={isLoading}
loadingMode="skeleton"
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
/>
)
}
export { CertificatesDataGrid, STATUS_CONFIG, type CertificatesDataGridProps }
@@ -0,0 +1,272 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} 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 {
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
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
copiedValue?: string | null
onSelect: (community: CommunityRow) => void
onCopy: (value: string) => void
}
function CommunitiesDataGrid({
communities,
selectedId,
copiedValue,
onSelect,
onCopy,
}: CommunitiesDataGridProps) {
const columns = useMemo<ColumnDef<CommunityRow>[]>(
() => [
{
id: "value",
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 bg-muted px-1.5 py-0.5 rounded">
{c.value}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onCopy(c.value)
}}
className="text-muted-foreground/40 hover:text-muted-foreground transition-colors"
>
{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",
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",
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",
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",
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",
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: () => <ChevronRightIcon className="size-4 text-muted-foreground/40" />,
size: 40,
meta: {
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[copiedValue, onCopy],
)
const rowSelection = useMemo(
() => (selectedId ? { [selectedId]: true } : {}),
[selectedId],
)
const table = useReactTable({
data: communities,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
enableRowSelection: true,
state: { rowSelection },
})
if (communities.length === 0) {
return (
<EmptyState
icon={<TagIcon className="size-4" />}
title="Ничего не найдено"
className="border-0 py-16"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={communities.length}
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",
),
}}
/>
)
}
export {
CommunitiesDataGrid,
type CommunitiesDataGridProps,
TYPE_LABELS,
ACTION_LABELS,
ACTION_COLOR,
}
+116
View File
@@ -0,0 +1,116 @@
"use client"
import { useMemo, type ReactNode } from "react"
import {
type ColumnDef,
getCoreRowModel,
getExpandedRowModel,
getSortedRowModel,
useReactTable,
} 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 { 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
headerClassName?: string
cellClassName?: string
}
interface CompactDataGridProps<T extends { id: string }> {
data: T[]
columns: CompactDataGridColumn<T>[]
isLoading?: boolean
emptyTitle?: string
emptyDescription?: string
onRowClick?: (row: T) => void
getExpandedContent?: (row: T) => ReactNode
compact?: boolean
}
function CompactDataGrid<T extends { id: string }>({
data,
columns,
isLoading,
emptyTitle = "Нет записей",
emptyDescription,
onRowClick,
getExpandedContent,
compact = false,
}: CompactDataGridProps<T>) {
const pad = compact ? "py-2" : DATA_GRID_CELL_PAD
const padFirst = compact ? "pl-4 py-2" : DATA_GRID_CELL_PAD_FIRST
const padLast = compact ? "pr-4 py-2" : DATA_GRID_CELL_PAD_LAST
const columnDefs = useMemo<ColumnDef<T>[]>(
() =>
columns.map((col, index) => ({
id: col.id,
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 ? padFirst : index === columns.length - 1 ? padLast : pad),
cellClassName:
col.cellClassName ??
(index === 0 ? padFirst : index === columns.length - 1 ? padLast : pad),
...(getExpandedContent && index === 0
? { expandedContent: getExpandedContent }
: {}),
},
})),
[columns, getExpandedContent, pad, padFirst, padLast],
)
const table = useReactTable({
data,
columns: columnDefs,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
...(getExpandedContent ? { getExpandedRowModel: getExpandedRowModel() } : {}),
getRowId: (row) => row.id,
...(getExpandedContent ? { getRowCanExpand: () => true } : {}),
})
return (
<DataGridShell
table={table}
recordCount={data.length}
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"
/>
}
/>
)
}
export { CompactDataGrid, type CompactDataGridProps }
@@ -0,0 +1,191 @@
"use client"
import { useMemo } from "react"
import type { PingProbe, Server, ServerType } from "@/lib/data"
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): string {
if (!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: PingProbe; 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="offline" />
</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: PingProbe[]
catalog: Server[]
isLoading?: boolean
emptyTitle?: string
emptyDescription?: string
}
function DashboardActiveProbesDataGrid({
probes,
catalog,
isLoading,
emptyTitle = "Нет активных проб",
emptyDescription,
}: DashboardActiveProbesDataGridProps) {
const columns = useMemo<CompactDataGridColumn<PingProbe>[]>(
() => [
{
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 > 5 ? "text-red-500" : p.loss > 0 ? "text-amber-500" : "text-muted-foreground",
)}
>
{formatLossPct(p.loss)}
</span>
),
},
{
id: "series",
header: "60с",
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) => (
<StatusBadge
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
/>
),
},
],
[catalog],
)
return (
<CompactDataGrid
data={probes}
columns={columns}
compact
isLoading={isLoading}
emptyTitle={emptyTitle}
emptyDescription={emptyDescription}
/>
)
}
export { DashboardActiveProbesDataGrid, type DashboardActiveProbesDataGridProps }
@@ -0,0 +1,219 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { SchedulerJobStatusDto } from "@/lib/scheduler-settings"
import { FormToggle } from "@/components/form-kit"
import { Badge } from "@/components/ui/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
intervalValue: string
intervalReadOnly: boolean
intervalDisabled: boolean
defaultInterval: number
job?: SchedulerJobStatusDto
onEnabledChange?: (enabled: boolean) => void
onIntervalChange: (value: string) => void
onRunNow: () => void
runNowLoading: boolean
saveBusy: boolean
}
interface DataCollectionSchedulerDataGridProps {
rows: SchedulerJobGridRow[]
}
function DataCollectionSchedulerDataGrid({ rows }: DataCollectionSchedulerDataGridProps) {
const columns = useMemo<ColumnDef<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 } = row.original
return (
<div className="text-center align-top">
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
<FormToggle
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={String(r.defaultInterval)}
/>
</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.job?.running || r.runNowLoading}
onClick={r.onRunNow}
>
<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 = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
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 }
+155
View File
@@ -0,0 +1,155 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} 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
pagination?: boolean
}
function DomainsDataGrid({ domains, isLoading, pagination = false }: DomainsDataGridProps) {
const columns = useMemo<ColumnDef<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 = useReactTable({
data: domains,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
...(pagination ? { getPaginationRowModel: getPaginationRowModel() } : {}),
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="Добавьте домены или импортируйте каталог"
className="border-0 py-12"
/>
}
/>
)
}
export { DomainsDataGrid, type DomainsDataGridProps }
+457
View File
@@ -0,0 +1,457 @@
"use client"
import { useMemo, useState } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} 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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
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,
AlertTriangleIcon,
CheckCircle2Icon,
ChevronDownIcon,
ChevronUpIcon,
CircleDashedIcon,
PencilIcon,
RouteIcon,
StarIcon,
TrashIcon,
XCircleIcon,
FilterIcon,
} from "lucide-react"
export type FilterRouterSyncStatus = "synced" | "drift" | "missing"
export interface RecursiveRouteLite {
id: string
dstAddress: string
gateway: string
distance: number
routingTable: string
comment: string
disabled: boolean
}
function RouterSyncMarker({
status,
}: {
status: FilterRouterSyncStatus | null | "skip"
}) {
if (status === "skip") {
return <span className="size-3.5 shrink-0 block" aria-hidden />
}
const icon =
status === "synced"
? <CheckCircle2Icon className="size-3.5 text-emerald-600 dark:text-emerald-500 shrink-0" />
: status === "drift"
? <AlertTriangleIcon className="size-3.5 text-amber-500 shrink-0" />
: status === "missing"
? <XCircleIcon className="size-3.5 text-destructive shrink-0" />
: <CircleDashedIcon className="size-3.5 text-muted-foreground/35 shrink-0" />
const title =
status === "synced"
? "Совпадает с цепочкой bgp-in на MikroTik"
: status === "drift"
? "В БД и на роутере разное действие (gateway, blackhole или out-interface)"
: status === "missing"
? "Эта community не найдена в правиле bgp-in на роутере"
: "Не проверено — нажмите «Сверить с роутером»"
return (
<Tooltip>
<TooltipTrigger className="inline-flex cursor-default border-0 bg-transparent p-0">
{icon}
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{title}
</TooltipContent>
</Tooltip>
)
}
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 && !(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({
onEdit,
onDelete,
}: {
onEdit: () => void
onDelete: () => void
}) {
const [confirmDel, setConfirmDel] = useState(false)
return (
<div className="flex items-center gap-0.5 justify-end opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
<Button
size="sm"
variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onEdit}
>
<PencilIcon className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className={cn(
"size-7 p-0 transition-colors",
confirmDel
? "text-destructive bg-destructive/10 hover:bg-destructive/20"
: "text-muted-foreground hover:text-destructive",
)}
onClick={() => {
if (!confirmDel) setConfirmDel(true)
else onDelete()
}}
onBlur={() => setConfirmDel(false)}
>
{confirmDel ? <AlertCircleIcon className="size-3.5" /> : <TrashIcon className="size-3.5" />}
</Button>
</div>
)
}
interface FiltersDataGridProps {
rules: FilterRule[]
tunnelsList: GreTunnel[]
serversList: Server[]
communityNameMap: Record<string, string>
recursiveRoutes: RecursiveRouteLite[]
routerSyncByCommunity?: Record<string, FilterRouterSyncStatus> | null
isLive?: boolean
enableSorting?: boolean
onEdit: (rule: FilterRule) => void
onDelete: (id: string) => void
onMoveUp: (id: string) => void
onMoveDown: (id: string) => void
}
function FiltersDataGrid({
rules,
tunnelsList,
serversList,
communityNameMap,
recursiveRoutes,
routerSyncByCommunity,
isLive,
enableSorting = false,
onEdit,
onDelete,
onMoveUp,
onMoveDown,
}: FiltersDataGridProps) {
const columns = useMemo<ColumnDef<FilterRule>[]>(
() => [
{
id: "priority",
header: () => <span className="text-xs font-medium text-muted-foreground">#</span>,
enableSorting: false,
cell: ({ row, table }) => {
const isLast = row.index === table.getRowModel().rows.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>{row.index + 1}</span>
)}
</div>
)
},
size: 28,
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, table }) => {
const isLast = row.index === table.getRowModel().rows.length - 1
return (
<div className="flex flex-col gap-px opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
<button
type="button"
onClick={() => onMoveUp(row.original.id)}
disabled={row.index === 0}
className="text-muted-foreground/50 hover:text-foreground disabled:opacity-20"
>
<ChevronUpIcon className="size-3" />
</button>
<button
type="button"
onClick={() => onMoveDown(row.original.id)}
disabled={isLast}
className="text-muted-foreground/50 hover:text-foreground disabled:opacity-20"
>
<ChevronDownIcon className="size-3" />
</button>
</div>
)
},
size: 28,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "routerSync",
header: () => (
<Tooltip>
<TooltipTrigger className="cursor-help font-mono text-xs text-muted-foreground border-0 bg-transparent p-0">
MT
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
Совпадение с MikroTik (bgp-in)
</TooltipContent>
</Tooltip>
),
enableSorting: false,
cell: ({ row }) => (
<RouterSyncMarker
status={
!isLive
? "skip"
: !routerSyncByCommunity
? null
: routerSyncByCommunity[row.original.community.trim()] ?? null
}
/>
),
size: 32,
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "community",
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",
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",
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
onEdit={() => onEdit(row.original)}
onDelete={() => onDelete(row.original.id)}
/>
),
size: 64,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
[
communityNameMap,
enableSorting,
isLive,
onDelete,
onEdit,
onMoveDown,
onMoveUp,
recursiveRoutes,
routerSyncByCommunity,
serversList,
tunnelsList,
],
)
const table = useReactTable({
data: rules,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
if (rules.length === 0) {
return (
<EmptyState
icon={<FilterIcon className="size-4" />}
title="Ничего не найдено"
className="border-0 py-16"
/>
)
}
return (
<>
<DataGridShell table={table} recordCount={rules.length} />
<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, RouterSyncMarker, type FiltersDataGridProps }
@@ -0,0 +1,311 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} 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,
} 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 {
CopyIcon,
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[]
onToggle: (id: string) => void
onEdit: (rule: FirewallRule) => void
}
function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGridProps) {
const indexedRules = useMemo(
() => rules.map((rule, index) => ({ ...rule, _index: index + 1 })),
[rules],
)
const columns = useMemo<ColumnDef<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: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "chain",
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",
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",
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",
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",
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",
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",
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",
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",
accessorKey: "enabled",
header: () => <span className="text-xs font-medium text-muted-foreground">Вкл</span>,
enableSorting: false,
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()}>
<FormToggle 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-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
"opacity-0 transition-opacity group-hover/row:opacity-100 focus-visible:opacity-100",
"data-popup-open:opacity-100",
)}
aria-label={`Действия: ${r.chain} ${r.action}`}
>
<MoreHorizontalIcon className="size-4" />
</Button>
}
/>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem onClick={() => onEdit(r)}>
<PencilIcon className="size-4" />
Редактировать
</DropdownMenuItem>
<DropdownMenuItem>
<CopyIcon className="size-4" />
Дублировать
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onToggle(r.id)}>
<PowerIcon className="size-4" />
{r.enabled ? "Отключить" : "Включить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon className="size-4" />
Удалить правило
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
},
size: 56,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
[onEdit, onToggle],
)
const table = useReactTable({
data: indexedRules,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.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}
tableClassNames={{
headerRow: "border-b border-border",
bodyRow: cn("group/row", "[&:has([data-rule-disabled=true])]:opacity-40"),
}}
/>
)
}
export { FirewallRulesDataGrid, type FirewallRulesDataGridProps, ActionBadge, ChainBadge }
@@ -0,0 +1,216 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
useReactTable,
} 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_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<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",
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",
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",
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",
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",
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",
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",
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",
header: () => null,
enableSorting: false,
cell: ({ row }) => {
const index = row.original._index - 1
return (
<div className="flex items-center gap-0.5 justify-end opacity-0 group-hover/row:opacity-100 transition-opacity">
<button
type="button"
onClick={() => onToggleEnabled(row.original.id)}
title={row.original.enabled ? "Отключить" : "Включить"}
className="p-0.5 text-muted-foreground/40 hover:text-foreground transition-colors"
>
<PowerIcon className="size-3.5" />
</button>
<button
type="button"
onClick={() => onMoveUp(row.original.id)}
disabled={index === 0}
className="p-0.5 text-muted-foreground/40 hover:text-foreground disabled:opacity-20 transition-colors"
>
</button>
<button
type="button"
onClick={() => onMoveDown(row.original.id)}
disabled={index === rules.length - 1}
className="p-0.5 text-muted-foreground/40 hover:text-foreground disabled:opacity-20 transition-colors"
>
</button>
<button
type="button"
onClick={() => onRemove(row.original.id)}
className="p-0.5 ml-0.5 text-muted-foreground/40 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 = useReactTable({
data: indexedRules,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
})
return (
<DataGridShell
table={table}
recordCount={rules.length}
tableClassNames={{
bodyRow: cn("group/row text-xs", "hover:bg-muted/20 transition-colors"),
}}
/>
)
}
export { FirewallScenarioRulesDataGrid, type FirewallScenarioRulesDataGridProps }
@@ -0,0 +1,202 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { GrePool } from "@/lib/data"
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,
} 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, MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react"
interface GrePoolsDataGridProps {
pools: GrePool[]
}
function GrePoolsDataGrid({ pools }: GrePoolsDataGridProps) {
const columns = useMemo<ColumnDef<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,
},
},
{
id: "actions",
header: () => <span className="sr-only">Действия</span>,
enableSorting: false,
cell: () => (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
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 group-hover/row:opacity-100 focus-visible:opacity-100",
"data-popup-open:opacity-100",
)}
>
<MoreHorizontalIcon className="size-4" />
</Button>
}
/>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem>
<PencilIcon className="size-4" /> Редактировать
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon className="size-4" /> Удалить пул
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
size: 56,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
[],
)
const table = useReactTable({
data: pools,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
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,352 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} 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 {
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]
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 ? (
<span className="inline-flex items-center gap-1 text-xs font-medium border rounded px-2 py-0.5 bg-emerald-500/10 text-emerald-400 border-emerald-500/20">
<LockIcon className="size-3" /> IPsec
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs font-medium border rounded px-2 py-0.5 bg-muted text-muted-foreground border-border">
<LockOpenIcon className="size-3" /> Открытый
</span>
)
}
interface GreTunnelsDataGridProps {
tunnels: GreTunnel[]
servers: Server[]
pools: GrePool[]
onCodePreview: (tunnel: GreTunnel) => void
}
function GreTunnelsDataGrid({
tunnels,
servers,
pools,
onCodePreview,
}: 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<GreTunnel>[]>(
() => [
{
id: "name",
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]">{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",
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",
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",
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",
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",
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>
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs font-mono">
{ENC_LABELS[t.ipsec.encAlg]} / {AUTH_LABELS[t.ipsec.authAlg]}
</span>
<span className="text-xs text-muted-foreground font-mono">
{DH_LABELS[t.ipsec.dhGroup].split(" ")[0]} · {IKE_LABELS[t.ipsec.ikeVersion]}
{t.ipsec.pfs && " · PFS"}
</span>
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "mtu",
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",
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",
accessorKey: "status",
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
cell: ({ row }) => <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: ({ row }) => {
const t = row.original
return (
<div className="flex items-center gap-1 justify-end">
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"size-7 opacity-0 transition-opacity group-hover/row:opacity-100 focus-visible:opacity-100",
)}
title="Предпросмотр кода RouterOS"
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-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
"opacity-0 transition-opacity group-hover/row:opacity-100 focus-visible: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>
<PencilIcon className="size-4" /> Редактировать
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<PowerIcon className="size-4" />
{t.enabled ? "Выключить" : "Включить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon className="size-4" /> Удалить туннель
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
},
size: 88,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
[onCodePreview, poolMap, serverMap],
)
const table = useReactTable({
data: tunnels,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row, index) => `${row.id}:${row.serverId}:${row.name}:${index}`,
})
if (tunnels.length === 0) {
return (
<EmptyState
icon={<NetworkIcon className="size-4" />}
title="Нет GRE-туннелей"
description="Измените фильтр или добавьте туннель"
className="border-0 py-16"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={tunnels.length}
tableClassNames={{ headerRow: "border-b border-border", bodyRow: "group/row" }}
/>
)
}
export { GreTunnelsDataGrid, type GreTunnelsDataGridProps }
@@ -0,0 +1,157 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} 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
pagination?: boolean
}
function IpRangesDataGrid({ ipRanges, isLoading, pagination = false }: IpRangesDataGridProps) {
const columns = useMemo<ColumnDef<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 = useReactTable({
data: ipRanges,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
...(pagination ? { getPaginationRowModel: getPaginationRowModel() } : {}),
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="Добавьте CIDR-блоки или импортируйте каталог"
className="border-0 py-12"
/>
}
/>
)
}
export { IpRangesDataGrid, type IpRangesDataGridProps }
@@ -0,0 +1,298 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} 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 { ActivityIcon } from "lucide-react"
export interface BfdSessionRow {
id: string
serverId: string
serverLabel: string
localAddr: string
remoteAddr: string
state: "Up" | "Down" | "Init" | "AdminDown"
interval: number
multiplier: number
iface: string
uptime: string | null
multihop: boolean
rxInterval: number
holdTime: number
packetsRx: number
packetsTx: number
stateChanges: number
}
function stateClass(state: BfdSessionRow["state"]) {
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) {
if (!ms) 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<BfdSessionRow>[]>(
() => [
{
id: "serverLabel",
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-nowrap">{b.serverLabel}</span>
{b.multihop && (
<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",
stateClass(row.original.state),
)}
>
{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",
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 = useReactTable({
data: sessions,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
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}
tableClassNames={{ headerRow: "border-b border-border", bodyRow: "group/row hover:bg-muted/30" }}
tableLayout={{ dense: true }}
/>
)
}
export { OspfBfdDataGrid, type OspfBfdDataGridProps }
@@ -0,0 +1,238 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} 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 { NetworkIcon } from "lucide-react"
export interface OspfNeighborRow {
id: string
localRouter: string
localLabel: string
localIface: string
remoteRouter: string
remoteLabel: string
remoteRouterId: string
area: string
state: "Full" | "2-Way" | "ExStart" | "Down"
cost: number
uptime: string
priority: number
}
function stateClass(state: OspfNeighborRow["state"]) {
if (state === "Full")
return "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/25"
if (state === "2-Way")
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 {
neighbors: OspfNeighborRow[]
highlightRouterId?: string | null
selectedRouterId?: string | null
onHighlight?: (routerId: string | null) => void
onSelect?: (routerId: string | null) => void
}
function OspfNeighborsDataGrid({
neighbors,
highlightRouterId,
selectedRouterId,
onHighlight,
onSelect,
}: OspfNeighborsDataGridProps) {
const columns = useMemo<ColumnDef<OspfNeighborRow>[]>(
() => [
{
id: "localLabel",
accessorKey: "localLabel",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Роутер" className="ml-1" />
),
cell: ({ row }) => (
<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",
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",
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",
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",
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",
stateClass(row.original.state),
)}
>
{row.original.state}
</span>
),
meta: {
headerTitle: "Состояние",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "cost",
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",
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",
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"),
},
},
],
[],
)
const rowSelection = useMemo(() => {
if (!selectedRouterId) return {}
const match = neighbors.find(
(n) => n.localRouter === selectedRouterId || n.remoteRouter === selectedRouterId,
)
return match ? { [match.id]: true } : {}
}, [neighbors, selectedRouterId])
const table = useReactTable({
data: neighbors,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
enableRowSelection: true,
state: { rowSelection },
})
if (neighbors.length === 0) {
return (
<EmptyState
icon={<NetworkIcon className="size-4" />}
title="Нет OSPF-соседей"
className="border-0 py-10"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={neighbors.length}
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 }}
/>
)
}
export { OspfNeighborsDataGrid, type OspfNeighborsDataGridProps }
@@ -0,0 +1,171 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} 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 { RouteIcon } from "lucide-react"
export interface OspfRouteRow {
id: string
destination: string
type: "O" | "O IA" | "O E1" | "O E2"
cost: number
nextHop: string
via: string
serverId: string
serverLabel: string
area: string
}
function routeTypeClass(type: OspfRouteRow["type"]) {
if (type === "O") return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25"
if (type === "O IA") return "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/25"
if (type === "O E1") return "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/25"
return "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/25"
}
interface OspfRoutesDataGridProps {
routes: OspfRouteRow[]
}
function OspfRoutesDataGrid({ routes }: OspfRoutesDataGridProps) {
const columns = useMemo<ColumnDef<OspfRouteRow>[]>(
() => [
{
id: "destination",
accessorKey: "destination",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Назначение" className="ml-1" />
),
cell: ({ row }) => <span className="font-mono">{row.original.destination}</span>,
meta: {
headerTitle: "Назначение",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "type",
accessorKey: "type",
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",
routeTypeClass(row.original.type),
)}
>
{row.original.type}
</span>
),
meta: { headerTitle: "Тип", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "cost",
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",
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}</span>,
meta: {
headerTitle: "Роутер",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "area",
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_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[],
)
const table = useReactTable({
data: routes,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
if (routes.length === 0) {
return (
<EmptyState
icon={<RouteIcon className="size-4" />}
title="Нет OSPF-маршрутов"
className="border-0 py-10"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={routes.length}
tableClassNames={{ headerRow: "border-b border-border", bodyRow: "group/row hover:bg-muted/30" }}
tableLayout={{ dense: true }}
/>
)
}
export { OspfRoutesDataGrid, type OspfRoutesDataGridProps, routeTypeClass }
@@ -0,0 +1,221 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} 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<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 = useReactTable({
data: rules,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
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,178 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} 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"
export interface SpeedProbeApiRow {
id: string
srcServerId: string
dstServerId: string
srcInterface: string
dstInterface: string
protocol: string
direction: string
durationSec: string
enabled: boolean
lastRunAt: string | null
lastTxAvgMbps: number | null
lastRxAvgMbps: number | null
lastStatus: string | null
lastError: string | null
}
interface ProbesSpeedProbesDataGridProps {
rows: SpeedProbeApiRow[]
serverName: (id: string) => string
}
function ProbesSpeedProbesDataGrid({ rows, serverName }: ProbesSpeedProbesDataGridProps) {
const columns = useMemo<ColumnDef<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" && r.lastTxAvgMbps != null && (
<span className="text-emerald-600 dark:text-emerald-400 ml-1">
TX{r.lastTxAvgMbps.toFixed(1)} RX{(r.lastRxAvgMbps ?? 0).toFixed(1)} Mb/s
</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 = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
if (rows.length === 0) {
return (
<EmptyState
icon={<ClockIcon className="size-4" />}
title="Нет записей speed-test"
description="Настраиваются через API PUT /api/uptime/speed-probes"
className="border-0 py-10"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={rows.length}
tableClassNames={{
headerRow: "border-b border-border",
bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"),
}}
/>
)
}
export { ProbesSpeedProbesDataGrid, type ProbesSpeedProbesDataGridProps }
@@ -0,0 +1,328 @@
"use client"
import { useMemo, useState } from "react"
import {
type ColumnDef,
getCoreRowModel,
getExpandedRowModel,
getSortedRowModel,
useReactTable,
} 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 sorted = [...group.endpoints].sort((a, b) => a.distance - b.distance)
return (
<div className="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.map((ep, idx) => (
<div key={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 || inferCountry(ep.gateway)) && (
<Flag code={ep.country || inferCountry(ep.gateway) || ""} size={16} />
)}
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide">
Endpoint {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 items-center gap-3">
<span>scope: {ep.scope ?? "—"}</span>
<span>t.scope: {ep.targetScope ?? "—"}</span>
<span>check: {ep.checkGateway || "—"}</span>
</div>
</div>
))}
</div>
</div>
)
}
interface RecursiveRoutesDataGridProps {
groups: RecursiveRouteGroup[]
expandedKey?: string | null
onExpandedChange?: (key: string | null) => void
onEdit: (group: RecursiveRouteGroup) => void
onDelete: (group: RecursiveRouteGroup) => void
}
function RecursiveRoutesDataGrid({
groups,
expandedKey,
onExpandedChange,
onEdit,
onDelete,
}: 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<RecursiveRouteGroup>[]>(
() => [
{
id: "route",
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 (
<div className="flex items-start gap-2 min-w-0">
{isExpanded ? (
<ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRightIcon 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>
</div>
)
},
meta: {
headerTitle: "Route / Comment",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
expandedContent: (group: RecursiveRouteGroup) => (
<RouteGroupExpandedDetail group={group} />
),
},
},
{
id: "gateways",
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.map((ep, idx) => {
const code = ep.country || inferCountry(ep.gateway)
return (
<div key={ep.id} className="flex items-center gap-1.5 text-[11px] font-mono">
<span
className={cn(
"size-1.5 rounded-full shrink-0",
idx === 0 ? "bg-emerald-500" : "bg-sky-500",
)}
/>
{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-sky-600 dark:text-sky-400 truncate min-w-0">
{ep.gateway}
</span>
</div>
)
})}
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "epCount",
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",
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",
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 opacity-0 group-hover/row:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<Button
size="sm"
variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-foreground"
onClick={() => onEdit(g)}
>
<PencilIcon className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className={cn(
"size-7 p-0 transition-colors",
confirmDel
? "text-destructive bg-destructive/10 hover:bg-destructive/20"
: "text-muted-foreground hover:text-destructive",
)}
onClick={() => {
if (!confirmDel) setConfirmDeleteKey(g.key)
else {
onDelete(g)
setConfirmDeleteKey(null)
}
}}
onBlur={() => setConfirmDeleteKey(null)}
>
{confirmDel ? (
<AlertCircleIcon className="size-3.5" />
) : (
<TrashIcon className="size-3.5" />
)}
</Button>
</div>
)
},
size: 80,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
[confirmDeleteKey, expandedKey, onDelete, onEdit],
)
const table = useReactTable({
data: groups,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getRowId: (row) => row.id ?? row.key,
getRowCanExpand: () => true,
state: { expanded },
})
if (groups.length === 0) {
return (
<EmptyState
icon={<RouteIcon className="size-4" />}
title="Нет маршрутов"
className="border-0 py-10"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={groups.length}
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"),
}}
/>
)
}
export { RecursiveRoutesDataGrid, type RecursiveRoutesDataGridProps, inferCountry }
@@ -0,0 +1,259 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} 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; best?: boolean }) {
return (
<Chip
color={
best
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/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
onApply: (community: string, homeId: string) => void
}
function RouteOptimizerCommRecsDataGrid({
recs,
homeId,
pinned,
applied,
applying,
onPin,
onApply,
}: RouteOptimizerCommRecsDataGridProps) {
const rows = useMemo(
() => recs.map((r, idx) => ({ ...r, _rowKey: `${homeId}::${r.community}::${idx}` })),
[homeId, recs],
)
const columns = useMemo<ColumnDef<(typeof rows)[number]>[]>(
() => [
{
id: "community",
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",
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-sky-600 dark:text-sky-400">
{r.current.wan}
</span>
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
<span>{r.current.jh}</span>
<ArrowRightIcon 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",
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-amber-600 dark:text-amber-400",
)}
>
<span className="font-mono font-medium">{r.recommended.wan}</span>
<ArrowRightIcon className="size-3 shrink-0 opacity-60" />
<span>{r.recommended.jh}</span>
<ArrowRightIcon className="size-3 shrink-0 opacity-60" />
<span>{r.recommended.exit}</span>
{r.shouldSwitch && !isPinned && (
<span className="ml-1 text-[10px] font-bold bg-amber-500/10 border border-amber-500/20 px-1.5 py-0.5 rounded">
+{(r.recommended.prob ?? 0) - (r.current?.prob ?? 0)}%
</span>
)}
</div>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "probability",
header: () => (
<span className="text-[11px] font-medium text-muted-foreground block text-center">
P(тек / рек)
</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 ?? 0} />
<span className="text-muted-foreground text-[10px]">/</span>
<ProbChip prob={r.recommended?.prob ?? 0} 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",
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 = applied.has(pinKey)
const isApplying = applying.has(pinKey)
const canApply = r.shouldSwitch && !isPinned && !isApplied
return (
<div className="flex items-center justify-end gap-1.5" onClick={(e) => e.stopPropagation()}>
{isPinned && <PinIcon className="size-3 text-sky-500 fill-sky-500" />}
<Button
variant="outline"
size="sm"
className={cn("h-7 text-xs", isPinned && "text-sky-600 dark:text-sky-400 border-sky-500/30")}
onClick={() => onPin(pinKey)}
>
<PinIcon className={cn("size-3", isPinned && "fill-current")} />
{isPinned ? "Открепить" : "Закрепить"}
</Button>
{canApply && (
<Button
size="sm"
className="h-7 text-xs"
disabled={isApplying}
onClick={() => onApply(r.community, homeId)}
>
{isApplying ? (
<RefreshCwIcon className="size-3 animate-spin" />
) : (
"Применить"
)}
</Button>
)}
{isApplied && (
<span className="text-[10px] text-emerald-600 dark:text-emerald-400 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],
)
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row._rowKey,
})
return (
<DataGridShell
table={table}
recordCount={rows.length}
tableClassNames={{
headerRow: "border-b bg-muted/20",
bodyRow: cn(
"group/row hover:bg-muted/30",
"[&:has([data-comm-switch=true])]:bg-amber-500/5",
"[&:has([data-comm-applied=true])]:bg-emerald-500/5",
),
}}
tableLayout={{ dense: true }}
/>
)
}
export { RouteOptimizerCommRecsDataGrid, type RouteOptimizerCommRecsDataGridProps }
@@ -0,0 +1,294 @@
"use client"
import { useMemo, useState } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { FullRoute } from "@/lib/route-optimizer-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 { 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-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-muted text-muted-foreground border-border"
}
>
{prob}%
</Chip>
)
}
function ConfChip({ conf }: { conf: string }) {
const map: Record<string, string> = {
HIGH: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
MEDIUM: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
LOW: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/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<FullRoute & { _index: number }>[]>(
() => [
{
id: "index",
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 w-4">
{row.original._index + 1}
</span>
{isBest && (
<span className="text-[9px] font-bold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border border-emerald-500/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",
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-sky-600 dark:text-sky-400">{r.wan.name}</span>
<ArrowRightIcon 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",
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",
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-emerald-600 dark:text-emerald-400"
: totalPing < 80
? "text-amber-600 dark:text-amber-400"
: "text-red-500",
)}
>
{totalPing} мс
</span>
)
},
meta: {
headerTitle: "Ping (итого)",
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
},
},
{
id: "bw",
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",
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",
accessorKey: "probabilityOptimal",
header: ({ column }) => (
<DataGridSortHeader column={column} title="P(opt)" 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: "P(opt)",
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
},
},
{
id: "confidence",
accessorKey: "confidence",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Conf." className="mx-auto" />
),
cell: ({ row }) => (
<div className="flex justify-center">
<ConfChip conf={row.original.confidence} />
</div>
),
meta: {
headerTitle: "Conf.",
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: cn(DATA_GRID_CELL_PAD_LAST, "text-center"),
},
},
],
[bestId],
)
const table = useReactTable({
data: indexed,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
return (
<div>
<DataGridShell
table={table}
recordCount={visibleRoutes.length}
tableClassNames={{
headerRow: "border-b bg-muted/20",
bodyRow: cn("group/row hover:bg-muted/30", "has-[[data-route-best=true]]:bg-emerald-500/5"),
}}
tableLayout={{ dense: true }}
/>
{routes.length > 5 && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="w-full py-2 text-xs text-muted-foreground hover:text-foreground transition-colors border-t flex items-center justify-center gap-1"
>
{expanded ? (
<>
<ChevronUpIcon className="size-3" />
Свернуть
</>
) : (
<>
<ChevronDownIcon className="size-3" />
Показать все {routes.length} комбинаций
</>
)}
</button>
)}
</div>
)
}
export { RouteOptimizerFullRoutesDataGrid, type RouteOptimizerFullRoutesDataGridProps }
@@ -0,0 +1,150 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} 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 { NetworkIcon } from "lucide-react"
export interface OspfPreviewInterfaceRow {
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 columns = useMemo<ColumnDef<OspfPreviewInterfaceRow>[]>(
() => [
{
id: "interface",
accessorKey: "interface",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Интерфейс" className="ml-1" />
),
cell: ({ row }) => <span className="font-mono">{row.original.interface}</span>,
meta: {
headerTitle: "Интерфейс",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "cost",
accessorKey: "currentCost",
header: ({ column }) => <DataGridSortHeader column={column} title="Cost" />,
cell: ({ row }) => {
const r = row.original
return (
<span className="font-mono">
<span className="text-sky-600 dark:text-sky-400">{r.currentCost}</span>
{" → "}
<span
className={
r.currentCost === r.optimalCost
? "text-emerald-600 dark:text-emerald-400"
: "text-amber-600 dark:text-amber-400"
}
>
{r.optimalCost}
</span>
</span>
)
},
meta: { headerTitle: "Cost", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "score",
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",
accessorKey: "pingMs",
header: ({ column }) => <DataGridSortHeader column={column} title="Ping" />,
cell: ({ row }) => <span className="font-mono">{row.original.pingMs}ms</span>,
meta: { headerTitle: "Ping", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "speed",
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>
)
},
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
[],
)
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
if (error) {
return (
<p className="px-3 py-2 text-xs text-destructive">Ошибка preview: {error}</p>
)
}
if (!loading && rows.length === 0) {
return (
<EmptyState
icon={<NetworkIcon className="size-4" />}
title="Интерфейсы OSPF не найдены для выбранного сервера"
className="border-0 py-6 text-xs"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={rows.length}
isLoading={loading}
loadingMode="skeleton"
tableClassNames={{ headerRow: "border-b bg-muted/30", bodyRow: "group/row" }}
tableLayout={{ dense: true }}
/>
)
}
export { RouteOptimizerOspfPreviewDataGrid, type RouteOptimizerOspfPreviewDataGridProps }
@@ -0,0 +1,183 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
useReactTable,
} 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-emerald-600 dark:text-emerald-400 text-[11px] font-mono">0%</span>
return (
<span className={cn("text-[11px] font-mono", loss > 1 ? "text-red-500" : "text-amber-500")}>
{loss}%
</span>
)
}
interface RouteOptimizerWanMatrixDataGridProps {
home: HomeRouter
legs: WanJhLeg[]
jumpHosts: JumpHost[]
}
function RouteOptimizerWanMatrixDataGrid({
home,
legs,
jumpHosts,
}: RouteOptimizerWanMatrixDataGridProps) {
const bestScore = legs.length ? Math.max(...legs.map((l) => l.score)) : 0
const columns = useMemo<ColumnDef<HomeRouter["wans"][number]>[]>(() => {
const base: ColumnDef<HomeRouter["wans"][number]>[] = [
{
id: "wan",
accessorKey: "name",
header: () => <span className="text-[11px] font-medium text-muted-foreground">WAN-аплинк</span>,
cell: ({ row }) => {
const wan = row.original
return (
<div className="flex items-center gap-2">
<WifiIcon className="size-3.5 text-muted-foreground shrink-0" />
<div>
<p className="font-mono text-xs font-semibold">{wan.name}</p>
<p className="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",
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",
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<HomeRouter["wans"][number]>[] = jumpHosts.map((jh) => ({
id: `jh-${jh.id}`,
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 = legs.find((l) => l.wanId === wan.id && l.jhId === 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-emerald-500/5")}>
<div
className={cn(
"flex flex-col items-center gap-0.5 rounded-md px-2 py-1.5 transition-colors",
isBest ? "border border-emerald-500/20 bg-emerald-500/8" : "border border-transparent",
)}
>
{isBest && (
<span className="text-[9px] font-bold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 mb-0.5">
ЛУЧШИЙ
</span>
)}
<span
className={cn(
"font-mono text-xs font-semibold",
leg.pingMs < 10
? "text-emerald-600 dark:text-emerald-400"
: leg.pingMs < 25
? "text-foreground"
: "text-amber-600 dark:text-amber-400",
)}
>
{leg.pingMs} мс
</span>
<span className="text-[10px] text-muted-foreground font-mono">
{leg.dlMbps} {leg.ulMbps}
</span>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] font-mono text-foreground/70">score {leg.score}</span>
{leg.loss > 0 && <LossChip loss={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, jumpHosts, legs])
const table = useReactTable({
data: home.wans,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
})
return (
<DataGridShell
table={table}
recordCount={home.wans.length}
tableClassNames={{ headerRow: "border-b bg-muted/20", bodyRow: "group/row hover:bg-muted/30" }}
tableLayout={{ dense: true }}
/>
)
}
export { RouteOptimizerWanMatrixDataGrid, type RouteOptimizerWanMatrixDataGridProps }
+27 -89
View File
@@ -2,7 +2,6 @@
import { useMemo } from "react"
import {
type Column,
type ColumnDef,
getCoreRowModel,
getExpandedRowModel,
@@ -21,11 +20,13 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DataGrid,
DataGridContainer,
DataGridTable,
} from "@/components/reui/data-grid"
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 {
@@ -42,9 +43,6 @@ import {
RefreshCwIcon,
PowerIcon,
Trash2Icon,
ArrowUpIcon,
ArrowDownIcon,
ArrowUpDownIcon,
} from "lucide-react"
const TYPE_LABELS: Record<ServerType, string> = {
@@ -59,10 +57,6 @@ const TYPE_STYLES: Record<ServerType, string> = {
"home-router": "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
}
const CELL_PAD = "py-3"
const CELL_PAD_FIRST = "pl-5 py-3"
const CELL_PAD_LAST = "pr-4 py-3"
function rosVer(os: string): number {
const m = os.match(/(\d+)\.(\d+)/)
if (!m) return 0
@@ -91,48 +85,6 @@ function RosBadge({ os }: { os: string }) {
return <span className={cn("text-xs font-mono border rounded px-2 py-0.5", cls)}>{os}</span>
}
function ServersTableHeader<TData>({
column,
title,
className,
}: {
column: Column<TData, unknown>
title: string
className?: string
}) {
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",
className,
)}
onClick={column.getToggleSortingHandler()}
>
{title}
{sorted === "asc" ? (
<ArrowUpIcon className="size-3 text-foreground" />
) : sorted === "desc" ? (
<ArrowDownIcon className="size-3 text-foreground" />
) : (
<ArrowUpDownIcon className="size-3 opacity-35" />
)}
</button>
)
}
interface ServerRowActionsProps {
server: Server
isLive: boolean
@@ -234,7 +186,7 @@ function ServersDataGrid({
id: "name",
accessorKey: "name",
header: ({ column }) => (
<ServersTableHeader column={column} title="Имя / Хост" className="ml-1" />
<DataGridSortHeader column={column} title="Имя / Хост" className="ml-1" />
),
cell: ({ row }) => {
const s = row.original
@@ -258,8 +210,8 @@ function ServersDataGrid({
},
meta: {
headerTitle: "Имя / Хост",
headerClassName: CELL_PAD_FIRST,
cellClassName: CELL_PAD_FIRST,
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
expandedContent: (row: Server) => (
<ServerExpandedDetail
server={row}
@@ -273,35 +225,35 @@ function ServersDataGrid({
{
id: "type",
accessorKey: "type",
header: ({ column }) => <ServersTableHeader column={column} title="Тип" />,
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
cell: ({ row }) => <TypeBadge type={row.original.type} />,
meta: { headerTitle: "Тип", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
meta: { headerTitle: "Тип", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "model",
accessorKey: "model",
header: ({ column }) => <ServersTableHeader column={column} title="Модель" />,
header: ({ column }) => <DataGridSortHeader column={column} title="Модель" />,
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.model}</span>,
meta: { headerTitle: "Модель", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
meta: { headerTitle: "Модель", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "os",
accessorKey: "os",
header: ({ column }) => <ServersTableHeader column={column} title="RouterOS" />,
header: ({ column }) => <DataGridSortHeader column={column} title="RouterOS" />,
cell: ({ row }) => <RosBadge os={row.original.os} />,
meta: { headerTitle: "RouterOS", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
meta: { headerTitle: "RouterOS", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "site",
accessorKey: "site",
header: ({ column }) => <ServersTableHeader column={column} title="Площадка" />,
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: CELL_PAD, cellClassName: CELL_PAD },
meta: { headerTitle: "Площадка", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "wan",
@@ -345,13 +297,13 @@ function ServersDataGrid({
</div>
)
},
meta: { headerTitle: "WAN / LAN", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
meta: { headerTitle: "WAN / LAN", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "latency",
accessorKey: "latency",
header: ({ column }) => (
<ServersTableHeader column={column} title="Задержка" className="w-full justify-end" />
<DataGridSortHeader column={column} title="Задержка" className="w-full justify-end" />
),
cell: ({ row }) => {
const s = row.original
@@ -366,16 +318,16 @@ function ServersDataGrid({
},
meta: {
headerTitle: "Задержка",
headerClassName: cn(CELL_PAD, "text-right"),
cellClassName: cn(CELL_PAD, "text-right"),
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
},
},
{
id: "status",
accessorKey: "status",
header: ({ column }) => <ServersTableHeader column={column} title="Статус" />,
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
cell: ({ row }) => <StatusBadge status={row.original.status} />,
meta: { headerTitle: "Статус", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
meta: { headerTitle: "Статус", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "actions",
@@ -397,8 +349,8 @@ function ServersDataGrid({
enableSorting: false,
size: 56,
meta: {
headerClassName: CELL_PAD_LAST,
cellClassName: CELL_PAD_LAST,
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
@@ -427,25 +379,11 @@ function ServersDataGrid({
}
return (
<DataGrid
<DataGridShell
table={table}
recordCount={servers.length}
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
tableLayout={{
rowBorder: true,
headerBackground: true,
headerBorder: true,
columnsResizable: false,
}}
tableClassNames={{
headerRow: "border-b border-border",
bodyRow: "group/row",
}}
>
<DataGridContainer border={false} className="rounded-none border-0">
<DataGridTable />
</DataGridContainer>
</DataGrid>
/>
)
}
@@ -0,0 +1,168 @@
"use client"
import { useMemo } from "react"
import type { Server } from "@/lib/data"
import { CompactDataGrid, type CompactDataGridColumn } from "@/components/data-grids/compact-data-grid"
import { cn } from "@/lib/utils"
type PermLevel = "none" | "read" | "write"
type Role = "admin" | "operator" | "viewer"
interface SectionPerm {
section: string
level: PermLevel
}
interface ServerPerm {
serverId: string
level: PermLevel
}
export interface AccessSummaryUser {
id: string
name: string
avatar: string
active: boolean
role: Role
sections: SectionPerm[]
servers: ServerPerm[]
}
function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
return (
<div
className={cn(
"size-7 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0",
active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
)}
>
{avatar}
</div>
)
}
interface SettingsAccessSummaryDataGridProps {
users: AccessSummaryUser[]
servers: Server[]
allSectionsCount: number
}
function SettingsAccessSummaryDataGrid({
users,
servers,
allSectionsCount,
}: SettingsAccessSummaryDataGridProps) {
const columns = useMemo<CompactDataGridColumn<AccessSummaryUser>[]>(
() => [
{
id: "name",
header: "Пользователь",
accessorKey: "name",
cell: (u) => (
<div className="flex items-center gap-2">
<AvatarCircle avatar={u.avatar} active={u.active} />
<span className="text-sm font-medium">{u.name}</span>
</div>
),
},
{
id: "sections",
header: "Разделы",
enableSorting: false,
cell: (u) => {
const writeSections =
u.role === "admin"
? []
: u.sections.filter((s) => s.level === "write").map((s) => s.section)
const readSections =
u.role === "admin"
? []
: u.sections.filter((s) => s.level === "read").map((s) => s.section)
return (
<span className="text-xs text-muted-foreground">
{u.role === "admin" ? (
<span className="text-violet-600 dark:text-violet-400 font-medium">
Все ({allSectionsCount})
</span>
) : (
<span>{readSections.length + writeSections.length} из {allSectionsCount}</span>
)}
</span>
)
},
},
{
id: "servers",
header: "Серверы",
enableSorting: false,
cell: (u) => {
const accessServers =
u.role === "admin"
? servers
: servers.filter((s) => u.servers.find((p) => p.serverId === s.id && p.level !== "none"))
return (
<span className="text-xs text-muted-foreground">
{u.role === "admin" ? (
<span className="text-violet-600 dark:text-violet-400 font-medium">
Все ({servers.length})
</span>
) : (
<span>
{accessServers.length} из {servers.length}
</span>
)}
</span>
)
},
},
{
id: "write",
header: "Права записи",
enableSorting: false,
cell: (u) => {
const writeSections =
u.role === "admin"
? []
: u.sections.filter((s) => s.level === "write").map((s) => s.section)
return (
<div className="flex flex-wrap gap-1">
{u.role === "admin" ? (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20">
Полный доступ
</span>
) : writeSections.length === 0 ? (
<span className="text-[10px] text-muted-foreground">Только просмотр</span>
) : (
<>
{writeSections.slice(0, 3).map((s) => (
<span
key={s}
className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20"
>
{s}
</span>
))}
{writeSections.length > 3 && (
<span className="text-[10px] text-muted-foreground">+{writeSections.length - 3}</span>
)}
</>
)}
</div>
)
},
},
],
[allSectionsCount, servers],
)
return (
<CompactDataGrid
data={users}
columns={columns}
compact
emptyTitle="Нет пользователей"
/>
)
}
export { SettingsAccessSummaryDataGrid, type SettingsAccessSummaryDataGridProps }
@@ -0,0 +1,28 @@
import type { DataGridProps } 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<object>["tableLayout"]> = {
rowBorder: true,
headerBackground: true,
headerBorder: true,
columnsResizable: false,
}
const DEFAULT_DATA_GRID_CLASS_NAMES: NonNullable<DataGridProps<object>["tableClassNames"]> = {
headerRow: "border-b border-border",
bodyRow: "group/row",
}
const DATA_GRID_CONTAINER_CLASS = "rounded-none border-0"
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,
}
@@ -0,0 +1,68 @@
"use client"
import type { ReactNode } from "react"
import type { Table } from "@tanstack/react-table"
import {
DataGrid,
DataGridContainer,
DataGridPagination,
DataGridTable,
type DataGridProps,
} 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<TData>
recordCount: number
children?: ReactNode
pagination?: boolean
isLoading?: boolean
loadingMode?: DataGridProps<TData>["loadingMode"]
emptyMessage?: ReactNode
onRowClick?: DataGridProps<TData>["onRowClick"]
tableLayout?: DataGridProps<TData>["tableLayout"]
tableClassNames?: DataGridProps<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
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,51 @@
"use client"
import type { Column } from "@tanstack/react-table"
import { cn } from "@/lib/utils"
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon } from "lucide-react"
interface DataGridSortHeaderProps<TData> {
column: Column<TData, unknown>
title: string
className?: string
}
function DataGridSortHeader<TData>({
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",
className,
)}
onClick={column.getToggleSortingHandler()}
>
{title}
{sorted === "asc" ? (
<ArrowUpIcon className="size-3 text-foreground" />
) : sorted === "desc" ? (
<ArrowDownIcon className="size-3 text-foreground" />
) : (
<ArrowUpDownIcon className="size-3 opacity-35" />
)}
</button>
)
}
export { DataGridSortHeader, type DataGridSortHeaderProps }
@@ -0,0 +1,498 @@
"use client"
import { Badge } from "@/components/ui/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="outline" className="text-[10px] border-emerald-500/40 text-emerald-700 dark:text-emerald-400">
{okLabel}
</Badge>
) : (
<Badge variant="outline" className="text-[10px] border-destructive/50 text-destructive">
{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: Omit<CompactDataGridProps<T>, "compact">,
) {
return <CompactDataGrid {...props} compact />
}
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,234 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} 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<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 = useReactTable({
data: subUsers,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
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,385 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} 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,
ServerIcon,
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<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 = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.serverId,
})
if (rows.length === 0) {
return (
<EmptyState
icon={<SearchIcon className="size-4" />}
title="Ничего не найдено"
className="border-0 py-12"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={rows.length}
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",
),
}}
/>
)
}
export { UptimeResourcesDataGrid, type UptimeResourcesDataGridProps }
@@ -0,0 +1,242 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} 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
rxAvgMbps: number
status: "running" | "done" | "error"
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<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>
}
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",
accessorKey: "txAvgMbps",
header: ({ column }) => <DataGridSortHeader column={column} title="TX avg" />,
cell: ({ row }) => {
const run = row.original
const maxVal = Math.max(run.txAvgMbps, run.rxAvgMbps, 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",
accessorKey: "rxAvgMbps",
header: ({ column }) => <DataGridSortHeader column={column} title="RX avg" />,
cell: ({ row }) => {
const run = row.original
const maxVal = Math.max(run.txAvgMbps, run.rxAvgMbps, 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?.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">
timeout
{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 = useReactTable({
data: runs,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
initialState: { sorting: [{ id: "startedAt", desc: true }] },
})
return (
<DataGridShell
table={table}
recordCount={runs.length}
tableClassNames={{ bodyRow: cn("group/row text-xs") }}
/>
)
}
export { UptimeSpeedHistoryDataGrid, type UptimeSpeedHistoryDataGridProps }
+289
View File
@@ -0,0 +1,289 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} 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,
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,
MoreHorizontalIcon,
NetworkIcon,
PencilIcon,
PowerIcon,
Trash2Icon,
} from "lucide-react"
interface VxlanDataGridProps {
tunnels: VxlanTunnel[]
servers: Server[]
onExport: (tunnel: VxlanTunnel) => void
}
function VxlanDataGrid({ tunnels, servers, onExport }: VxlanDataGridProps) {
const serverMap = useMemo(
() => new Map(servers.map((s) => [s.id, s])),
[servers],
)
const columns = useMemo<ColumnDef<VxlanTunnel>[]>(
() => [
{
id: "name",
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",
t.status === "up" ? "bg-emerald-500" : "bg-red-500",
)}
/>
<div className="min-w-0">
<p className="font-mono font-medium text-sm truncate">{t.name}</p>
<p className="text-[11px] text-muted-foreground font-mono">VTEP: {t.vtepIp}</p>
</div>
</div>
)
},
meta: {
headerTitle: "Имя / VTEP",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "server",
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",
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",
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: "remoteVteps",
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",
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",
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",
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",
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",
)}
>
{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: ({ row }) => {
const tunnel = row.original
return (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
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 group-hover/row:opacity-100 focus-visible:opacity-100",
"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>
<DropdownMenuItem>
<PencilIcon className="size-4" />
Редактировать
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<PowerIcon className="size-4" />
{tunnel.enabled ? "Отключить" : "Включить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon className="size-4" />
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
},
enableSorting: false,
size: 56,
meta: {
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[onExport, serverMap],
)
const table = useReactTable({
data: tunnels,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
if (tunnels.length === 0) {
return (
<EmptyState
icon={<NetworkIcon className="size-4" />}
title="Нет VXLAN-туннелей"
description="Добавьте туннель или измените поиск"
className="border-0 py-16"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={tunnels.length}
tableClassNames={{
headerRow: "border-b border-border",
bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"),
}}
/>
)
}
export { VxlanDataGrid, type VxlanDataGridProps }
@@ -0,0 +1,276 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getExpandedRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { WireGuardInterface } from "@/lib/data"
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 {
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,
PencilIcon,
PlusIcon,
PowerIcon,
ShieldCheckIcon,
Trash2Icon,
} from "lucide-react"
export interface WgIfaceWithServer extends WireGuardInterface {
serverId: string
serverName: string
serverCountry: string
}
interface WireguardDataGridProps {
interfaces: WgIfaceWithServer[]
onExport: (iface: WgIfaceWithServer) => void
}
function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
const columns = useMemo<ColumnDef<WgIfaceWithServer>[]>(
() => [
{
id: "name",
accessorKey: "name",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Интерфейс / Сервер" className="ml-1" />
),
cell: ({ row }) => {
const iface = row.original
const expanded = row.getIsExpanded()
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
return (
<div className="flex items-start gap-2 min-w-0">
{expanded ? (
<ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />
)}
<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-emerald-500 animate-pulse" : "bg-red-500",
)}
/>
<span className="font-mono font-semibold text-sm">{iface.name}</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
<Flag code={iface.serverCountry} size={12} />
{iface.serverName}
</div>
<p className="sr-only">
{onlinePeers}/{iface.peers.length} пиров
</p>
</div>
</div>
)
},
meta: {
headerTitle: "Интерфейс / Сервер",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
expandedContent: (row: WgIfaceWithServer) => (
<WireGuardPeersDetail peers={row.peers} />
),
},
},
{
id: "listenPort",
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",
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",
header: () => (
<span className="text-xs font-medium text-muted-foreground">Пиры</span>
),
enableSorting: false,
cell: ({ row }) => {
const iface = row.original
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
return (
<span className="font-mono text-sm text-center block">
<span className="text-emerald-600 dark:text-emerald-400">{onlinePeers}</span>
<span className="text-muted-foreground">/{iface.peers.length}</span>
</span>
)
},
meta: {
headerTitle: "Пиры",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
},
},
{
id: "status",
accessorKey: "status",
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
cell: ({ row }) => (
<span
className={cn(
"text-[11px] font-mono px-2 py-0.5 rounded 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",
)}
>
{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: ({ row }) => {
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-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",
)}
onClick={(e) => e.stopPropagation()}
aria-label={`Действия: ${iface.name}`}
>
<MoreHorizontalIcon className="size-4" />
</Button>
}
/>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem onClick={() => onExport(iface)}>
<CodeXmlIcon className="size-4" />
Экспорт .rsc
</DropdownMenuItem>
<DropdownMenuItem>
<PencilIcon className="size-4" />
Редактировать
</DropdownMenuItem>
<DropdownMenuItem>
<PlusIcon className="size-4" />
Добавить пира
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<PowerIcon className="size-4" />
{iface.enabled ? "Отключить" : "Включить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon className="size-4" />
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
},
enableSorting: false,
size: 56,
meta: {
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[onExport],
)
const table = useReactTable({
data: interfaces,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getRowId: (row) => row.id,
getRowCanExpand: () => true,
})
if (interfaces.length === 0) {
return (
<EmptyState
icon={<ShieldCheckIcon className="size-4" />}
title="Нет WireGuard интерфейсов"
description="Добавьте первый интерфейс или проверьте поиск"
className="border-0 py-16"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={interfaces.length}
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
tableClassNames={{
headerRow: "border-b border-border",
bodyRow: cn("group/row", "[&[data-disabled=true]]:opacity-50"),
}}
/>
)
}
export { WireguardDataGrid, type WireguardDataGridProps }
@@ -0,0 +1,81 @@
"use client"
import type { WireGuardPeer } from "@/lib/data"
import { cn } from "@/lib/utils"
import {
ArrowDownIcon,
ArrowUpIcon,
KeyRoundIcon,
} from "lucide-react"
function fmtBytes(n: number | undefined): string {
if (!n) 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 WireGuardPeersDetail({ peers }: { peers: WireGuardPeer[] }) {
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 (
<div className="border-t border-border/50">
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-5 py-1.5 bg-muted/10 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<span>Public Key</span>
<span>Allowed IPs</span>
<span>Последнее рукопожатие</span>
<span>RX / TX</span>
<span>Endpoint</span>
</div>
{peers.map((peer) => (
<div
key={peer.publicKey}
className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-5 py-2.5 items-center text-xs border-t border-border/50 bg-muted/20"
>
<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 truncate" title={peer.publicKey}>
{truncKey(peer.publicKey)}
</span>
</div>
<div className="font-mono text-muted-foreground truncate">
{peer.allowedIps.join(", ")}
</div>
<span
className={cn(
"font-mono text-[11px] whitespace-nowrap",
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
)}
>
{peer.latestHandshake ?? "нет рукопожатия"}
</span>
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
<span className="flex items-center gap-0.5">
<ArrowDownIcon className="size-3 text-emerald-500" />
{fmtBytes(peer.transferRx)}
</span>
<span className="flex items-center gap-0.5">
<ArrowUpIcon className="size-3 text-blue-400" />
{fmtBytes(peer.transferTx)}
</span>
</div>
<span className="font-mono text-muted-foreground/60 text-[11px]">{peer.endpoint ?? "—"}</span>
</div>
))}
</div>
)
}
export { WireGuardPeersDetail, fmtBytes, truncKey }
+18
View File
@@ -0,0 +1,18 @@
import type { ReactNode } from "react"
import { Card } from "@/components/ui/card"
import { cn } from "@/lib/utils"
interface DataPageCardProps {
children: ReactNode
className?: string
}
function DataPageCard({ children, className }: DataPageCardProps) {
return (
<Card className={cn("overflow-hidden py-0 gap-0", className)}>
{children}
</Card>
)
}
export { DataPageCard, type DataPageCardProps }
+11 -6
View File
@@ -3,7 +3,11 @@
import { ReactNode } from "react"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Input } from "@/components/ui/input"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import {
Filters,
type Filter,
@@ -58,15 +62,16 @@ function DataPageToolbar<T extends string = string>({
/>
)}
{onSearchChange != null && (
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<Input
className="h-6 border-0 bg-transparent px-0 shadow-none focus-visible:ring-0"
<InputGroup className="min-w-[220px] max-w-sm">
<InputGroupAddon>
<SearchIcon className="size-3.5" />
</InputGroupAddon>
<InputGroupInput
placeholder={searchPlaceholder}
value={search ?? ""}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
</InputGroup>
)}
{countLabel && (
<span className="text-sm text-muted-foreground ml-auto">{countLabel}</span>
+44 -29
View File
@@ -4,18 +4,18 @@ import { useMemo, useState } from "react"
import {
type ColumnDef,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { Card } from "@/components/ui/card"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DataGrid,
DataGridContainer,
DataGridPagination,
DataGridTable,
} from "@/components/reui/data-grid"
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 { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { EmptyState } from "@/components/empty-state"
import { InboxIcon } from "lucide-react"
@@ -24,6 +24,7 @@ export interface Column<T> {
key: string
label: string
render: (row: T) => React.ReactNode
enableSorting?: boolean
}
interface DataTableProps<T extends { id: string }> {
@@ -34,6 +35,8 @@ interface DataTableProps<T extends { id: string }> {
isLoading?: boolean
emptyTitle?: string
emptyDescription?: string
pagination?: boolean
countLabel?: string
}
export function DataTable<T extends { id: string }>({
@@ -44,9 +47,10 @@ export function DataTable<T extends { id: string }>({
isLoading = false,
emptyTitle = "Нет записей",
emptyDescription,
pagination = false,
countLabel,
}: DataTableProps<T>) {
const [search, setSearch] = useState("")
const [globalFilter, setGlobalFilter] = useState("")
const filteredData = useMemo(() => {
if (!search || searchKeys.length === 0) return data
@@ -57,59 +61,70 @@ export function DataTable<T extends { id: string }>({
}, [data, search, searchKeys])
const columnDefs = useMemo<ColumnDef<T>[]>(
() =>
columns.map((col) => ({
() => {
const defs: ColumnDef<T>[] = columns.map((col, index) => ({
id: col.key,
accessorKey: col.key,
header: col.label,
header: ({ column }) => (
<DataGridSortHeader column={column} title={col.label} />
),
cell: ({ row }) => col.render(row.original),
meta: { headerTitle: col.label },
})),
enableSorting: col.enableSorting !== false,
meta: {
headerTitle: col.label,
headerClassName:
index === 0
? DATA_GRID_CELL_PAD_FIRST
: index === columns.length - 1
? DATA_GRID_CELL_PAD_LAST
: DATA_GRID_CELL_PAD,
cellClassName:
index === 0
? DATA_GRID_CELL_PAD_FIRST
: index === columns.length - 1
? DATA_GRID_CELL_PAD_LAST
: DATA_GRID_CELL_PAD,
},
}))
return defs
},
[columns],
)
const table = useReactTable({
data: filteredData,
columns: columnDefs,
state: { globalFilter },
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
...(pagination ? { getPaginationRowModel: getPaginationRowModel() } : {}),
getRowId: (row) => row.id,
})
const displayCount = searchKeys.length > 0 ? filteredData.length : data.length
return (
<Card>
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder={searchPlaceholder}
countLabel={`${displayCount} записей`}
countLabel={countLabel ?? `${displayCount} записей`}
/>
<DataGrid
<DataGridShell
table={table}
recordCount={filteredData.length}
isLoading={isLoading}
loadingMode="skeleton"
pagination={pagination}
emptyMessage={
<EmptyState
icon={<InboxIcon className="size-4" />}
title={emptyTitle}
description={emptyDescription}
className="py-12"
className="border-0 py-12"
/>
}
tableLayout={{ rowBorder: true, headerBackground: true }}
>
<DataGridContainer border={false}>
<DataGridTable />
</DataGridContainer>
{filteredData.length > 0 && <DataGridPagination className="px-5 pb-3" />}
</DataGrid>
</Card>
/>
</DataPageCard>
)
}