fix(config): добавить новые зависимости и обновить конфигурацию компонентов
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 2m37s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s

This commit is contained in:
Denozordec committed 2026-06-30 21:30:40 +07:00
1 parent 009011a917
commit a1a9124f3d
80 files changed
+13310 -1587

No files matched your search

+171
View File
@@ -0,0 +1,171 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { Backup } from "@/lib/data"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DataGrid,
DataGridColumnHeader,
DataGridContainer,
DataGridTable,
} from "@/components/reui/data-grid"
import { EmptyState } from "@/components/empty-state"
import { DownloadIcon, HardDriveIcon, RefreshCwIcon, Trash2Icon } from "lucide-react"
interface BackupsDataGridProps {
backups: Backup[]
onDownload: (id: string, filename: string) => void
onRestore: (backup: Backup) => void
onDelete: (id: string) => void
}
function BackupsDataGrid({
backups,
onDownload,
onRestore,
onDelete,
}: BackupsDataGridProps) {
const columns = useMemo<ColumnDef<Backup>[]>(
() => [
{
id: "filename",
accessorKey: "filename",
header: ({ column }) => <DataGridColumnHeader column={column} title="Файл" />,
cell: ({ row }) => (
<span className="font-mono text-xs font-medium">{row.original.filename}</span>
),
},
{
id: "server",
accessorKey: "server",
header: ({ column }) => <DataGridColumnHeader column={column} title="Сервер" />,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">{row.original.server}</span>
),
},
{
id: "size",
accessorKey: "size",
header: ({ column }) => <DataGridColumnHeader column={column} title="Размер" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">{row.original.size}</span>
),
},
{
id: "kind",
accessorKey: "kind",
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
cell: ({ row }) => {
const kind = row.original.kind
return (
<span
className={cn(
"text-xs px-2 py-0.5 rounded border font-medium",
kind === "manual"
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
: "bg-muted text-muted-foreground border-border",
)}
>
{kind === "auto" ? "авто" : "вручную"}
</span>
)
},
},
{
id: "notes",
accessorKey: "notes",
header: ({ column }) => <DataGridColumnHeader column={column} title="Заметки" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground max-w-[200px] truncate block">
{row.original.notes || "—"}
</span>
),
},
{
id: "created",
accessorKey: "created",
header: ({ column }) => <DataGridColumnHeader column={column} title="Создан" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">{row.original.created}</span>
),
},
{
id: "actions",
header: () => null,
cell: ({ row }) => {
const b = row.original
return (
<div className="flex items-center gap-1 justify-end">
<Button
variant="ghost"
size="icon"
className="size-7"
title="Скачать"
onClick={() => onDownload(b.id, b.filename)}
>
<DownloadIcon className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7"
title="Восстановить"
onClick={() => onRestore(b)}
>
<RefreshCwIcon className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-destructive hover:text-destructive"
title="Удалить"
onClick={() => onDelete(b.id)}
>
<Trash2Icon className="size-3.5" />
</Button>
</div>
)
},
enableSorting: false,
size: 120,
},
],
[onDelete, onDownload, onRestore],
)
const table = useReactTable({
data: backups,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
if (backups.length === 0) {
return (
<EmptyState
icon={<HardDriveIcon className="size-4" />}
title="Нет бэкапов"
description="Создайте первый бэкап вручную или настройте расписание"
className="border-0 py-10"
/>
)
}
return (
<DataGrid table={table} recordCount={backups.length} isLoading={false}>
<DataGridContainer>
<DataGridTable />
</DataGridContainer>
</DataGrid>
)
}
export { BackupsDataGrid, type BackupsDataGridProps }
@@ -0,0 +1,130 @@
"use client"
import { useState } from "react"
import { cn } from "@/lib/utils"
import { ClipboardCopyIcon } from "lucide-react"
import type { BgpSessionRow } from "@/lib/bgp/types"
import { fmtBgpNum, rscBgpSnippet } from "@/lib/bgp/helpers"
function CapChip({ cap }: { cap: string }) {
return (
<span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium border bg-muted/60 text-muted-foreground border-border/60">
{cap}
</span>
)
}
function PrefixBar({ rx, tx, active }: { rx: number; tx: number; active: number }) {
const max = Math.max(rx, 1)
return (
<div className="flex flex-col gap-1.5 text-[10px] font-mono">
{[
{ label: "Получено", val: rx, color: "bg-[var(--chart-rx)]", w: rx / max },
{ label: "Активных", val: active, color: "bg-[var(--chart-1)]", w: active / max },
{ label: "Отправлено", val: tx, color: "bg-[var(--chart-tx)]", w: Math.min(tx / max, 1) },
].map((r) => (
<div key={r.label} className="flex items-center gap-2">
<span className="w-20 text-muted-foreground shrink-0">{r.label}</span>
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className={cn("h-full rounded-full", r.color)}
style={{ width: `${Math.max(r.w * 100, r.val > 0 ? 2 : 0)}%` }}
/>
</div>
<span className="w-14 text-right tabular-nums">{fmtBgpNum(r.val)}</span>
</div>
))}
</div>
)
}
function BgpSessionDetail({ session }: { session: BgpSessionRow }) {
const [copied, setCopied] = useState(false)
function copy() {
navigator.clipboard.writeText(rscBgpSnippet(session)).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1800)
})
}
return (
<div className="px-4 pb-4 pt-2 bg-muted/20 border-t border-border/60">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
{[
{ label: "Router ID", value: session.routerId },
{ label: "Hold / KA", value: `${session.holdTime}s / ${session.keepalive}s` },
{ label: "AFI/SAFI", value: session.afi },
{
label: "Сообщения ↓/↑",
value: `${fmtBgpNum(session.inputMessages)} / ${fmtBgpNum(session.outputMessages)}`,
},
].map(({ label, value }) => (
<div key={label}>
<p className="text-[10px] text-muted-foreground mb-0.5">{label}</p>
<p className="text-xs font-mono font-medium">{value}</p>
</div>
))}
</div>
{session.state === "Established" && (
<div className="mb-4">
<p className="text-[10px] text-muted-foreground mb-2 uppercase tracking-wider font-semibold">
Префиксы
</p>
<PrefixBar
rx={session.prefixesRx}
tx={session.prefixesTx}
active={session.prefixesActive}
/>
</div>
)}
{session.capabilities.length > 0 && (
<div className="mb-4">
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">
Capabilities
</p>
<div className="flex flex-wrap gap-1.5">
{session.capabilities.map((c) => (
<CapChip key={c} cap={c} />
))}
</div>
</div>
)}
{session.lastError && (
<div className="mb-4 flex items-center gap-2 rounded-md border border-red-500/20 bg-red-500/5 px-3 py-2">
<span className="size-1.5 rounded-full bg-red-500 shrink-0" />
<p className="text-xs font-mono text-red-500">{session.lastError}</p>
</div>
)}
<div className="mt-2">
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">
RouterOS Export
</p>
<div className="rounded-md bg-[#0a0f1a] border border-white/8 px-3 py-2.5 flex items-start justify-between gap-3">
<pre className="text-[10px] font-mono text-[#94a3b8] leading-relaxed whitespace-pre-wrap flex-1 min-w-0">
{rscBgpSnippet(session)}
</pre>
<button
type="button"
onClick={copy}
className={cn(
"shrink-0 flex items-center gap-1 text-[10px] px-2 py-1 rounded border transition-colors",
copied
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-400"
: "border-white/10 text-white/40 hover:text-white/70 hover:border-white/20",
)}
>
<ClipboardCopyIcon className="size-3" />
{copied ? "Скопировано" : "Копировать"}
</button>
</div>
</div>
</div>
)
}
export { BgpSessionDetail }
@@ -0,0 +1,253 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getExpandedRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { cn } from "@/lib/utils"
import {
DataGrid,
DataGridColumnHeader,
DataGridContainer,
DataGridTable,
} from "@/components/reui/data-grid"
import { EmptyState } from "@/components/empty-state"
import { BgpSessionDetail } from "@/components/data-grids/bgp-session-detail"
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
import { BGP_AS_NAMES } from "@/lib/bgp/types"
import { fmtBgpNum } from "@/lib/bgp/helpers"
import { ChevronDownIcon, ChevronRightIcon, NetworkIcon } from "lucide-react"
const STATE_STYLE: Record<BgpState, { bg: string; text: string; dot: string; label: string }> = {
Established: {
bg: "bg-emerald-500/10",
text: "text-emerald-600 dark:text-emerald-400",
dot: "bg-emerald-500",
label: "Established",
},
Active: {
bg: "bg-amber-500/10",
text: "text-amber-600 dark:text-amber-400",
dot: "bg-amber-500",
label: "Active",
},
Idle: {
bg: "bg-slate-500/10",
text: "text-slate-500 dark:text-slate-400",
dot: "bg-slate-500",
label: "Idle",
},
Connect: {
bg: "bg-blue-500/10",
text: "text-blue-600 dark:text-blue-400",
dot: "bg-blue-500",
label: "Connect",
},
OpenSent: {
bg: "bg-violet-500/10",
text: "text-violet-600 dark:text-violet-400",
dot: "bg-violet-500",
label: "OpenSent",
},
OpenConfirm: {
bg: "bg-violet-500/10",
text: "text-violet-600 dark:text-violet-400",
dot: "bg-violet-500",
label: "OpenConfirm",
},
}
const TYPE_STYLE: Record<BgpType, { bg: string; text: string }> = {
eBGP: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400" },
iBGP: { bg: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" },
}
function StateBadge({ state }: { state: BgpState }) {
const s = STATE_STYLE[state]
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded border px-2 py-0.5 text-[11px] font-semibold",
s.bg,
s.text,
"border-current/20",
)}
>
<span className={cn("size-1.5 rounded-full shrink-0", s.dot)} />
{s.label}
</span>
)
}
function TypeBadge({ type }: { type: BgpType }) {
const s = TYPE_STYLE[type]
return (
<span
className={cn(
"inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold",
s.bg,
s.text,
"border-current/20",
)}
>
{type}
</span>
)
}
interface BgpSessionsDataGridProps {
sessions: BgpSessionRow[]
}
function BgpSessionsDataGrid({ sessions }: BgpSessionsDataGridProps) {
const columns = useMemo<ColumnDef<BgpSessionRow>[]>(
() => [
{
id: "serverLabel",
accessorKey: "serverLabel",
header: ({ column }) => <DataGridColumnHeader column={column} title="Роутер" />,
cell: ({ row }) => {
const expanded = row.getIsExpanded()
return (
<div className="flex items-center gap-2">
{expanded ? (
<ChevronDownIcon className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground/40" />
)}
<span className="font-mono whitespace-nowrap">{row.original.serverLabel}</span>
</div>
)
},
meta: {
headerTitle: "Роутер",
expandedContent: (row: BgpSessionRow) => <BgpSessionDetail session={row} />,
},
},
{
id: "peerIp",
accessorKey: "peerIp",
header: ({ column }) => <DataGridColumnHeader column={column} title="Peer IP" />,
cell: ({ row }) => <span className="font-mono">{row.original.peerIp}</span>,
},
{
id: "remoteAs",
accessorKey: "remoteAs",
header: ({ column }) => <DataGridColumnHeader column={column} title="Remote AS" />,
cell: ({ row }) => {
const as = row.original.remoteAs
return (
<div className="flex items-center gap-1.5 font-mono">
<span>AS{as}</span>
{BGP_AS_NAMES[as] && (
<span className="text-muted-foreground text-[10px]">{BGP_AS_NAMES[as]}</span>
)}
</div>
)
},
},
{
id: "description",
accessorKey: "description",
header: ({ column }) => <DataGridColumnHeader column={column} title="Описание" />,
cell: ({ row }) => (
<span className="text-muted-foreground max-w-[180px] truncate block">
{row.original.description}
</span>
),
},
{
id: "type",
accessorKey: "type",
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
cell: ({ row }) => <TypeBadge type={row.original.type} />,
},
{
id: "state",
accessorKey: "state",
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => <StateBadge state={row.original.state} />,
},
{
id: "uptime",
accessorKey: "uptime",
header: ({ column }) => <DataGridColumnHeader column={column} title="Uptime" />,
cell: ({ row }) => (
<span className="font-mono tabular-nums text-muted-foreground">
{row.original.uptime ?? "—"}
</span>
),
},
{
id: "prefixesRx",
accessorKey: "prefixesRx",
header: ({ column }) => <DataGridColumnHeader column={column} title="Prefixes ↓" />,
cell: ({ row }) => {
const n = row.original.prefixesRx
return n > 0 ? (
<span className="font-mono tabular-nums text-emerald-600 dark:text-emerald-400 text-right block">
{fmtBgpNum(n)}
</span>
) : (
<span className="text-muted-foreground">—</span>
)
},
},
{
id: "prefixesTx",
accessorKey: "prefixesTx",
header: ({ column }) => <DataGridColumnHeader column={column} title="Prefixes ↑" />,
cell: ({ row }) => {
const n = row.original.prefixesTx
return n > 0 ? (
<span className="font-mono tabular-nums text-[var(--chart-tx)] text-right block">
{fmtBgpNum(n)}
</span>
) : (
<span className="text-muted-foreground">—</span>
)
},
},
],
[],
)
const table = useReactTable({
data: sessions,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getRowId: (row) => row.id,
})
if (sessions.length === 0) {
return (
<EmptyState
icon={<NetworkIcon className="size-4" />}
title="Нет BGP-сессий"
description="Измените фильтры или проверьте подключение к роутерам"
className="border-0 py-10"
/>
)
}
return (
<DataGrid
table={table}
recordCount={sessions.length}
isLoading={false}
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
>
<DataGridContainer>
<DataGridTable />
</DataGridContainer>
</DataGrid>
)
}
export { BgpSessionsDataGrid, type BgpSessionsDataGridProps }
@@ -0,0 +1,123 @@
"use client"
import type { Server } from "@/lib/data"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { RosBadge, rosVer } from "@/components/data-grids/servers-data-grid"
import { CheckCircleIcon, XCircleIcon, RefreshCwIcon } from "lucide-react"
const ROS_FEATURES = [
{ name: "WireGuard", minVer: 701, label: "7.1+", desc: "WireGuard VPN туннели" },
{ name: "Container", minVer: 704, label: "7.4+", desc: "Docker-совместимые контейнеры" },
{ name: "BFD", minVer: 705, label: "7.5+", desc: "Bidirectional Forwarding Detection" },
{ name: "Large Communities", minVer: 707, label: "7.7+", desc: "BGP Large Communities (RFC 8092)" },
{ name: "VXLAN", minVer: 710, label: "7.10+", desc: "VXLAN overlay туннели" },
{ name: "RPKI", minVer: 713, label: "7.13+", desc: "Route Origin Validation" },
{ name: "BGP Flowspec", minVer: 714, label: "7.14+", desc: "BGP Flow Spec (RFC 8955)" },
{ name: "IPv6 Firewall", minVer: 715, label: "7.15+", desc: "Расширенный IPv6 Firewall" },
{ name: "REST API v2", minVer: 716, label: "7.16+", desc: "Обновлённый REST API" },
{ name: "VRF Enhanced", minVer: 717, label: "7.17+", desc: "Расширенная поддержка VRF" },
]
interface ServerExpandedDetailProps {
server: Server
isLive: boolean
isPolling: boolean
onPoll: () => void
}
function ServerExpandedDetail({ server: s, isLive, isPolling, onPoll }: ServerExpandedDetailProps) {
const ver = rosVer(s.os)
return (
<div className="flex flex-col gap-5 px-5 py-5 bg-muted/20">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
{s.model && s.model !== "—" && (
<span className="text-muted-foreground">Модель: <span className="font-mono text-foreground">{s.model}</span></span>
)}
{s.uptime && (
<span className="text-muted-foreground">Uptime: <span className="font-mono text-foreground">{s.uptime}</span></span>
)}
{s.cpuLoad != null && (
<span className="text-muted-foreground">CPU: <span className={cn("font-mono font-semibold", s.cpuLoad > 80 ? "text-red-400" : s.cpuLoad > 50 ? "text-amber-400" : "text-emerald-400")}>{s.cpuLoad}%</span></span>
)}
{s.asn && (
<span className="text-muted-foreground">ASN: <span className="font-mono text-foreground">{s.asn}</span></span>
)}
{s.ipv6Address && (
<span className="text-muted-foreground">IPv6: <span className="font-mono text-sky-400">{s.ipv6Address}</span></span>
)}
{s.vrfNames?.map((v) => (
<span key={v} className="text-muted-foreground">VRF: <span className="font-mono text-foreground">{v}</span></span>
))}
{s.comment && <span className="text-muted-foreground italic">{s.comment}</span>}
{s.polledAt ? (
<span className="text-muted-foreground/50 text-[11px]">
Опрошен: {new Date(s.polledAt).toLocaleString("ru")}
</span>
) : (
<span className="text-amber-500/70 text-[11px]">⚠ Ещё не опрашивался</span>
)}
</div>
{isLive && (
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 text-xs shrink-0"
disabled={isPolling}
onClick={(e) => { e.stopPropagation(); onPoll() }}
>
<RefreshCwIcon className={cn("size-3.5", isPolling && "animate-spin")} />
{isPolling ? "Опрос…" : "Опросить сейчас"}
</Button>
)}
</div>
<div>
<div className="flex items-center gap-3 mb-3">
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
Возможности RouterOS
</p>
<RosBadge os={s.os} />
<span className="text-[11px] text-muted-foreground">
{ver >= 715
? "✓ Актуальная версия — все ключевые фичи доступны"
: ver >= 710
? "⚠ Рекомендуется обновление до 7.15+"
: s.os !== "—"
? "✗ Устаревшая версия — требуется обновление"
: "Нет данных — нажмите «Опросить сейчас»"}
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-2">
{ROS_FEATURES.map((f) => {
const ok = ver >= f.minVer
return (
<div
key={f.name}
className={cn(
"flex items-start gap-2 rounded-md border px-3 py-2.5 transition-colors",
ok ? "border-emerald-500/25 bg-emerald-500/5" : "border-border/40 bg-background/40 opacity-60",
)}
>
{ok
? <CheckCircleIcon className="size-3.5 text-emerald-500 shrink-0 mt-0.5" />
: <XCircleIcon className="size-3.5 text-muted-foreground/40 shrink-0 mt-0.5" />}
<div className="min-w-0">
<p className={cn("text-xs font-medium leading-tight truncate", ok ? "text-foreground" : "text-muted-foreground")}>
{f.name}
</p>
<p className="text-[10px] text-muted-foreground leading-tight mt-0.5">
{f.label} · {f.desc}
</p>
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
export { ServerExpandedDetail }
+452
View File
@@ -0,0 +1,452 @@
"use client"
import { useMemo } from "react"
import {
type Column,
type ColumnDef,
getCoreRowModel,
getExpandedRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { Server, ServerType } from "@/lib/data"
import { StatusBadge } from "@/components/status-badge"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
DataGrid,
DataGridContainer,
DataGridTable,
} from "@/components/reui/data-grid"
import { EmptyState } from "@/components/empty-state"
import { ServerExpandedDetail } from "@/components/data-grids/server-expanded-detail"
import {
ChevronRightIcon,
ChevronDownIcon,
HomeIcon,
ServerIcon,
NetworkIcon,
ShieldIcon,
WifiIcon,
MoreHorizontalIcon,
ExternalLinkIcon,
PencilIcon,
RefreshCwIcon,
PowerIcon,
Trash2Icon,
ArrowUpIcon,
ArrowDownIcon,
ArrowUpDownIcon,
} from "lucide-react"
const TYPE_LABELS: Record<ServerType, string> = {
"jump-host": "JumpHost",
"exit-node": "Exit Node",
"home-router": "Home Router",
}
const TYPE_STYLES: Record<ServerType, string> = {
"jump-host": "bg-violet-500/10 text-violet-400 border-violet-500/20",
"exit-node": "bg-sky-500/10 text-sky-400 border-sky-500/20",
"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
return parseInt(m[1], 10) * 100 + parseInt(m[2], 10)
}
function TypeBadge({ type }: { type: ServerType }) {
const icon =
type === "jump-host" ? <ServerIcon className="size-3 mr-1" />
: type === "exit-node" ? <NetworkIcon className="size-3 mr-1" />
: <HomeIcon className="size-3 mr-1" />
return (
<span className={cn("inline-flex items-center text-xs font-medium border rounded px-2 py-0.5", TYPE_STYLES[type])}>
{icon}{TYPE_LABELS[type]}
</span>
)
}
function RosBadge({ os }: { os: string }) {
const v = rosVer(os)
const cls = v >= 715
? "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/20"
: v >= 710
? "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/20"
: "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/20"
return <span className={cn("text-xs font-mono border rounded px-2 py-0.5", cls)}>{os}</span>
}
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
isPolling: boolean
onEdit: (server: Server) => void
onDelete: (id: string) => void
onPoll: (id: string) => void
onToggleStatus: (id: string) => void
}
function ServerRowActions({
server,
isLive,
isPolling,
onEdit,
onDelete,
onPoll,
onToggleStatus,
}: ServerRowActionsProps) {
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="outline"
size="icon"
className={cn(
"size-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
"opacity-0 transition-[opacity,background-color,color,border-color]",
"group-hover/row:opacity-100 focus-visible:opacity-100",
"data-popup-open:opacity-100 data-popup-open:bg-muted",
"hover:bg-muted hover:text-foreground hover:border-border",
)}
onClick={(e) => e.stopPropagation()}
aria-label={`Действия: ${server.name}`}
>
<MoreHorizontalIcon className="size-4" />
</Button>
}
/>
<DropdownMenuContent align="end" side="bottom" className="w-52">
<DropdownMenuItem onClick={() => window.open(`https://${server.host}`, "_blank")}>
<ExternalLinkIcon className="size-4" />
Открыть WebFig
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(server)}>
<PencilIcon className="size-4" />
Редактировать
</DropdownMenuItem>
{isLive && (
<DropdownMenuItem onClick={() => onPoll(server.id)} disabled={isPolling}>
<RefreshCwIcon className={cn("size-4", isPolling && "animate-spin")} />
{isPolling ? "Опрос…" : "Опросить"}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onToggleStatus(server.id)}>
<PowerIcon className="size-4" />
{server.status === "offline" ? "Включить" : "Отключить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => onDelete(server.id)}>
<Trash2Icon className="size-4" />
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
interface ServersDataGridProps {
servers: Server[]
isLive: boolean
pollingIds: Set<string>
onPoll: (id: string) => void
onEdit: (server: Server) => void
onDelete: (id: string) => void
onToggleStatus: (id: string) => void
}
function ServersDataGrid({
servers,
isLive,
pollingIds,
onPoll,
onEdit,
onDelete,
onToggleStatus,
}: ServersDataGridProps) {
const columns = useMemo<ColumnDef<Server>[]>(
() => [
{
id: "name",
accessorKey: "name",
header: ({ column }) => (
<ServersTableHeader column={column} title="Имя / Хост" className="ml-1" />
),
cell: ({ row }) => {
const s = row.original
const expanded = row.getIsExpanded()
return (
<div className="flex items-start gap-2 min-w-0">
{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">
<p className="font-medium truncate">{s.name}</p>
<p className="text-xs font-mono text-muted-foreground">{s.host}</p>
{s.ipv6Address && (
<p className="text-[10px] font-mono text-sky-500/70 truncate max-w-[150px]" title={s.ipv6Address}>
{s.ipv6Address}
</p>
)}
</div>
</div>
)
},
meta: {
headerTitle: "Имя / Хост",
headerClassName: CELL_PAD_FIRST,
cellClassName: CELL_PAD_FIRST,
expandedContent: (row: Server) => (
<ServerExpandedDetail
server={row}
isLive={isLive}
isPolling={pollingIds.has(row.id)}
onPoll={() => onPoll(row.id)}
/>
),
},
},
{
id: "type",
accessorKey: "type",
header: ({ column }) => <ServersTableHeader column={column} title="Тип" />,
cell: ({ row }) => <TypeBadge type={row.original.type} />,
meta: { headerTitle: "Тип", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
},
{
id: "model",
accessorKey: "model",
header: ({ column }) => <ServersTableHeader column={column} title="Модель" />,
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.model}</span>,
meta: { headerTitle: "Модель", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
},
{
id: "os",
accessorKey: "os",
header: ({ column }) => <ServersTableHeader column={column} title="RouterOS" />,
cell: ({ row }) => <RosBadge os={row.original.os} />,
meta: { headerTitle: "RouterOS", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
},
{
id: "site",
accessorKey: "site",
header: ({ column }) => <ServersTableHeader 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 },
},
{
id: "wan",
header: () => (
<span className="text-xs font-medium text-muted-foreground">WAN / LAN</span>
),
enableSorting: false,
cell: ({ row }) => {
const s = row.original
if (s.type === "home-router" && s.wanUplinks?.length) {
return (
<div className="flex flex-col gap-0.5">
{s.wanUplinks.map((w) => (
<div key={w.id} className="flex items-center gap-1.5 text-[11px] font-mono">
<WifiIcon className="size-3 text-sky-400 shrink-0" />
<span className="font-semibold text-sky-600 dark:text-sky-400">{w.name}</span>
<span className="text-muted-foreground">{w.isp}</span>
<span className="text-muted-foreground">↓{w.maxDl}↑{w.maxUl}</span>
</div>
))}
{s.lanSubnet && (
<div className="text-[10px] font-mono text-muted-foreground mt-0.5">LAN {s.lanSubnet}</div>
)}
</div>
)
}
return (
<div className="flex flex-col gap-0.5">
{s.wireGuardIfaces && s.wireGuardIfaces.length > 0 && (
<div className="text-[11px] font-mono text-violet-500 dark:text-violet-400 flex items-center gap-1">
<ShieldIcon className="size-3" />
WG: {s.wireGuardIfaces.length} iface · {s.wireGuardIfaces.reduce((n, i) => n + i.peers.length, 0)} peers
</div>
)}
{s.rpkiEnabled && (
<div className="text-[10px] font-mono text-emerald-600 dark:text-emerald-400">RPKI ✓</div>
)}
{!s.wireGuardIfaces?.length && !s.rpkiEnabled && (
<span className="text-xs text-muted-foreground">—</span>
)}
</div>
)
},
meta: { headerTitle: "WAN / LAN", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
},
{
id: "latency",
accessorKey: "latency",
header: ({ column }) => (
<ServersTableHeader column={column} title="Задержка" className="w-full justify-end" />
),
cell: ({ row }) => {
const s = row.original
return (
<span className={cn(
"font-mono text-sm block text-right tabular-nums",
s.latency == null ? "text-muted-foreground" : s.latency > 60 ? "text-[var(--status-degraded-fg)]" : "",
)}>
{s.latency == null ? "—" : `${s.latency} мс`}
</span>
)
},
meta: {
headerTitle: "Задержка",
headerClassName: cn(CELL_PAD, "text-right"),
cellClassName: cn(CELL_PAD, "text-right"),
},
},
{
id: "status",
accessorKey: "status",
header: ({ column }) => <ServersTableHeader column={column} title="Статус" />,
cell: ({ row }) => <StatusBadge status={row.original.status} />,
meta: { headerTitle: "Статус", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
},
{
id: "actions",
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const s = row.original
return (
<ServerRowActions
server={s}
isLive={isLive}
isPolling={pollingIds.has(s.id)}
onEdit={onEdit}
onDelete={onDelete}
onPoll={onPoll}
onToggleStatus={onToggleStatus}
/>
)
},
enableSorting: false,
size: 56,
meta: {
headerClassName: CELL_PAD_LAST,
cellClassName: CELL_PAD_LAST,
},
},
],
[isLive, onDelete, onEdit, onPoll, onToggleStatus, pollingIds],
)
const table = useReactTable({
data: servers,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getRowId: (row) => row.id,
getRowCanExpand: () => true,
})
if (servers.length === 0) {
return (
<EmptyState
icon={<ServerIcon className="size-4" />}
title="Нет серверов"
description="Добавьте первый MikroTik-сервер для мониторинга"
className="border-0 py-12"
/>
)
}
return (
<DataGrid
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>
)
}
export { ServersDataGrid, rosVer, RosBadge, TypeBadge }