From fe32c9313aa2234aea0558a44d736d729353f892 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 6 Sep 2026 17:58:05 +0700 Subject: [PATCH] feat(ui): integrate KpiStatGrid for enhanced statistics display Replaced existing KPI display implementations across multiple pages with the new KpiStatGrid component for a more consistent and visually appealing presentation of statistics. Updated the Backups, BGP, Certificates, Communities, Containers, Dashboard, and Data Collection pages to utilize the KpiStatGrid, improving the overall user experience and maintainability of the codebase. Additionally, added new dependencies in package.json for required libraries. --- app/(main)/backups/page.tsx | 57 +- app/(main)/bgp/page.tsx | 102 +- app/(main)/certificates/page.tsx | 160 ++- app/(main)/communities/page.tsx | 50 +- app/(main)/containers/page.tsx | 57 +- app/(main)/dashboard/page.tsx | 139 +-- app/(main)/data-collection/page.tsx | 55 +- app/(main)/filters/page.tsx | 270 +++-- app/(main)/firewall/page.tsx | 1059 ++++++++++++++--- app/(main)/gre/page.tsx | 172 ++- app/(main)/ospf/page.tsx | 283 ++--- app/(main)/probes/page.tsx | 85 +- app/(main)/recursive-routes/page.tsx | 148 ++- app/(main)/route-optimizer/page.tsx | 52 +- app/(main)/servers/page.tsx | 57 +- app/(main)/terminal/page.tsx | 131 +- app/(main)/traffic/page.tsx | 262 ++-- app/(main)/uptime/page.tsx | 135 ++- app/(main)/vxlan/page.tsx | 58 +- app/(main)/wireguard/page.tsx | 130 +- backend/package.json | 3 +- backend/src/index.ts | 2 + backend/src/lib/permissions.test.ts | 12 + backend/src/lib/permissions.ts | 6 +- backend/src/routes/firewall.ts | 294 +++++ backend/src/routes/traffic.ts | 310 +++-- backend/src/services/firewall-live.ts | 198 +++ backend/src/services/mikrotik.ts | 26 +- backend/src/services/traffic-collector.ts | 88 +- backend/src/services/traffic-rate.test.ts | 76 ++ backend/src/services/traffic-rate.ts | 221 ++++ backend/src/types/server.ts | 16 + .../data-grids/firewall-rules-data-grid.tsx | 71 +- components/data-page-toolbar.tsx | 3 + components/reui-kit/traffic-rx-tx-chart.tsx | 132 ++ components/server-rail-layout.tsx | 236 ++++ components/server-tile-rail.tsx | 299 +++-- components/ui/chart.tsx | 372 ++++++ components/ui/combobox.tsx | 300 +++++ components/ui/item.tsx | 203 ++++ docs/ui-design-contract.md | 6 +- hooks/use-traffic-live.ts | 93 ++ lib/data.ts | 40 +- lib/fmt-rate.ts | 28 + package-lock.json | 401 ++++++- package.json | 2 + tsconfig.tsbuildinfo | 2 +- 47 files changed, 5371 insertions(+), 1531 deletions(-) create mode 100644 backend/src/routes/firewall.ts create mode 100644 backend/src/services/firewall-live.ts create mode 100644 backend/src/services/traffic-rate.test.ts create mode 100644 backend/src/services/traffic-rate.ts create mode 100644 components/reui-kit/traffic-rx-tx-chart.tsx create mode 100644 components/server-rail-layout.tsx create mode 100644 components/ui/chart.tsx create mode 100644 components/ui/combobox.tsx create mode 100644 components/ui/item.tsx create mode 100644 hooks/use-traffic-live.ts create mode 100644 lib/fmt-rate.ts diff --git a/app/(main)/backups/page.tsx b/app/(main)/backups/page.tsx index d7c0adf..68c2e37 100644 --- a/app/(main)/backups/page.tsx +++ b/app/(main)/backups/page.tsx @@ -8,8 +8,7 @@ import { FileImportDialog } from "@/components/file-import-dialog" import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit" import { StatusBadge } from "@/components/status-badge" import type { Backup, Server } from "@/lib/data" -import { Frame, FramePanel } from "@/components/reui/frame" -import { IconTile } from "@/components/reui/icon-tile" +import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { OpsPanel } from "@/components/ops-panel" import { DataPageCard } from "@/components/data-page-card" import { Button } from "@/components/ui/button" @@ -343,27 +342,39 @@ export default function BackupsPage() {
- {/* Stats */} -
- {[ - { label: "Всего бэкапов", value: backupList.length, icon: }, - { label: "Авто", value: autoCount, icon: }, - { label: "Вручную", value: manualCount, icon: }, - { label: "Серверов охвачено",value: serverCount, icon: }, - ].map((s) => ( - - - -
-

{s.label}

-

{s.value}

-
-
- - ))} -
+ , + iconClassName: "text-muted-foreground", + }, + { + id: "auto", + label: "Авто", + value: autoCount, + icon: , + iconClassName: "text-info", + }, + { + id: "manual", + label: "Вручную", + value: manualCount, + icon: , + iconClassName: "text-muted-foreground", + }, + { + id: "servers", + label: "Серверов охвачено", + value: serverCount, + icon: , + iconClassName: "text-primary", + }, + ]} + /> {/* Info bar */}
diff --git a/app/(main)/bgp/page.tsx b/app/(main)/bgp/page.tsx index a2d5d6a..0b0b3eb 100644 --- a/app/(main)/bgp/page.tsx +++ b/app/(main)/bgp/page.tsx @@ -10,6 +10,7 @@ import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields" import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types" import { BGP_AS_NAMES } from "@/lib/bgp/types" import { Frame, FramePanel } from "@/components/reui/frame" +import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { OpsPanel } from "@/components/ops-panel" import { DataPageCard } from "@/components/data-page-card" import { Button } from "@/components/ui/button" @@ -20,7 +21,7 @@ import { RefreshCwIcon, DownloadIcon, SearchIcon, ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon, ArrowDownIcon, ClipboardCopyIcon, ServerIcon, - XIcon, AlertCircleIcon, + XIcon, AlertCircleIcon, GitMergeIcon, CheckCircleIcon, } from "lucide-react" import { useDataSource } from "@/lib/data-source" import { requestJson } from "@/shared/api/http-client" @@ -463,22 +464,39 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) { return (
- {/* summary row */} -
- {[ - { label: "Всего префиксов", value: fmtNum(totalRx), color: "text-emerald-600 dark:text-emerald-400" }, - { label: "Активных маршрутов", value: fmtNum(totalActive), color: "text-sky-600 dark:text-sky-400" }, - { label: "eBGP сессий", value: ebgpSessions, color: "" }, - { label: "iBGP сессий", value: ibgpSessions, color: "" }, - ].map(s => ( - - -

{s.label}

-

{s.value}

-
- - ))} -
+ , + iconClassName: "text-success", + }, + { + id: "active", + label: "Активных маршрутов", + value: fmtNum(totalActive), + icon: , + iconClassName: "text-info", + }, + { + id: "ebgp", + label: "eBGP сессий", + value: ebgpSessions, + icon: , + iconClassName: "text-muted-foreground", + }, + { + id: "ibgp", + label: "iBGP сессий", + value: ibgpSessions, + icon: , + iconClassName: "text-muted-foreground", + }, + ]} + />
{/* prefixes by peer — horizontal bar chart */} @@ -722,22 +740,40 @@ export default function BgpPage() { )} - {/* KPI strip */} -
- {[ - { label: "Сессий всего", value: sessions.length, color: "" }, - { label: "Established", value: established, color: "text-emerald-600 dark:text-emerald-400" }, - { label: "Не установлено", value: notEstab, color: notEstab > 0 ? "text-amber-500" : "text-muted-foreground" }, - { label: "Получено префиксов", value: fmtNum(totalRx),color: "" }, - ].map(s => ( - - -

{s.label}

-

{s.value}

-
- - ))} -
+ , + iconClassName: "text-muted-foreground", + }, + { + id: "established", + label: "Established", + value: established, + icon: , + iconClassName: "text-success", + }, + { + id: "not-estab", + label: "Не установлено", + value: notEstab, + icon: , + iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground", + variant: notEstab > 0 ? "warning" : "default", + }, + { + id: "prefixes", + label: "Получено префиксов", + value: fmtNum(totalRx), + icon: , + iconClassName: "text-info", + }, + ]} + /> {/* alert: not-established sessions */} {notEstab > 0 && ( diff --git a/app/(main)/certificates/page.tsx b/app/(main)/certificates/page.tsx index a06dacf..9041a08 100644 --- a/app/(main)/certificates/page.tsx +++ b/app/(main)/certificates/page.tsx @@ -7,12 +7,13 @@ import { FileImportDialog } from "@/components/file-import-dialog" import { routerCertificates, servers as mockServers } from "@/lib/data" import type { CertStatus, Server } from "@/lib/data" import type { CertificateDto } from "@mmapp/contracts/certificates" -import { Frame, FramePanel } from "@/components/reui/frame" -import { IconTile } from "@/components/reui/icon-tile" import { OpsPanel } from "@/components/ops-panel" import { DataPageCard } from "@/components/data-page-card" import { DataPageToolbar } from "@/components/data-page-toolbar" import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid" +import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" +import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" +import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { @@ -119,42 +120,41 @@ function CertPartKpi({ expired: CertificateDto[] }) { return ( -
- {[ + , + icon: , + iconClassName: "text-muted-foreground", }, { + id: "valid", label: "Действующих", value: displayCerts.filter((c) => c.status === "valid").length, - icon: , + icon: , + iconClassName: "text-success", }, { + id: "expiring", label: "Истекают", value: expiring.length, - icon: , + icon: , + iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground", + variant: expiring.length > 0 ? "warning" : "default", }, { + id: "expired", label: "Истёкших", value: expired.length, - icon: , + icon: , + iconClassName: expired.length > 0 ? "text-destructive" : "text-muted-foreground", + variant: expired.length > 0 ? "destructive" : "default", }, - ].map((s) => ( - - - -
-

{s.label}

-

{s.value}

-
-
- - ))} -
+ ]} + /> ) } @@ -437,6 +437,8 @@ export default function CertificatesPage() { const [issueTrustWww, setIssueTrustWww] = useState(true) const [issueTrustApi, setIssueTrustApi] = useState(true) + const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID) + const [acmeDirectoryUrl, setAcmeDirectoryUrl] = useState( "https://acme-v02.api.letsencrypt.org/directory", ) @@ -451,6 +453,27 @@ export default function CertificatesPage() { return routerCertificates.map(mockToDto) }, [prefsHydrated, isLive, certificates]) + const displayServers = isLive ? serverList : mockServers + + const scopedCerts = useMemo(() => { + if (selectedServerId === ALL_SERVERS_ID) return displayCerts + return displayCerts.filter((c) => c.serverId === selectedServerId) + }, [displayCerts, selectedServerId]) + + const certRailItems = useMemo(() => ( + displayServers.map((s) => ({ + id: s.id, + name: s.name, + host: s.host, + site: s.site, + country: s.country, + status: s.status, + type: s.type, + enabled: s.enabled, + meta: String(displayCerts.filter((c) => c.serverId === s.id).length), + })) + ), [displayServers, displayCerts]) + const serverById = useMemo(() => { const map = new Map() for (const s of isLive ? serverList : mockServers) map.set(s.id, s) @@ -515,13 +538,13 @@ export default function CertificatesPage() { }, [isLive, loadLive, loadAcmeSettings]) const expiring = useMemo( - () => displayCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30), - [displayCerts], + () => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30), + [scopedCerts], ) - const expired = useMemo(() => displayCerts.filter((c) => c.status === "expired"), [displayCerts]) + const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts]) const filtered = useMemo(() => { - return displayCerts.filter((c) => { + return scopedCerts.filter((c) => { if (statusFilter !== "all" && c.status !== statusFilter) return false if (!search) return true const q = search.toLowerCase() @@ -532,7 +555,7 @@ export default function CertificatesPage() { c.sans.some((s) => s.includes(q)) ) }) - }, [displayCerts, search, statusFilter]) + }, [scopedCerts, search, statusFilter]) async function handleRefresh() { if (!liveReady) return @@ -625,35 +648,52 @@ export default function CertificatesPage() { } return ( -
- - - - - - } - /> - -
+ <> + + + + + + + } + /> + } + >
{isLive && backendStatus === false && (
@@ -673,7 +713,7 @@ export default function CertificatesPage() {
)} - + {liveReady && (
-
+ { setIssueOpen(v); if (!v) setIssueStep(1) }}> @@ -753,7 +793,7 @@ export default function CertificatesPage() { -
+ ) } diff --git a/app/(main)/communities/page.tsx b/app/(main)/communities/page.tsx index 5044505..b7417af 100644 --- a/app/(main)/communities/page.tsx +++ b/app/(main)/communities/page.tsx @@ -11,6 +11,7 @@ import { ACTION_COLOR, } from "@/components/data-grids/communities-data-grid" import { Frame, FramePanel } from "@/components/reui/frame" +import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { OpsPanel } from "@/components/ops-panel" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -160,22 +161,39 @@ export default function CommunitiesPage() {

)} - {/* ── summary ── */} -
- {[ - { label: "Всего communities", value: String(listData.length) }, - { label: "Активных", value: String(listData.filter(c => c.enabled).length) }, - { label: "Стандартных", value: String(listData.filter(c => c.type === "standard").length) }, - { label: "Использует фильтры",value: String(new Set(listData.flatMap(c => c.filterIds)).size) }, - ].map(({ label, value }) => ( - - -

{label}

-

{value}

-
- - ))} -
+ , + iconClassName: "text-muted-foreground", + }, + { + id: "enabled", + label: "Активных", + value: String(listData.filter((c) => c.enabled).length), + icon: , + iconClassName: "text-success", + }, + { + id: "standard", + label: "Стандартных", + value: String(listData.filter((c) => c.type === "standard").length), + icon: , + iconClassName: "text-info", + }, + { + id: "filters", + label: "Использует фильтры", + value: String(new Set(listData.flatMap((c) => c.filterIds)).size), + icon: , + iconClassName: "text-primary", + }, + ]} + />
{/* ── main table ── */} diff --git a/app/(main)/containers/page.tsx b/app/(main)/containers/page.tsx index 533d57b..e96c978 100644 --- a/app/(main)/containers/page.tsx +++ b/app/(main)/containers/page.tsx @@ -6,7 +6,7 @@ import { routerContainers, servers } from "@/lib/data" import type { RouterContainer } from "@/lib/data" import { Flag } from "@/components/flag" import { Frame, FramePanel } from "@/components/reui/frame" -import { IconTile } from "@/components/reui/icon-tile" +import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { OpsPanel } from "@/components/ops-panel" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" @@ -286,27 +286,40 @@ export default function ContainersPage() {
- {/* KPI */} -
- {[ - { label: "Всего", value: routerContainers.length, icon: }, - { label: "Running", value: running, icon: }, - { label: "Stopped", value: stopped, icon: }, - { label: "Ошибок", value: errors, icon: }, - ].map((s) => ( - - - -
-

{s.label}

-

{s.value}

-
-
- - ))} -
+ , + iconClassName: "text-muted-foreground", + }, + { + id: "running", + label: "Running", + value: running, + icon: , + iconClassName: "text-success", + }, + { + id: "stopped", + label: "Stopped", + value: stopped, + icon: , + iconClassName: "text-muted-foreground", + }, + { + id: "errors", + label: "Ошибок", + value: errors, + icon: , + iconClassName: "text-destructive", + variant: errors > 0 ? "destructive" : "default", + }, + ]} + /> {/* Info banner */}
diff --git a/app/(main)/dashboard/page.tsx b/app/(main)/dashboard/page.tsx index 10b44db..00aca32 100644 --- a/app/(main)/dashboard/page.tsx +++ b/app/(main)/dashboard/page.tsx @@ -1,15 +1,13 @@ "use client" -import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { usePathname } from "next/navigation" import Link from "next/link" import { PageHeader } from "@/components/page-header" import { OpsPanel } from "@/components/ops-panel" -import { Frame, FramePanel } from "@/components/reui/frame" -import { IconTile } from "@/components/reui/icon-tile" +import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { StatusDot } from "@/components/status-dot" import { StatusBadge } from "@/components/status-badge" -import { Sparkline } from "@/components/sparkline" import { LatencyChart } from "@/components/dashboard/latency-chart" import { BandwidthChart } from "@/components/dashboard/bandwidth-chart" import { InternetPathMapCard } from "@/components/dashboard/internet-path-map" @@ -47,49 +45,6 @@ function makeApiFetch(backendUrl: string) { } } -function StatCard({ - label, value, unit, delta, deltaDir, spark, sparkColor, icon, -}: { - label: string; value: string; unit?: string; delta?: string - deltaDir?: "up" | "down"; spark?: number[]; sparkColor?: string - icon?: ReactNode -}) { - return ( - - -
- {icon ? ( - - ) : null} -
-

{label}

-
- {value} - {unit ? {unit} : null} -
- {delta ? ( -

- {delta} -

- ) : null} -
-
- {spark && spark.length > 1 ? ( -
- -
- ) : null} -
- - ) -} - function fmtIntRu(n: number): string { return n.toLocaleString("ru-RU") } @@ -725,49 +680,53 @@ export default function DashboardPage() {
- {/* KPI row */} -
- } - /> - } - /> - } - /> - } - /> -
+ , + iconClassName: "text-muted-foreground", + variant: dashboardKpi.servers.deltaDir === "down" ? "warning" : "default", + }, + { + id: "filters", + label: "Активные фильтры", + value: dashboardKpi.filters.unit + ? `${dashboardKpi.filters.value} ${dashboardKpi.filters.unit}` + : dashboardKpi.filters.value, + hint: dashboardKpi.filters.delta, + icon: , + iconClassName: "text-info", + }, + { + id: "bgp", + label: "BGP-префиксы", + value: dashboardKpi.bgp.unit + ? `${dashboardKpi.bgp.value} ${dashboardKpi.bgp.unit}` + : dashboardKpi.bgp.value, + hint: dashboardKpi.bgp.delta, + icon: , + iconClassName: "text-primary", + }, + { + id: "alerts", + label: "Активные алерты", + value: dashboardKpi.alerts.unit + ? `${dashboardKpi.alerts.value} ${dashboardKpi.alerts.unit}` + : dashboardKpi.alerts.value, + hint: dashboardKpi.alerts.delta, + icon: , + iconClassName: dashboardKpi.alerts.deltaDir === "down" ? "text-destructive" : "text-muted-foreground", + variant: dashboardKpi.alerts.deltaDir === "down" ? "destructive" : "default", + }, + ]} + /> {/* Latency chart + Events */}
diff --git a/app/(main)/data-collection/page.tsx b/app/(main)/data-collection/page.tsx index 75f14de..4cd3167 100644 --- a/app/(main)/data-collection/page.tsx +++ b/app/(main)/data-collection/page.tsx @@ -6,8 +6,7 @@ import { PageHeader } from "@/components/page-header" import { FormToggle } from "@/components/form-kit" import { Badge } from "@/components/reui/badge" import { Button, buttonVariants } from "@/components/ui/button" -import { Frame, FramePanel } from "@/components/reui/frame" -import { IconTile } from "@/components/reui/icon-tile" +import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { OpsPanel } from "@/components/ops-panel" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { @@ -967,13 +966,13 @@ export default function DataCollectionPage() { sub: uptimeCollector?.scheduler?.jobs?.length ? "По сохранённым задачам планировщика" : "По переключателям на этой странице", - icon: , + icon: , }, { label: "Сейчас выполняется", value: String(runningJobsCount), sub: "Фоновые прогоны планировщика", - icon: , + icon: , }, { label: "Трафик — последний сбор", @@ -982,16 +981,16 @@ export default function DataCollectionPage() { : "—", sub: trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных", icon: trafficCollector?.lastError ? ( - + ) : ( - + ), }, { label: "Журнал (в списке)", value: String(schedulerRuns.length), sub: errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет", - icon: , + icon: , }, ], [ @@ -1064,22 +1063,32 @@ export default function DataCollectionPage() { {isLive && ( <> -
- {stats.map((s) => ( - - - -
-

{s.label}

-

{s.value}

-

{s.sub}

-
-
- - ))} -
+ ({ + id: `dc-${i}`, + label: s.label, + value: s.value, + hint: s.sub, + icon: s.icon, + iconClassName: + s.label === "Трафик — последний сбор" && trafficCollector?.lastError + ? "text-destructive" + : s.label === "Сейчас выполняется" && runningJobsCount > 0 + ? "text-warning" + : s.label === "Журнал (в списке)" && errorRunsInView + ? "text-destructive" + : s.label === "Трафик — последний сбор" + ? "text-success" + : "text-muted-foreground", + variant: + s.label === "Трафик — последний сбор" && trafficCollector?.lastError + ? "destructive" as const + : s.label === "Журнал (в списке)" && errorRunsInView + ? "warning" as const + : "default" as const, + }))} + />
diff --git a/app/(main)/filters/page.tsx b/app/(main)/filters/page.tsx index b7c9805..4ee53f0 100644 --- a/app/(main)/filters/page.tsx +++ b/app/(main)/filters/page.tsx @@ -36,6 +36,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { toast } from "sonner" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" +import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" +import { type ServerTileItem } from "@/components/server-tile-rail" // ── helpers ──────────────────────────────────────────────────────────────────── @@ -1401,6 +1403,25 @@ export default function FiltersPage() { const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0] const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0) + const filterRailItems = useMemo(() => ( + allServers.map((s) => ({ + id: s.id, + name: s.name, + host: s.host, + site: s.site, + country: s.country, + status: s.status, + type: s.type, + enabled: s.enabled, + meta: String(rulesets.find((r) => r.serverId === s.id)?.rules.length ?? 0), + })) + ), [allServers, rulesets]) + + const handleSelectServer = useCallback((id: string) => { + setSelectedServerId(id) + setSearch("") + }, []) + const currentRules = useMemo( () => rulesets.find(r => r.serverId === selectedServerId)?.rules ?? [], [rulesets, selectedServerId], @@ -1566,142 +1587,117 @@ export default function FiltersPage() { } return ( -
- - {isLive && ( - <> - - - - - )} - - - - - } - /> - - {isLive && liveLoadState === "error" && ( -
- - Бекенд недоступен — показаны демо-данные из lib/data. Проверьте URL бекенда в настройках. -
- )} - - {/* ── summary bar + server chips (same pattern as monitoring) ── */} -
-
- Всего правил - {totalRules} -
- {(() => { - const bhTotal = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0) - if (bhTotal === 0) return null - return ( - - ⊘ {bhTotal} blackhole - - ) - })()} - -
- - {allServers.map(s => { - const count = rulesets.find(r => r.serverId === s.id)?.rules.length ?? 0 - const active = selectedServerId === s.id - return ( - - ) - })} -
- - {/* ── toolbar ── */} -
-
- - setSearch(e.target.value)} /> - {search && ( - - )} -
- -

- {filteredRules.length !== currentRules.length - ? `${filteredRules.length} из ${currentRules.length} правил` - : `${currentRules.length} правил` + <> + + + {isLive && ( + <> + + + + + )} + + + + } -

-
- - {/* ── main content ── */} -
+ /> + } + banner={ + <> + {isLive && liveLoadState === "error" && ( +
+ + Бекенд недоступен — показаны демо-данные из lib/data. Проверьте URL бекенда в настройках. +
+ )} +
+
+ + setSearch(e.target.value)} /> + {search && ( + + )} +
+
+ Всего правил + {totalRules} +
+ {(() => { + const bhTotal = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0) + if (bhTotal === 0) return null + return ( + + ⊘ {bhTotal} blackhole + + ) + })()} +

+ {filteredRules.length !== currentRules.length + ? `${filteredRules.length} из ${currentRules.length} правил` + : `${currentRules.length} правил` + } +

+
+ + } + >
{/* RouterOS 7.x BGP extensions — только демо из lib/data (моки) */} @@ -1817,7 +1813,7 @@ export default function FiltersPage() {
-
+ -
+ ) } diff --git a/app/(main)/firewall/page.tsx b/app/(main)/firewall/page.tsx index 2b26f95..d738e90 100644 --- a/app/(main)/firewall/page.tsx +++ b/app/(main)/firewall/page.tsx @@ -1,6 +1,7 @@ "use client" -import { useEffect, useMemo, useRef, useState } from "react" +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useSearchParams } from "next/navigation" import { PageHeader } from "@/components/page-header" import { FirewallRulesDataGrid, @@ -11,9 +12,18 @@ import { FirewallScenarioRulesDataGrid } from "@/components/data-grids/firewall- import { DataPageCard } from "@/components/data-page-card" import { DataPageToolbarFrame } from "@/components/data-page-toolbar" import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit" -import { firewallRules, type FirewallRule } from "@/lib/data" +import { + firewallRules, + servers as mockServers, + type FirewallAddressListEntry, + type FirewallRule, + type FirewallTable, + type Server, +} from "@/lib/data" +import { useDataSource } from "@/lib/data-source" +import { requestJson } from "@/shared/api/http-client" import { Frame, FramePanel } from "@/components/reui/frame" -import { IconTile } from "@/components/reui/icon-tile" +import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { OpsPanel } from "@/components/ops-panel" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -34,13 +44,17 @@ import { cn } from "@/lib/utils" import { PlusIcon, SearchIcon, ShieldIcon, ShieldOffIcon, ListFilterIcon, ArrowRightLeftIcon, WrenchIcon, LayersIcon, - MoreHorizontalIcon, PencilIcon, Trash2Icon, CodeXmlIcon, + PencilIcon, Trash2Icon, CodeXmlIcon, + MoreHorizontalIcon, PowerIcon, CheckCircleIcon, PlayIcon, SquareIcon, RotateCcwIcon, ZapIcon, CheckCircle2Icon, XCircleIcon, MinusCircleIcon, SkipForwardIcon, - SlidersHorizontalIcon, + SlidersHorizontalIcon, RefreshCwIcon, } from "lucide-react" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" +import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" +import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail" +import { toast } from "sonner" // ─── Types ──────────────────────────────────────────────────────────────────── @@ -254,40 +268,33 @@ function evalRule(rule: FirewallRule, pkt: PacketDef): { return { verdict: rule.action === "passthrough" ? "passthrough" : "match", checks } } -interface AddressListEntry { - id: string - list: string - address: string - comment: string - disabled: boolean - timeout?: string -} +type AddressListEntry = FirewallAddressListEntry // ─── Mock address-list data ─────────────────────────────────────────────────── const INIT_ADDRESS_LISTS: AddressListEntry[] = [ - { id: "al1", list: "youtube-bypass", address: "142.250.0.0/15", comment: "Google/YouTube AS15169", disabled: false }, - { id: "al2", list: "youtube-bypass", address: "172.217.0.0/16", comment: "Google/YouTube AS15169", disabled: false }, - { id: "al3", list: "cdn-bypass", address: "104.16.0.0/12", comment: "Cloudflare AS13335", disabled: false }, - { id: "al4", list: "cdn-bypass", address: "23.32.0.0/11", comment: "Akamai AS20940", disabled: false }, - { id: "al5", list: "cdn-bypass", address: "151.101.0.0/16", comment: "Fastly AS54113", disabled: false }, - { id: "al6", list: "streaming-eu", address: "54.246.0.0/16", comment: "Netflix AS2906", disabled: false }, - { id: "al7", list: "streaming-eu", address: "45.57.0.0/16", comment: "Netflix AS2906 NL", disabled: false }, - { id: "al8", list: "social-block", address: "157.240.0.0/17", comment: "Facebook/Meta AS32934", disabled: false }, - { id: "al9", list: "social-block", address: "31.13.64.0/18", comment: "Facebook/Meta AS32934", disabled: false }, - { id: "al10", list: "gaming-low-latency",address: "162.159.128.0/19", comment: "Discord/Cloudflare AS13335", disabled: false }, - { id: "al11", list: "management-access", address: "10.10.0.0/16", comment: "LAN", disabled: false }, - { id: "al12", list: "management-access", address: "192.168.100.0/24", comment: "MGMT VLAN", disabled: false }, - { id: "al13", list: "blocklist-dynamic", address: "185.220.101.45", comment: "Tor exit — dynamic ban", disabled: false, timeout: "01:00:00" }, - { id: "al14", list: "blocklist-dynamic", address: "80.82.70.118", comment: "Shodan scanner — dynamic ban", disabled: false, timeout: "00:42:18" }, + { id: "al1", list: "youtube-bypass", address: "142.250.0.0/15", comment: "Google/YouTube AS15169", disabled: false, family: "ip", serverId: "srv1", rosId: "*1" }, + { id: "al2", list: "youtube-bypass", address: "172.217.0.0/16", comment: "Google/YouTube AS15169", disabled: false, family: "ip", serverId: "srv1", rosId: "*2" }, + { id: "al3", list: "cdn-bypass", address: "104.16.0.0/12", comment: "Cloudflare AS13335", disabled: false, family: "ip", serverId: "srv1", rosId: "*3" }, + { id: "al4", list: "cdn-bypass", address: "23.32.0.0/11", comment: "Akamai AS20940", disabled: false, family: "ip", serverId: "srv1", rosId: "*4" }, + { id: "al5", list: "cdn-bypass", address: "151.101.0.0/16", comment: "Fastly AS54113", disabled: false, family: "ip", serverId: "srv1", rosId: "*5" }, + { id: "al6", list: "streaming-eu", address: "54.246.0.0/16", comment: "Netflix AS2906", disabled: false, family: "ip", serverId: "srv1", rosId: "*6" }, + { id: "al7", list: "streaming-eu", address: "45.57.0.0/16", comment: "Netflix AS2906 NL", disabled: false, family: "ip", serverId: "srv1", rosId: "*7" }, + { id: "al8", list: "social-block", address: "157.240.0.0/17", comment: "Facebook/Meta AS32934", disabled: false, family: "ip", serverId: "srv1", rosId: "*8" }, + { id: "al9", list: "social-block", address: "31.13.64.0/18", comment: "Facebook/Meta AS32934", disabled: false, family: "ip", serverId: "srv1", rosId: "*9" }, + { id: "al10", list: "gaming-low-latency",address: "162.159.128.0/19", comment: "Discord/Cloudflare AS13335", disabled: false, family: "ip", serverId: "srv1", rosId: "*10" }, + { id: "al11", list: "management-access", address: "10.10.0.0/16", comment: "LAN", disabled: false, family: "ip", serverId: "srv1", rosId: "*11" }, + { id: "al12", list: "management-access", address: "192.168.100.0/24", comment: "MGMT VLAN", disabled: false, family: "ip", serverId: "srv1", rosId: "*12" }, + { id: "al13", list: "blocklist-dynamic", address: "185.220.101.45", comment: "Tor exit — dynamic ban", disabled: false, timeout: "01:00:00", family: "ip", serverId: "srv1", rosId: "*13" }, + { id: "al14", list: "blocklist-dynamic", address: "80.82.70.118", comment: "Shodan scanner — dynamic ban", disabled: false, timeout: "00:42:18", family: "ip", serverId: "srv1", rosId: "*14" }, + { id: "al15", list: "v6-allow", address: "2a01:4f8::/32", comment: "Hetzner v6", disabled: false, family: "ip6", serverId: "srv1", rosId: "*1" }, ] // ─── IP family filter ───────────────────────────────────────────────────────── -type IpFamily = "all" | "ip" | "ip6" +type IpFamily = "ip" | "ip6" const IP_FAMILY_LABELS: Record = { - all: "IPv4 + IPv6", ip: "IPv4", ip6: "IPv6", } @@ -295,18 +302,17 @@ const IP_FAMILY_LABELS: Record = { // ─── Chain groups config ───────────────────────────────────────────────────── const CHAIN_GROUPS: { - id: ChainGroup; label: string; icon: React.ReactNode; chains: string[]; ip6chains?: string[] + id: ChainGroup; label: string; icon: React.ReactNode; chains: string[] }[] = [ { id: "filter", label: "Filter", icon: , chains: ["input","forward","output"], - ip6chains: ["ip6-input","ip6-forward","ip6-output"], }, - { id: "nat", label: "NAT", icon: , chains: ["srcnat","dstnat"] }, + { id: "nat", label: "NAT", icon: , chains: ["srcnat","dstnat"] }, { id: "mangle", label: "Mangle", icon: , chains: ["prerouting","postrouting","forward","input","output"] }, - { id: "raw", label: "Raw", icon: , chains: ["prerouting","output"] }, - { id: "address-lists", label: "Адр. листы", icon: , chains: [] }, - { id: "simulator", label: "Симулятор", icon: , chains: [] }, + { id: "raw", label: "Raw", icon: , chains: ["prerouting","output"] }, + { id: "address-lists", label: "Адр. листы", icon: , chains: [] }, + { id: "simulator", label: "Симулятор", icon: , chains: [] }, ] // ─── Action styles ──────────────────────────────────────────────────────────── @@ -350,6 +356,59 @@ const defaultForm = { type RuleForm = typeof defaultForm +function looksLikeAddress(value: string): boolean { + const v = value.trim() + if (!v || v === "—") return false + return v.includes(".") || v.includes(":") || v.includes("/") +} + +function tableOfGroup(group: ChainGroup): FirewallTable | null { + if (group === "filter" || group === "nat" || group === "mangle" || group === "raw") return group + return null +} + +interface RuleWritePayload { + chain: string + action: string + protocol?: string + srcAddress?: string + dstAddress?: string + srcAddressList?: string + dstAddressList?: string + srcPort?: string + dstPort?: string + inInterface?: string + outInterface?: string + connectionState?: string + comment?: string + disabled?: boolean + log?: boolean + logPrefix?: string +} + +function formToWritePayload(form: RuleForm): RuleWritePayload { + const src = form.srcAddrList.trim() + const dst = form.dstAddrList.trim() + return { + chain: form.chain, + action: form.action, + protocol: form.proto, + srcAddress: src && looksLikeAddress(src) ? src : undefined, + srcAddressList: src && !looksLikeAddress(src) ? src : undefined, + dstAddress: dst && looksLikeAddress(dst) ? dst : undefined, + dstAddressList: dst && !looksLikeAddress(dst) ? dst : undefined, + srcPort: form.srcPort.trim() || undefined, + dstPort: form.dstPort.trim() || undefined, + inInterface: form.inIface.trim() || undefined, + outInterface: form.outIface.trim() || undefined, + connectionState: form.connState.trim() || undefined, + comment: form.comment.trim() || undefined, + disabled: !form.enabled, + log: form.log, + logPrefix: form.log && form.logPrefix.trim() ? form.logPrefix.trim() : undefined, + } +} + // ─── Helpers ────────────────────────────────────────────────────────────────── function fmtHits(n: number): string { @@ -397,10 +456,11 @@ function generateRsc(rules: FirewallRule[], timestamp?: string): string { } for (const [chain, chainRules] of Object.entries(byChain)) { - const table = TABLE_MAP[chain] ?? "filter" - lines.push(`# ── /ip firewall ${table} chain=${chain} ──────────────────────────────`) + const table = chainRules[0]?.table ?? TABLE_MAP[chain] ?? "filter" + const familyPath = chainRules[0]?.family === "ip6" ? "/ipv6 firewall" : "/ip firewall" + lines.push(`# ── ${familyPath} ${table} chain=${chain} ──────────────────────────────`) for (const r of chainRules) { - const parts = [`/ip firewall ${table} add`] + const parts = [`${familyPath} ${table} add`] parts.push(`chain=${r.chain}`) parts.push(`action=${r.action}`) if (r.proto && r.proto !== "all") parts.push(`protocol=${r.proto}`) @@ -420,32 +480,42 @@ function generateRsc(rules: FirewallRule[], timestamp?: string): string { // ─── Rule Sheet ─────────────────────────────────────────────────────────────── -function RuleSheet({ open, onClose, initialRule, chainGroup }: { +function RuleSheet({ open, onClose, initialRule, chainGroup, family, onSave, saving = false }: { open: boolean onClose: () => void initialRule: Partial | null chainGroup: ChainGroup + family: IpFamily + onSave: (payload: RuleWritePayload) => void + saving?: boolean }) { const isNew = !initialRule?.id - const [form, setForm] = useState(() => ({ - ...defaultForm, - chain: CHAIN_GROUPS.find(g => g.id === chainGroup)?.chains[0] ?? "forward", - ...(initialRule ?? {}), - srcPort: "", - dstPort: initialRule?.port ?? "", - inIface: initialRule?.iface ?? "", - outIface: "", - connState: "", - srcAddrList: initialRule?.src ?? "", - dstAddrList: initialRule?.dst ?? "", - log: false, - logPrefix: "", - })) + const [form, setForm] = useState(defaultForm) + + useEffect(() => { + if (!open) return + setForm({ + ...defaultForm, + chain: initialRule?.chain ?? (CHAIN_GROUPS.find(g => g.id === chainGroup)?.chains[0] ?? "forward"), + action: (initialRule?.action as string) ?? defaultForm.action, + proto: initialRule?.proto ?? defaultForm.proto, + comment: initialRule?.comment ?? "", + enabled: initialRule?.enabled ?? true, + srcAddrList: initialRule?.src && initialRule.src !== "—" ? initialRule.src : "", + dstAddrList: initialRule?.dst && initialRule.dst !== "—" ? initialRule.dst : "", + dstPort: initialRule?.port && initialRule.port !== "—" ? initialRule.port : "", + inIface: initialRule?.iface && initialRule.iface !== "—" ? initialRule.iface : "", + log: initialRule?.log ?? false, + logPrefix: initialRule?.logPrefix ?? "", + }) + }, [open, initialRule, chainGroup]) const set = (k: K, v: RuleForm[K]) => setForm((f) => ({ ...f, [k]: v })) const chainsForGroup = CHAIN_GROUPS.find(g => g.id === chainGroup)?.chains ?? ["forward"] + const tableName = tableOfGroup(chainGroup) ?? "filter" + const familyPath = family === "ip6" ? "/ipv6 firewall" : "/ip firewall" const showNatActions = chainGroup === "nat" const actions = showNatActions ? ["masquerade", "dst-nat", "src-nat", "netmap", "same", "passthrough"] @@ -459,7 +529,7 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: { {isNew ? "Новое правило" : "Редактировать правило"} - /ip firewall {chainGroup === "nat" ? "nat" : chainGroup === "mangle" ? "mangle" : "filter"} · RouterOS 7 + {familyPath} {tableName} · {IP_FAMILY_LABELS[family]} · RouterOS 7 @@ -568,12 +638,20 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {

RouterOS CLI preview

               {[
-                `/ip firewall filter add \\`,
+                `${familyPath} ${tableName} add \\`,
                 `  chain=${form.chain} \\`,
                 `  action=${form.action}`,
                 form.proto !== "all" ? `  protocol=${form.proto} \\` : null,
-                form.srcAddrList ? `  src-address-list=${form.srcAddrList} \\` : null,
-                form.dstAddrList ? `  dst-address-list=${form.dstAddrList} \\` : null,
+                form.srcAddrList
+                  ? looksLikeAddress(form.srcAddrList)
+                    ? `  src-address=${form.srcAddrList} \\`
+                    : `  src-address-list=${form.srcAddrList} \\`
+                  : null,
+                form.dstAddrList
+                  ? looksLikeAddress(form.dstAddrList)
+                    ? `  dst-address=${form.dstAddrList} \\`
+                    : `  dst-address-list=${form.dstAddrList} \\`
+                  : null,
                 form.dstPort     ? `  dst-port=${form.dstPort} \\`             : null,
                 form.inIface     ? `  in-interface=${form.inIface} \\`         : null,
                 form.outIface    ? `  out-interface=${form.outIface} \\`        : null,
@@ -589,8 +667,12 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
 
         
           }>Отмена
-          
         
       
@@ -630,9 +712,22 @@ function ExportSheet({ open, onClose, rules }: {
 
 // ─── Address List tab ─────────────────────────────────────────────────────────
 
-function AddressListsTab({ entries, onAdd }: {
+function AddressListsTab({
+  entries,
+  onAdd,
+  onAddToList,
+  onToggle,
+  onEdit,
+  onDelete,
+  showServer = false,
+}: {
   entries: AddressListEntry[]
   onAdd: () => void
+  onAddToList: (list: string) => void
+  onToggle: (entry: AddressListEntry) => void
+  onEdit: (entry: AddressListEntry) => void
+  onDelete: (entry: AddressListEntry) => void
+  showServer?: boolean
 }) {
   const [search, setSearch] = useState("")
   const [listFilter, setListFilter] = useState("all")
@@ -642,16 +737,20 @@ function AddressListsTab({ entries, onAdd }: {
     return ["all", ...Array.from(s)]
   }, [entries])
 
+  useEffect(() => {
+    if (listFilter !== "all" && !lists.includes(listFilter)) setListFilter("all")
+  }, [lists, listFilter])
+
   const filtered = useMemo(() => {
     return entries.filter((e) => {
       if (listFilter !== "all" && e.list !== listFilter) return false
       if (!search) return true
       const q = search.toLowerCase()
       return e.address.includes(q) || e.list.includes(q) || e.comment.toLowerCase().includes(q)
+        || (e.serverName ?? "").toLowerCase().includes(q)
     })
   }, [entries, search, listFilter])
 
-  // Group by list name for display
   const grouped = useMemo(() => {
     if (listFilter !== "all") return { [listFilter]: filtered }
     const g: Record = {}
@@ -664,7 +763,6 @@ function AddressListsTab({ entries, onAdd }: {
 
   return (
     
- {/* toolbar */}
@@ -685,7 +783,6 @@ function AddressListsTab({ entries, onAdd }: {
- {/* groups */}
{Object.entries(grouped).map(([listName, listEntries]) => ( @@ -696,7 +793,7 @@ function AddressListsTab({ entries, onAdd }: { {listName} ({listEntries.length})
-
@@ -709,11 +806,15 @@ function AddressListsTab({ entries, onAdd }: { {e.address} {e.comment} + {showServer && e.serverName && ( + {e.serverName} + )} {e.timeout && ( ⏱ {e.timeout} )} + onToggle(e)} /> @@ -721,9 +822,14 @@ function AddressListsTab({ entries, onAdd }: { } /> - Редактировать + onEdit(e)}>Редактировать + onToggle(e)}> + {e.disabled ? "Включить" : "Отключить"} + - Удалить + onDelete(e)}> + Удалить +
@@ -737,6 +843,93 @@ function AddressListsTab({ entries, onAdd }: { ) } +const defaultAddrForm = { + list: "", + address: "", + comment: "", + timeout: "", + enabled: true, +} + +function AddressListSheet({ + open, onClose, initial, family, onSave, saving = false, +}: { + open: boolean + onClose: () => void + initial: Partial | null + family: IpFamily + onSave: (payload: { list: string; address: string; comment?: string; timeout?: string; disabled?: boolean }) => void + saving?: boolean +}) { + const isNew = !initial?.id + const [form, setForm] = useState(defaultAddrForm) + + useEffect(() => { + if (!open) return + setForm({ + list: initial?.list ?? "", + address: initial?.address ?? "", + comment: initial?.comment ?? "", + timeout: initial?.timeout ?? "", + enabled: !(initial?.disabled ?? false), + }) + }, [open, initial]) + + return ( + { if (!v) onClose() }}> + + + {isNew ? "Запись address-list" : "Редактировать запись"} + + {family === "ip6" ? "/ipv6 firewall address-list" : "/ip firewall address-list"} · {IP_FAMILY_LABELS[family]} + + +
+ + setForm((f) => ({ ...f, list: e.target.value }))} /> + + + setForm((f) => ({ ...f, address: e.target.value }))} /> + + + setForm((f) => ({ ...f, comment: e.target.value }))} /> + + + setForm((f) => ({ ...f, timeout: e.target.value }))} /> + +
+
+

Запись включена

+

Отключённые адреса не матчятся

+
+ setForm((f) => ({ ...f, enabled: v }))} /> +
+
+ + }>Отмена + + +
+
+ ) +} + // ─── Verdict helpers ────────────────────────────────────────────────────────── function getVerdictIcon(action: string) { @@ -1588,44 +1781,277 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) { ) } +// ─── Live API ───────────────────────────────────────────────────────────────── + +interface BackendServer { + id: number + name: string + host: string + type: "jump-host" | "exit-node" | "home-router" + site: string + country: string + asn: string + enabled: boolean + status: "online" | "offline" | null + latency: number | null +} + +interface FirewallAllResponse { + rules: FirewallRule[] + addressLists: AddressListEntry[] +} + +function makeApiFetch(backendUrl: string) { + return async function apiFetch(path: string, init?: RequestInit): Promise { + return requestJson(backendUrl, path, init) + } +} + +function mapBackendToServer(s: BackendServer): Server { + return { + id: String(s.id), + name: s.name || s.host, + host: s.host, + model: "—", + os: "—", + site: s.site, + country: s.country || "UN", + asn: s.asn, + type: s.type, + enabled: s.enabled, + status: (s.status ?? "offline") as Server["status"], + latency: s.latency != null ? Math.round(s.latency) : null, + sessions: 0, + } +} + +function ruleFamily(r: Pick): IpFamily { + return r.family === "ip6" ? "ip6" : "ip" +} + +function addressFamily(e: Pick): IpFamily { + return e.family === "ip6" ? "ip6" : "ip" +} + +function ruleKey(r: FirewallRule) { + if (!r.serverId || !r.rosId || !r.table) return null + return { serverId: r.serverId, family: ruleFamily(r), table: r.table, rosId: r.rosId } +} + +function sameRuleGroup(a: FirewallRule, b: FirewallRule): boolean { + return ( + a.serverId === b.serverId + && ruleFamily(a) === ruleFamily(b) + && (a.table ?? "filter") === (b.table ?? "filter") + ) +} + +function moveInArray(arr: T[], from: number, to: number): T[] { + if (from < 0 || to < 0 || from >= arr.length || to >= arr.length) return arr + const copy = [...arr] + const [moved] = copy.splice(from, 1) + if (moved === undefined) return arr + copy.splice(to, 0, moved) + return copy +} + +function reorderWithinGroup(list: FirewallRule[], activeId: string, overId: string): FirewallRule[] | null { + const active = list.find((r) => r.id === activeId) + const over = list.find((r) => r.id === overId) + if (!active || !over || !sameRuleGroup(active, over)) return null + const same = (r: FirewallRule) => sameRuleGroup(r, active) + const group = list.filter(same) + const from = group.findIndex((r) => r.id === activeId) + const to = group.findIndex((r) => r.id === overId) + if (from < 0 || to < 0 || from === to) return null + const nextGroup = moveInArray(group, from, to) + let i = 0 + return list.map((r) => (same(r) ? nextGroup[i++]! : r)) +} + +/** RouterOS move: insert-before destination. Omit dest = конец таблицы. */ +function rosMoveDestination(list: FirewallRule[], activeId: string, overId: string): { dest?: string } | null { + const active = list.find((r) => r.id === activeId) + const over = list.find((r) => r.id === overId) + if (!active || !over || !sameRuleGroup(active, over)) return null + const group = list.filter((r) => sameRuleGroup(r, active)) + const from = group.findIndex((r) => r.id === activeId) + const to = group.findIndex((r) => r.id === overId) + if (from < 0 || to < 0 || from === to) return null + if (from < to) { + const dest = group[to + 1]?.rosId + return dest ? { dest } : {} + } + const dest = group[to]?.rosId + return dest ? { dest } : {} +} + +function addressKey(e: AddressListEntry) { + if (!e.serverId || !e.rosId) return null + return { serverId: e.serverId, family: addressFamily(e), rosId: e.rosId } +} + +function payloadToMockRule( + payload: RuleWritePayload, + opts: { id: string; table: FirewallTable; family: IpFamily; serverId: string; serverName: string; rosId: string }, +): FirewallRule { + return { + id: opts.id, + table: opts.table, + family: opts.family, + serverId: opts.serverId, + serverName: opts.serverName, + rosId: opts.rosId, + chain: payload.chain, + action: payload.action, + proto: payload.protocol ?? "all", + src: payload.srcAddress ?? payload.srcAddressList ?? "—", + dst: payload.dstAddress ?? payload.dstAddressList ?? "—", + port: payload.dstPort ?? payload.srcPort ?? "—", + iface: payload.inInterface ?? payload.outInterface ?? "—", + comment: payload.comment ?? "", + enabled: !payload.disabled, + hits: 0, + log: payload.log, + logPrefix: payload.logPrefix, + } +} + // ════════════════════════════════════════════════════════════════════════════ -export default function FirewallPage() { - const [rules, setRules] = useState(firewallRules) - const [addrLists] = useState(INIT_ADDRESS_LISTS) +function FirewallPageInner() { + const { mode, backendUrl } = useDataSource() + const isLive = mode === "live" + const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) + const searchParams = useSearchParams() + const serverFromQuery = searchParams.get("server") + + const [liveServers, setLiveServers] = useState([]) + const [liveRules, setLiveRules] = useState([]) + const [liveAddrLists, setLiveAddrLists] = useState([]) + const [mockRules, setMockRules] = useState(firewallRules) + const [mockAddrLists, setMockAddrLists] = useState(INIT_ADDRESS_LISTS) + const [dataLoading, setDataLoading] = useState(false) + const [dataError, setDataError] = useState(null) + const [busy, setBusy] = useState(false) + + const [selectedServerId, setSelectedServerId] = useState(serverFromQuery ?? ALL_SERVERS_ID) + const appliedQueryRef = useRef(false) const [chainGroup, setChainGroup] = useState("filter") const [chainFilter, setChainFilter] = useState("all") - const [ipFamily, setIpFamily] = useState("all") + const [ipFamily, setIpFamily] = useState("ip") const [search, setSearch] = useState("") const [editingRule, setEditingRule] = useState | null>(null) const [ruleSheetOpen, setRuleSheetOpen] = useState(false) const [exportOpen, setExportOpen] = useState(false) + const [editingAddr, setEditingAddr] = useState | null>(null) + const [addrSheetOpen, setAddrSheetOpen] = useState(false) - // ── derived ────────────────────────────────────────────────────────────── + const loadLive = useCallback(async () => { + if (!isLive) return + setDataLoading(true) + setDataError(null) + try { + const [backendServers, fw] = await Promise.all([ + apiFetch("/api/servers"), + apiFetch("/api/firewall/all"), + ]) + setLiveServers(backendServers.map(mapBackendToServer)) + setLiveRules(fw.rules) + setLiveAddrLists(fw.addressLists) + } catch (e) { + setDataError(e instanceof Error ? e.message : "Ошибка загрузки") + setLiveServers([]) + setLiveRules([]) + setLiveAddrLists([]) + } finally { + setDataLoading(false) + } + }, [isLive, apiFetch]) + + useEffect(() => { + if (!isLive) { + queueMicrotask(() => { + setLiveServers([]) + setLiveRules([]) + setLiveAddrLists([]) + setDataError(null) + }) + return + } + queueMicrotask(() => { + void loadLive() + }) + }, [isLive, loadLive]) + + useEffect(() => { + if (dataError) toast.error(dataError) + }, [dataError]) + + const displayRules = isLive ? liveRules : mockRules + const displayAddrLists = isLive ? liveAddrLists : mockAddrLists + const displayServers = isLive ? liveServers : mockServers + + useEffect(() => { + if (appliedQueryRef.current || !serverFromQuery) return + if (displayServers.length === 0) return + if (displayServers.some((s) => s.id === serverFromQuery)) { + setSelectedServerId(serverFromQuery) + } + appliedQueryRef.current = true + }, [displayServers, serverFromQuery]) + + const scopedRules = useMemo(() => { + if (selectedServerId === ALL_SERVERS_ID) return displayRules + return displayRules.filter((r) => r.serverId === selectedServerId) + }, [displayRules, selectedServerId]) + + const scopedAddrLists = useMemo(() => { + if (selectedServerId === ALL_SERVERS_ID) return displayAddrLists + return displayAddrLists.filter((e) => e.serverId === selectedServerId) + }, [displayAddrLists, selectedServerId]) + + const familyRules = useMemo( + () => scopedRules.filter((r) => ruleFamily(r) === ipFamily), + [scopedRules, ipFamily], + ) + const familyAddrLists = useMemo( + () => scopedAddrLists.filter((e) => addressFamily(e) === ipFamily), + [scopedAddrLists, ipFamily], + ) + + const firewallRailItems = useMemo(() => ( + displayServers.map((s) => ({ + id: s.id, + name: s.name, + host: s.host, + site: s.site, + country: s.country, + status: s.status, + type: s.type, + enabled: s.enabled, + meta: String(displayRules.filter((r) => r.serverId === s.id).length), + })) + ), [displayServers, displayRules]) + + const writeServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId + const writeServer = displayServers.find((s) => s.id === writeServerId) const chainsInGroup = useMemo(() => { - if (chainGroup === "address-lists") return [] - const grp = CHAIN_GROUPS.find((g) => g.id === chainGroup) - if (!grp) return [] - const v4 = grp.chains - const v6 = grp.ip6chains ?? [] - if (ipFamily === "ip") return v4 - if (ipFamily === "ip6") return v6 - return [...v4, ...v6] - }, [chainGroup, ipFamily]) + if (chainGroup === "address-lists" || chainGroup === "simulator") return [] + return CHAIN_GROUPS.find((g) => g.id === chainGroup)?.chains ?? [] + }, [chainGroup]) const groupRules = useMemo(() => { - if (chainGroup === "address-lists") return [] - return rules.filter((r) => chainsInGroup.includes(r.chain)) - }, [rules, chainGroup, chainsInGroup]) + const table = tableOfGroup(chainGroup) + if (!table) return [] + return familyRules.filter((r) => r.table === table) + }, [familyRules, chainGroup]) const filteredRules = useMemo(() => { return groupRules.filter((r) => { if (chainFilter !== "all" && r.chain !== chainFilter) return false - // ipFamily filtering also respects rules with explicit family field - if (ipFamily === "ip" && r.family === "ip6") return false - if (ipFamily === "ip6" && r.family === "ip") return false if (!search) return true const q = search.toLowerCase() return ( @@ -1633,10 +2059,11 @@ export default function FirewallPage() { r.action.toLowerCase().includes(q) || r.src.toLowerCase().includes(q) || r.dst.toLowerCase().includes(q) || - r.comment.toLowerCase().includes(q) + r.comment.toLowerCase().includes(q) || + (r.serverName ?? "").toLowerCase().includes(q) ) }) - }, [groupRules, chainFilter, ipFamily, search]) + }, [groupRules, chainFilter, search]) const chainCounts = useMemo(() => { const counts: Record = { all: groupRules.length } @@ -1646,19 +2073,101 @@ export default function FirewallPage() { return counts }, [groupRules]) - // ── stats ──────────────────────────────────────────────────────────────── + const totalEnabled = familyRules.filter((r) => r.enabled).length + const totalHits = familyRules.reduce((s, r) => s + r.hits, 0) + const dropRules = familyRules.filter((r) => r.action === "drop" || r.action === "reject").length + const showServerCol = selectedServerId === ALL_SERVERS_ID - const totalEnabled = rules.filter((r) => r.enabled).length - const totalHits = rules.reduce((s, r) => s + r.hits, 0) - const dropRules = rules.filter((r) => r.action === "drop" || r.action === "reject").length + function requireWriteServer(): string | null { + if (writeServerId) return writeServerId + toast.info("Выберите сервер в панели слева") + return null + } - // ── handlers ───────────────────────────────────────────────────────────── + async function toggleRule(id: string) { + const r = displayRules.find((x) => x.id === id) + if (!r) return + if (isLive) { + const key = ruleKey(r) + if (!key) return + try { + await apiFetch("/api/firewall/rules", { + method: "PATCH", + body: JSON.stringify({ ...key, disabled: r.enabled }), + }) + await loadLive() + } catch (e) { + toast.error(e instanceof Error ? e.message : "Не удалось переключить правило") + } + return + } + setMockRules((rs) => rs.map((x) => x.id === id ? { ...x, enabled: !x.enabled } : x)) + } - function toggleRule(id: string) { - setRules((rs) => rs.map((r) => r.id === id ? { ...r, enabled: !r.enabled } : r)) + async function deleteRule(r: FirewallRule) { + if (isLive) { + const key = ruleKey(r) + if (!key) return + try { + await apiFetch("/api/firewall/rules", { method: "DELETE", body: JSON.stringify(key) }) + toast.success("Правило удалено") + await loadLive() + } catch (e) { + toast.error(e instanceof Error ? e.message : "Не удалось удалить правило") + } + return + } + setMockRules((rs) => rs.filter((x) => x.id !== r.id)) + } + + const canReorder = useMemo(() => { + if (filteredRules.length < 2) return false + const servers = new Set(filteredRules.map((r) => r.serverId).filter(Boolean)) + return servers.size === 1 + }, [filteredRules]) + + async function reorderRules(activeId: string, overId: string) { + const source = isLive ? liveRules : mockRules + const next = reorderWithinGroup(source, activeId, overId) + if (!next) { + toast.info("Правила можно переставлять только в пределах одного сервера и таблицы") + return + } + if (!isLive) { + setMockRules(next) + return + } + const active = source.find((r) => r.id === activeId) + const key = active ? ruleKey(active) : null + if (!key) { + toast.error("У правила нет идентификатора RouterOS") + return + } + const dest = rosMoveDestination(source, activeId, overId) + if (!dest) return + const prev = source + setLiveRules(next) + try { + await apiFetch("/api/firewall/rules/move", { + method: "POST", + body: JSON.stringify({ + ...key, + ...(dest.dest ? { destinationRosId: dest.dest } : {}), + }), + }) + await loadLive() + } catch (e) { + setLiveRules(prev) + toast.error(e instanceof Error ? e.message : "Не удалось переместить правило") + } } function openAdd() { + if (!requireWriteServer()) return + const table = tableOfGroup(chainGroup) + if (!table) { + setChainGroup("filter") + } setEditingRule(null) setRuleSheetOpen(true) } @@ -1668,50 +2177,268 @@ export default function FirewallPage() { setRuleSheetOpen(true) } - // ───────────────────────────────────────────────────────────────────────── + async function saveRule(payload: RuleWritePayload) { + const table = (editingRule?.table ?? tableOfGroup(chainGroup) ?? "filter") as FirewallTable + const family = editingRule?.id ? ruleFamily(editingRule as FirewallRule) : ipFamily + const serverId = editingRule?.serverId ?? requireWriteServer() + if (!serverId) return + const serverName = editingRule?.serverName + ?? displayServers.find((s) => s.id === serverId)?.name + ?? writeServer?.name + ?? serverId + + if (isLive) { + setBusy(true) + try { + if (editingRule?.rosId) { + await apiFetch("/api/firewall/rules", { + method: "PUT", + body: JSON.stringify({ serverId, family, table, rosId: editingRule.rosId, ...payload }), + }) + toast.success("Правило сохранено") + } else { + await apiFetch("/api/firewall/rules", { + method: "POST", + body: JSON.stringify({ serverId, family, table, ...payload }), + }) + toast.success("Правило создано") + } + setRuleSheetOpen(false) + await loadLive() + } catch (e) { + toast.error(e instanceof Error ? e.message : "Не удалось сохранить правило") + } finally { + setBusy(false) + } + return + } + + if (editingRule?.id) { + setMockRules((rs) => rs.map((x) => x.id === editingRule.id + ? { ...x, ...payloadToMockRule(payload, { + id: x.id, + table, + family, + serverId, + serverName, + rosId: x.rosId ?? "*0", + }) } + : x)) + } else { + const id = `fw-${Date.now()}` + setMockRules((rs) => [...rs, payloadToMockRule(payload, { + id, + table, + family, + serverId, + serverName, + rosId: `*${Date.now()}`, + })]) + } + setRuleSheetOpen(false) + } + + function openAddAddr(list?: string) { + if (!requireWriteServer()) return + setEditingAddr(list ? { list } : null) + setAddrSheetOpen(true) + } + + function openEditAddr(e: AddressListEntry) { + setEditingAddr(e) + setAddrSheetOpen(true) + } + + async function toggleAddr(e: AddressListEntry) { + if (isLive) { + const key = addressKey(e) + if (!key) return + try { + await apiFetch("/api/firewall/address-lists", { + method: "PATCH", + body: JSON.stringify({ ...key, disabled: !e.disabled }), + }) + await loadLive() + } catch (err) { + toast.error(err instanceof Error ? err.message : "Не удалось переключить запись") + } + return + } + setMockAddrLists((rows) => rows.map((x) => x.id === e.id ? { ...x, disabled: !x.disabled } : x)) + } + + async function deleteAddr(e: AddressListEntry) { + if (isLive) { + const key = addressKey(e) + if (!key) return + try { + await apiFetch("/api/firewall/address-lists", { method: "DELETE", body: JSON.stringify(key) }) + toast.success("Запись удалена") + await loadLive() + } catch (err) { + toast.error(err instanceof Error ? err.message : "Не удалось удалить запись") + } + return + } + setMockAddrLists((rows) => rows.filter((x) => x.id !== e.id)) + } + + async function saveAddr(payload: { + list: string + address: string + comment?: string + timeout?: string + disabled?: boolean + }) { + const family = editingAddr?.id ? addressFamily(editingAddr as AddressListEntry) : ipFamily + const serverId = editingAddr?.serverId ?? requireWriteServer() + if (!serverId) return + const serverName = editingAddr?.serverName + ?? displayServers.find((s) => s.id === serverId)?.name + ?? writeServer?.name + ?? serverId + + if (isLive) { + setBusy(true) + try { + if (editingAddr?.rosId) { + await apiFetch("/api/firewall/address-lists", { + method: "PUT", + body: JSON.stringify({ serverId, family, rosId: editingAddr.rosId, ...payload }), + }) + toast.success("Запись сохранена") + } else { + await apiFetch("/api/firewall/address-lists", { + method: "POST", + body: JSON.stringify({ serverId, family, ...payload }), + }) + toast.success("Запись создана") + } + setAddrSheetOpen(false) + await loadLive() + } catch (err) { + toast.error(err instanceof Error ? err.message : "Не удалось сохранить запись") + } finally { + setBusy(false) + } + return + } + + if (editingAddr?.id) { + setMockAddrLists((rows) => rows.map((x) => x.id === editingAddr.id + ? { ...x, ...payload, family, serverId, serverName } + : x)) + } else { + setMockAddrLists((rows) => [...rows, { + id: `al-${Date.now()}`, + list: payload.list, + address: payload.address, + comment: payload.comment ?? "", + timeout: payload.timeout, + disabled: payload.disabled ?? false, + family, + serverId, + serverName, + rosId: `*${Date.now()}`, + }]) + } + setAddrSheetOpen(false) + } + + const sheetChainGroup: ChainGroup = + chainGroup === "address-lists" || chainGroup === "simulator" ? "filter" : chainGroup return ( -
- - - - - } - /> - -
+ <> + + + + + + + } + /> + } + >
+ {dataError && isLive && ( +
+ {dataError} +
+ )} - {/* Stats */} -
- {[ - { label: "Всего правил", value: rules.length, icon: }, - { label: "Активных", value: totalEnabled, icon: }, - { label: "Блокирующих", value: dropRules, icon: }, - { label: "Срабатываний", value: fmtHits(totalHits), icon: }, - ].map((s) => ( - - - -
-

{s.label}

-

{s.value}

-
-
- - ))} +
+ { setIpFamily(v); setChainFilter("all") }} + options={(["ip", "ip6"] as IpFamily[]).map((f) => ({ + value: f, + label: IP_FAMILY_LABELS[f], + }))} + /> + + {ipFamily === "ip6" ? "/ipv6 firewall" : "/ip firewall"} +
- {/* Table group tabs */} + , + iconClassName: "text-muted-foreground", + }, + { + id: "enabled", + label: "Активных", + value: totalEnabled, + icon: , + iconClassName: "text-success", + }, + { + id: "drop", + label: "Блокирующих", + value: dropRules, + icon: , + iconClassName: "text-destructive", + }, + { + id: "hits", + label: "Срабатываний", + value: fmtHits(totalHits), + icon: , + iconClassName: "text-info", + }, + ]} + /> +
{CHAIN_GROUPS.map((g) => ( ))}
{chainGroup === "address-lists" ? ( - {}} /> + openAddAddr()} + onAddToList={(list) => openAddAddr(list)} + onToggle={(e) => { void toggleAddr(e) }} + onEdit={openEditAddr} + onDelete={(e) => { void deleteAddr(e) }} + showServer={showServerCol} + /> ) : chainGroup === "simulator" ? ( - + ) : ( - ({ - value: f, - label: IP_FAMILY_LABELS[f], - }))} - /> - + { void toggleRule(id) }} + onEdit={openEdit} + onDelete={(r) => { void deleteRule(r) }} + onReorder={canReorder ? (activeId, overId) => { void reorderRules(activeId, overId) } : undefined} + showServer={showServerCol} + /> )} - {/* RouterOS reference */}
{[ @@ -1792,7 +2525,7 @@ export default function FirewallPage() { }, { title: "/ipv6 firewall filter", - lines: ["ip6-input", "ip6-forward", "ip6-output"], + lines: ["input / forward / output", "без префикса ip6-", "семейство — отдельный сегмент"], }, { title: "Новые матчеры 7.x", @@ -1827,22 +2560,40 @@ export default function FirewallPage() {
-
+ - {/* Sheet: Add/Edit Rule */} setRuleSheetOpen(false)} initialRule={editingRule} - chainGroup={chainGroup === "address-lists" ? "filter" : chainGroup} + chainGroup={sheetChainGroup} + family={editingRule?.id ? ruleFamily(editingRule as FirewallRule) : ipFamily} + onSave={(payload) => { void saveRule(payload) }} + saving={busy} + /> + + setAddrSheetOpen(false)} + initial={editingAddr} + family={editingAddr?.id ? addressFamily(editingAddr as AddressListEntry) : ipFamily} + onSave={(payload) => { void saveAddr(payload) }} + saving={busy} /> - {/* Sheet: Export */} setExportOpen(false)} - rules={rules} + rules={familyRules} /> -
+ + ) +} + +export default function FirewallPage() { + return ( + + + ) } diff --git a/app/(main)/gre/page.tsx b/app/(main)/gre/page.tsx index f7bc9a3..d706bbe 100644 --- a/app/(main)/gre/page.tsx +++ b/app/(main)/gre/page.tsx @@ -14,7 +14,7 @@ import { requestJson } from "@/shared/api/http-client" import { cn } from "@/lib/utils" import { toast } from "sonner" import { Frame, FramePanel } from "@/components/reui/frame" -import { IconTile } from "@/components/reui/icon-tile" +import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { OpsPanel } from "@/components/ops-panel" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -26,7 +26,6 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup, } from "@/components/ui/dropdown-menu" -import { Flag } from "@/components/flag" import { PlusIcon, RefreshCwIcon, MoreHorizontalIcon, LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon, @@ -35,6 +34,8 @@ import { DatabaseIcon, } from "lucide-react" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" +import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" +import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail" // ─── label maps ───────────────────────────────────────────────────────────── @@ -241,6 +242,7 @@ export default function GrePage() { const [pageTab, setPageTab] = useState("tunnels") const [tabFilter, setTabFilter] = useState("all") const [search, setSearch] = useState("") + const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID) const [tunnelOpen, setTunnelOpen] = useState(false) const [poolOpen, setPoolOpen] = useState(false) @@ -293,6 +295,25 @@ export default function GrePage() { [isLive, displayTunnels], ) + const greRailItems = useMemo(() => ( + displayServers.map((s) => ({ + id: s.id, + name: s.name, + host: s.host, + site: s.site, + country: s.country, + status: s.status, + type: s.type, + enabled: s.enabled, + meta: String(displayTunnels.filter((t) => t.serverId === s.id).length), + })) + ), [displayServers, displayTunnels]) + + const scopedTunnels = useMemo(() => { + if (selectedServerId === ALL_SERVERS_ID) return displayTunnels + return displayTunnels.filter((t) => t.serverId === selectedServerId) + }, [displayTunnels, selectedServerId]) + const serverById = useMemo( () => Object.fromEntries(displayServers.map((s) => [s.id, s])), [displayServers], @@ -341,7 +362,7 @@ export default function GrePage() { }, [dataError]) const filtered = useMemo(() => { - return displayTunnels.filter((t) => { + return scopedTunnels.filter((t) => { if (tabFilter === "up" && t.status !== "up") return false if (tabFilter === "ipsec" && !t.ipsec) return false if (tabFilter === "plain" && t.ipsec) return false @@ -354,16 +375,18 @@ export default function GrePage() { serverById[t.serverId]?.name.toLowerCase().includes(q) ) }) - }, [tabFilter, search, displayTunnels, serverById]) + }, [tabFilter, search, scopedTunnels, serverById]) const upCount = displayTunnels.filter((t) => t.status === "up").length const ipsecCount = displayTunnels.filter((t) => t.ipsec).length + const scopedUpCount = scopedTunnels.filter((t) => t.status === "up").length + const scopedIpsecCount = scopedTunnels.filter((t) => t.ipsec).length const tunnelTabs: { value: TabFilter; label: string; count: number }[] = [ - { value: "all", label: "Все", count: displayTunnels.length }, - { value: "up", label: "Активные", count: upCount }, - { value: "ipsec", label: "С IPsec", count: ipsecCount }, - { value: "plain", label: "Без IPsec", count: displayTunnels.length - ipsecCount }, + { value: "all", label: "Все", count: scopedTunnels.length }, + { value: "up", label: "Активные", count: scopedUpCount }, + { value: "ipsec", label: "С IPsec", count: scopedIpsecCount }, + { value: "plain", label: "Без IPsec", count: scopedTunnels.length - scopedIpsecCount }, ] const greExportCode = useMemo( @@ -372,39 +395,48 @@ export default function GrePage() { ) return ( -
- - - - - - } - /> - -
+ <> + + + + + + + } + /> + } + >
{/* Legacy banner */} @@ -421,27 +453,39 @@ export default function GrePage() {
- {/* Stats */} -
- {[ - { label: "Всего туннелей", value: displayTunnels.length, icon: }, - { label: "Активно", value: upCount, icon: }, - { label: "Защищены IPsec", value: ipsecCount, icon: }, - { label: "IP-пулов", value: displayPools.length, icon: }, - ].map((s) => ( - - - -
-

{s.label}

-

{s.value}

-
-
- - ))} -
+ , + iconClassName: "text-muted-foreground", + }, + { + id: "up", + label: "Активно", + value: upCount, + icon: , + iconClassName: "text-success", + }, + { + id: "ipsec", + label: "Защищены IPsec", + value: ipsecCount, + icon: , + iconClassName: "text-info", + }, + { + id: "pools", + label: "IP-пулов", + value: displayPools.length, + icon: , + iconClassName: "text-primary", + }, + ]} + /> {/* Page tabs */}
@@ -545,7 +589,7 @@ export default function GrePage() {
-
+ {/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */} -
+ ) } diff --git a/app/(main)/ospf/page.tsx b/app/(main)/ospf/page.tsx index a06f5f5..3ce15c2 100644 --- a/app/(main)/ospf/page.tsx +++ b/app/(main)/ospf/page.tsx @@ -7,17 +7,20 @@ import { OspfNeighborsDataGrid } from "@/components/data-grids/ospf-neighbors-da import { OspfRoutesDataGrid, routeTypeClass } from "@/components/data-grids/ospf-routes-data-grid" import { OspfBfdDataGrid } from "@/components/data-grids/ospf-bfd-data-grid" import { Frame, FramePanel } from "@/components/reui/frame" +import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator" import { toast } from "sonner" import { RefreshCwIcon, WandSparklesIcon, SaveIcon, GripVerticalIcon, NetworkIcon, RouteIcon, ShieldIcon, ActivityIcon, XIcon, + LayersIcon, RouterIcon, UsersIcon, CheckCircleIcon, AlertCircleIcon, } from "lucide-react" import { cn } from "@/lib/utils" import { useDataSource } from "@/lib/data-source" import { requestJson } from "@/shared/api/http-client" -import { Flag } from "@/components/flag" +import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" +import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail" import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data" // ─── types ──────────────────────────────────────────────────────────────────── @@ -707,7 +710,7 @@ function InterfacesTab({ }, [grouped, isLive]) const needsOptimize = !isLive && items.some(item => hints[item.key] && hints[item.key].optimalCost !== item.cost) - const canOptimizeLive = isLive && filterServerId !== "all" + const canOptimizeLive = isLive && filterServerId !== ALL_SERVERS_ID const uniqueLiveFallbackOpt = useMemo(() => { const out: Record = {} const byRouter: Record = {} @@ -921,20 +924,33 @@ function NeighborsTab({ return (
-
- {[ - { label: "Всего соседей", value: neighbors.length, color: "" }, - { label: "Full", value: fullCount, color: "text-[var(--status-online-fg)]" }, - { label: "Не Full", value: neighbors.length - fullCount, color: neighbors.length - fullCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" }, - ].map(s => ( - - -

{s.label}

-

{s.value}

-
- - ))} -
+ , + iconClassName: "text-muted-foreground", + }, + { + id: "full", + label: "Full", + value: fullCount, + icon: , + iconClassName: "text-success", + }, + { + id: "not-full", + label: "Не Full", + value: neighbors.length - fullCount, + icon: , + iconClassName: neighbors.length - fullCount > 0 ? "text-warning" : "text-muted-foreground", + variant: neighbors.length - fullCount > 0 ? "warning" : "default", + }, + ]} + /> {graphNodes.length > 0 && (
@@ -1044,21 +1060,41 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) { return (
-
- {[ - { label: "Сессий BFD", value: sessions.length, color: "" }, - { label: "Up", value: upCount, color: "text-[var(--status-online-fg)]" }, - { label: "Down / Admin", value: downCount, color: downCount > 0 ? "text-[var(--status-offline-fg)]" : "text-muted-foreground" }, - { label: "Init / другие", value: initCount, color: initCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" }, - ].map(s => ( - - -

{s.label}

-

{s.value}

-
- - ))} -
+ , + iconClassName: "text-muted-foreground", + }, + { + id: "up", + label: "Up", + value: upCount, + icon: , + iconClassName: "text-success", + }, + { + id: "down", + label: "Down / Admin", + value: downCount, + icon: , + iconClassName: downCount > 0 ? "text-destructive" : "text-muted-foreground", + variant: downCount > 0 ? "destructive" : "default", + }, + { + id: "init", + label: "Init / другие", + value: initCount, + icon: , + iconClassName: initCount > 0 ? "text-warning" : "text-muted-foreground", + variant: initCount > 0 ? "warning" : "default", + }, + ]} + /> {sessions.length === 0 && (
@@ -1096,7 +1132,7 @@ const TABS: Array<{ id: OspfTab; label: string; icon: React.ReactNode }> = [ export default function OspfPage() { const [activeTab, setActiveTab] = useState("interfaces") - const [filterServerId, setFilterServerId] = useState("all") + const [filterServerId, setFilterServerId] = useState(ALL_SERVERS_ID) const { mode, backendUrl } = useDataSource() const isLive = mode === "live" @@ -1218,9 +1254,26 @@ export default function OspfPage() { }, [items, neighbors, bfdSessions]) // ── filtered display data ───────────────────────────────────────────────────── - const displayItems = filterServerId === "all" ? items : items.filter(i => i.routerKey === filterServerId) - const displayNeighbors = filterServerId === "all" ? neighbors : neighbors.filter(n => n.localRouter === filterServerId) - const displayBfdSessions = filterServerId === "all" ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId) + const displayItems = filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId) + const displayNeighbors = filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId) + const displayBfdSessions = filterServerId === ALL_SERVERS_ID ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId) + + const ospfRailItems = useMemo(() => ( + ospfServers.map((s) => { + const counts = serverCounts[s.id] + const host = s.label.replace(/^mt-/, "") + return { + id: s.id, + name: host, + host, + site: s.site, + country: s.country || undefined, + meta: counts + ? `${counts.neighbors}n · ${counts.ifaces}i${counts.bfd > 0 ? ` · ${counts.bfd}b` : ""}` + : undefined, + } + }) + ), [ospfServers, serverCounts]) // Graph always shows full topology (highlight is handled by node click inside tab) // KPIs reflect the current filter @@ -1229,91 +1282,45 @@ export default function OspfPage() { const totalAreas = new Set(displayItems.map(i => i.area)).size return ( -
- setFetchTick(t => t + 1)}> - - Обновить - - } - /> - -
-
- {TABS.map(t => ( - - ))} -
-
- - {/* ── server filter chips (same pattern as Filters page) ─────────────── */} - {ospfServers.length > 0 && ( -
- {/* "All" chip */} - - -
- - {ospfServers.map(s => { - const counts = serverCounts[s.id] - const active = filterServerId === s.id - return ( - + + } + /> + } + banner={ +
+
+ {TABS.map(t => ( + - ) - })} + ))} +
- )} - -
+ } + >
{/* data source banner */} @@ -1346,21 +1353,32 @@ export default function OspfPage() { )} - {/* KPI strip */} -
- {[ - { label: "Роутеров", value: totalRouters }, - { label: "Интерфейсов", value: totalInterfaces }, - { label: "Зон (Area)", value: totalAreas }, - ].map(s => ( - - -

{s.label}

-

{s.value}

-
- - ))} -
+ , + iconClassName: "text-muted-foreground", + }, + { + id: "ifaces", + label: "Интерфейсов", + value: totalInterfaces, + icon: , + iconClassName: "text-info", + }, + { + id: "areas", + label: "Зон (Area)", + value: totalAreas, + icon: , + iconClassName: "text-primary", + }, + ]} + /> {activeTab === "interfaces" && ( }
-
-
+ ) } diff --git a/app/(main)/probes/page.tsx b/app/(main)/probes/page.tsx index d80f7e8..bbdbf70 100644 --- a/app/(main)/probes/page.tsx +++ b/app/(main)/probes/page.tsx @@ -17,6 +17,8 @@ import { } from "@/components/data-grids/probes-speed-probes-data-grid" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" +import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" +import { type ServerTileItem } from "@/components/server-tile-rail" import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data" import { useDataSource } from "@/lib/data-source" import { @@ -480,19 +482,25 @@ function ScheduleTab({ setRules, serverOptions, tunnelsForServer, + defaultSrc, }: { rules: SchedRule[] setRules: React.Dispatch> serverOptions: Server[] tunnelsForServer: (serverId: string) => GreTunnel[] + defaultSrc?: string }) { const [showAdd, setShowAdd] = useState(false) - const [addSrc, setAddSrc] = useState(serverOptions[0]?.id ?? "srv1") + const [addSrc, setAddSrc] = useState(defaultSrc ?? serverOptions[0]?.id ?? "srv1") const [addTun, setAddTun] = useState("") const [addType, setAddType] = useState("ping") const [addMin, setAddMin] = useState(10) const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer]) + useEffect(() => { + if (defaultSrc) setAddSrc(defaultSrc) + }, [defaultSrc]) + useEffect(() => { const list = tunnelsForServer(addSrc) if (list.length && !list.some(t => t.id === addTun)) { @@ -630,6 +638,20 @@ export default function ProbesPage() { return liveServers }, [isLive, liveServers]) + const probeRailItems = useMemo(() => ( + allServers.map((s) => ({ + id: s.id, + name: s.name, + host: s.host, + site: s.site, + country: s.country, + status: s.status, + type: s.type, + enabled: s.enabled, + selectable: s.enabled, + })) + ), [allServers]) + useEffect(() => { if (!isLive) { setRosSrcV4(undefined) @@ -887,24 +909,33 @@ export default function ProbesPage() { } return ( -
- 0 - ? - : undefined - } - /> - -
+ + + {running.length > 0 + ? + : null} + + } + /> + } + >
{isLive && liveLoad === "error" && ( @@ -916,7 +947,7 @@ export default function ProbesPage() { {isLive && (

- Режим «Живые»: ping/traceroute/route/mtu/bandwidth выполняются на выбранном MikroTik; для «Источника» поле src-address — только IPv4 (FQDN резолвится на бекенде). Traceroute через REST: у MikroTik лимит сессии ~60 с (параметры команды это не продлевают); у нас timeout в формате HH:MM:SS, count=1, max-hops при необходимости уменьшается автоматически. «Стоп» прерывает HTTP к бекенду. DNS — резолвер приложения, не MikroTik. + Режим «Живые»: ping/traceroute/route/mtu/bandwidth выполняются на выбранном MikroTik; для источника поле src-address — только IPv4 (FQDN резолвится на бекенде). Traceroute через REST: у MikroTik лимит сессии ~60 с (параметры команды это не продлевают); у нас timeout в формате HH:MM:SS, count=1, max-hops при необходимости уменьшается автоматически. «Стоп» прерывает HTTP к бекенду. DNS — резолвер приложения, не MikroTik.

)} @@ -945,16 +976,6 @@ export default function ProbesPage() { {/* main config row */}
- {/* source server */} -
- Источник - - {allServers.filter(s => s.enabled).map(s => ( - - ))} - -
- {/* target — all tools except bandwidth */} {tool !== "bandwidth" && (
@@ -1169,13 +1190,13 @@ export default function ProbesPage() { setRules={setRules} serverOptions={allServers} tunnelsForServer={sid => greTunnels.filter(t => t.serverId === sid)} + defaultSrc={srcId} /> ) )}
-
-
+ ) } diff --git a/app/(main)/recursive-routes/page.tsx b/app/(main)/recursive-routes/page.tsx index c6c98c9..8550cd5 100644 --- a/app/(main)/recursive-routes/page.tsx +++ b/app/(main)/recursive-routes/page.tsx @@ -20,6 +20,8 @@ import { cn } from "@/lib/utils" import { servers as mockServers, type Server } from "@/lib/data" import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon } from "lucide-react" import { requestJson } from "@/shared/api/http-client" +import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" +import { type ServerTileItem } from "@/components/server-tile-rail" interface BackendServer { id: number @@ -543,6 +545,18 @@ export default function RecursiveRoutesPage() { } const currentServer = servers.find(s => s.id === selectedServerId) + const rrRailItems = useMemo(() => ( + servers.map((s) => ({ + id: s.id, + name: s.name, + host: s.host, + site: s.site, + country: s.country, + status: s.status, + type: s.type, + enabled: s.enabled, + })) + ), [servers]) const filteredRows = useMemo(() => { const q = search.trim().toLowerCase() if (!q) return rows @@ -574,82 +588,64 @@ export default function RecursiveRoutesPage() { }, [filteredRows]) return ( -
- - - - - - - } - /> - -
-
- Всего маршрутов - {rows.length} -
-
- {servers.map((s) => { - const count = s.id === selectedServerId ? rows.length : 0 - const active = selectedServerId === s.id - return ( - - ) - })} -
- -
-
- - setSearch(e.target.value)} /> - {search && ( - + <> + + + + + + + + } + /> + } + banner={ +
+
+ + setSearch(e.target.value)} /> + {search && ( + + )} +
+
+ Всего маршрутов + {rows.length} +
+

+ {filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`} +

+ {opError && ( +
+ {opError} +
)}
-

- {filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`} -

- {opError && ( -
- {opError} -
- )} -
- -
+ } + > {!isLive ? ( @@ -692,7 +688,7 @@ export default function RecursiveRoutesPage() { )} -
+ setSheetOpen(false)} gateways={gatewayOptions} /> -
+ ) } diff --git a/app/(main)/route-optimizer/page.tsx b/app/(main)/route-optimizer/page.tsx index 4b80d10..c0fc51c 100644 --- a/app/(main)/route-optimizer/page.tsx +++ b/app/(main)/route-optimizer/page.tsx @@ -10,7 +10,7 @@ import { RouteOptimizerCommRecsDataGrid } from "@/components/data-grids/route-op import { RouteOptimizerOspfPreviewDataGrid } from "@/components/data-grids/route-optimizer-ospf-preview-data-grid" import { FormToggle } from "@/components/form-kit" import { Frame, FramePanel } from "@/components/reui/frame" -import { IconTile } from "@/components/reui/icon-tile" +import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { OpsPanel } from "@/components/ops-panel" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -589,32 +589,25 @@ export default function RouteOptimizerPage() { label: "Home роутеров", value: homeCount, sub: `${wanCount} WAN-аплинков`, - icon: , + icon: , }, { label: "JumpHost", value: jh.length, sub: jhSub, - icon: , + icon: , }, { label: "Exit Node", value: ex.length, sub: exSub, - icon: , + icon: , }, { label: "Переключений", value: totalSwitches, sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально", - icon: ( - 0 ? "text-amber-500" : "text-muted-foreground", - )} - /> - ), + icon: , }, ] }, [useLiveData, data, liveJumpHosts, liveExitNodes, totalSwitches]) @@ -762,23 +755,24 @@ export default function RouteOptimizerPage() { )}
- {/* Stats chips */} -
- {statsChips.map((s) => ( - - - -
-

{s.label}

-

{s.value}

-

{s.sub}

-
-
- - ))} -
+ ({ + id: `ro-${i}`, + label: s.label, + value: s.value, + hint: s.sub, + icon: s.icon, + iconClassName: s.label === "Переключений" && totalSwitches > 0 + ? "text-warning" + : s.label === "Exit Node" + ? "text-success" + : s.label === "JumpHost" + ? "text-primary" + : "text-muted-foreground", + variant: s.label === "Переключений" && totalSwitches > 0 ? "warning" as const : "default" as const, + }))} + /> {error && (
diff --git a/app/(main)/servers/page.tsx b/app/(main)/servers/page.tsx index 40f8fe0..c1bfe75 100644 --- a/app/(main)/servers/page.tsx +++ b/app/(main)/servers/page.tsx @@ -27,8 +27,7 @@ import { import { useDataSource } from "@/lib/data-source" import { Flag } from "@/components/flag" import { cn } from "@/lib/utils" -import { Frame, FramePanel } from "@/components/reui/frame" -import { IconTile } from "@/components/reui/icon-tile" +import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { DataPageCard } from "@/components/data-page-card" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -491,27 +490,39 @@ export default function ServersPage() {
- {/* Stats */} -
- {[ - { label: "Всего серверов", value: counts.all, icon: , iconClass: "text-muted-foreground" }, - { label: "Онлайн", value: counts.online, icon: , iconClass: "text-[var(--status-online-fg)]" }, - { label: "JH + Exit Node", value: counts["jump-host"] + counts["exit-node"], icon: , iconClass: "text-muted-foreground" }, - { label: "Home Router", value: counts["home-router"],icon: , iconClass: "text-muted-foreground" }, - ].map(s => ( - - - -
-

{s.label}

-

{s.value}

-
-
- - ))} -
+ , + iconClassName: "text-muted-foreground", + }, + { + id: "online", + label: "Онлайн", + value: counts.online, + icon: , + iconClassName: "text-success", + }, + { + id: "jh-en", + label: "JH + Exit Node", + value: counts["jump-host"] + counts["exit-node"], + icon: , + iconClassName: "text-muted-foreground", + }, + { + id: "home", + label: "Home Router", + value: counts["home-router"], + icon: , + iconClassName: "text-muted-foreground", + }, + ]} + /> {/* Table */} diff --git a/app/(main)/terminal/page.tsx b/app/(main)/terminal/page.tsx index ef0a43b..b350b23 100644 --- a/app/(main)/terminal/page.tsx +++ b/app/(main)/terminal/page.tsx @@ -16,18 +16,12 @@ import { FrameTitle, } from "@/components/reui/frame" import { ScrollArea } from "@/components/ui/scroll-area" +import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { - ServerTileRail, type ServerTileItem, } from "@/components/server-tile-rail" import { - TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon, ServerIcon, + TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon, } from "lucide-react" import { cn } from "@/lib/utils" @@ -456,7 +450,6 @@ export default function TerminalPage() { const [liveServers, setLiveServers] = useState([]) const [serversLoading, setServersLoading] = useState(false) const [refreshKey, setRefreshKey] = useState(0) // force terminal remount on reconnect - const [railOpen, setRailOpen] = useState(false) // Load servers from backend when in live mode useEffect(() => { @@ -516,6 +509,7 @@ export default function TerminalPage() { return termServers.map((s) => ({ id: s.uid, name: s.name, + host: s.host, country: s.country || undefined, status: (s.status ?? undefined) as ServerStatus | undefined, enabled: s.enabled, @@ -524,31 +518,15 @@ export default function TerminalPage() { })) }, [termServers]) - function handleSelectServer(id: string) { + const handleSelectServer = useCallback((id: string) => { setSelectedUid(id) setRefreshKey((k) => k + 1) - setRailOpen(false) - } + }, []) - const railHeaderRight = isLive - ? serversLoading - ? - : LIVE + const railHeaderRight = isLive && !serversLoading + ? LIVE : undefined - const rail = ( - - ) - function QuickCmds() { return ( @@ -595,42 +573,39 @@ export default function TerminalPage() { } return ( -
- - - - - } - /> - -
- - -
+ } + contentClassName="overflow-hidden p-3 md:p-4" + header={ + + + + + } + /> + } + > {selected ? ( )} -
-
- - - - - Серверы - -
- - -
-
-
-
+ ) } diff --git a/app/(main)/traffic/page.tsx b/app/(main)/traffic/page.tsx index 91b8f78..55a7767 100644 --- a/app/(main)/traffic/page.tsx +++ b/app/(main)/traffic/page.tsx @@ -3,7 +3,8 @@ import { useState, useMemo, useEffect, useCallback } from "react" import { PageHeader } from "@/components/page-header" import { Frame, FramePanel } from "@/components/reui/frame" -import { IconTile } from "@/components/reui/icon-tile" +import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" +import { TrafficRxTxChart } from "@/components/reui-kit/traffic-rx-tx-chart" import { Button } from "@/components/ui/button" import { Sparkline } from "@/components/sparkline" import { StatusDot } from "@/components/status-dot" @@ -13,7 +14,9 @@ import { ArrowUpIcon, ArrowDownIcon, ActivityIcon, UsersIcon, CableIcon, ServerIcon, SearchIcon, } from "lucide-react" import { cn } from "@/lib/utils" +import { fmtGB, fmtRate } from "@/lib/fmt-rate" import { useDataSource } from "@/lib/data-source" +import { useTrafficLive } from "@/hooks/use-traffic-live" import { requestJson } from "@/shared/api/http-client" // ─── helpers ────────────────────────────────────────────────────────────────── @@ -32,16 +35,6 @@ function addSeries(a: number[], b: number[]): number[] { return a.map((v, i) => v + (b[i] ?? 0)) } -function fmtMbps(v: number) { - if (v >= 1000) return `${(v / 1000).toFixed(2)} Гбит/с` - return `${v} Мбит/с` -} - -function fmtGB(v: number) { - if (v >= 1000) return `${(v / 1000).toFixed(2)} ТБ` - return `${v.toFixed(1)} ГБ` -} - // ─── data model ─────────────────────────────────────────────────────────────── interface GreClientTraffic { @@ -318,63 +311,10 @@ function MiniAreaChart({ rx, tx, height = 44 }: { rx: number[]; tx: number[]; he const line = (arr: number[]) => arr.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ") return ( - - - - - - ) -} - -function BigChart({ rx, tx }: { rx: number[]; tx: number[] }) { - const W = 900, H = 220 - const pad = { l: 56, r: 16, t: 14, b: 32 } - const iw = W - pad.l - pad.r - const ih = H - pad.t - pad.b - const maxVal = Math.max(...rx, ...tx, 1) * 1.15 - const xAt = (i: number, n: number) => pad.l + (i / (n - 1)) * iw - const yAt = (v: number) => pad.t + (1 - v / maxVal) * ih - const area = (arr: number[]) => { - const pts = arr.map((v, i) => `${xAt(i, arr.length).toFixed(1)},${yAt(v).toFixed(1)}`).join(" L ") - return `M ${pad.l},${pad.t + ih} L ${pts} L ${pad.l + iw},${pad.t + ih} Z` - } - const poly = (arr: number[]) => arr.map((v, i) => `${xAt(i, arr.length).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ") - const gridVals = [0, 0.25, 0.5, 0.75, 1] - return ( - - - - - - - - - - - - {gridVals.map((p, i) => { - const y = pad.t + ih * (1 - p) - return ( - - - - {fmtMbps(Math.round(maxVal * p))} - - - ) - })} - - - - - {[0, 12, 24, 36, 48, 59].map(i => ( - - -{60 - i}м - - ))} + + + + ) } @@ -401,11 +341,11 @@ function ServerCard({ s, selected, onClick }: { s: ServerTraffic; selected: bool
- {fmtMbps(s.rxNow)} + {fmtRate(s.rxNow)}
- {fmtMbps(s.txNow)} + {fmtRate(s.txNow)}
{s.greClients.length > 0 && (
@@ -445,11 +385,11 @@ function UserCard({ u, selected, onClick }: { u: UserTraffic; selected: boolean;
- {fmtMbps(u.rxNow)} + {fmtRate(u.rxNow)}
- {fmtMbps(u.txNow)} + {fmtRate(u.txNow)}
@@ -483,11 +423,11 @@ function GreCard({ c, selected, onClick }: { c: GreClientTraffic; selected: bool
- {fmtMbps(c.rxNow)} + {fmtRate(c.rxNow)}
- {fmtMbps(c.txNow)} + {fmtRate(c.txNow)}
@@ -525,10 +465,10 @@ function GreClientRow({ c, showServer = false }: { c: GreClientTraffic; showServ {/* live RX / TX */}
- {fmtMbps(c.rxNow)} + {fmtRate(c.rxNow)}
- {fmtMbps(c.txNow)} + {fmtRate(c.txNow)}
@@ -581,19 +521,6 @@ function DetailHeader({ range, setRange, children }: { ) } -function ChartLegend() { - return ( -
- - RX (входящий) - - - TX (исходящий) - -
- ) -} - function OfflinePlaceholder({ text = "Нет данных — объект недоступен" }: { text?: string }) { return (
@@ -611,18 +538,18 @@ function TotalsRow({ rxTotal, txTotal, rxSeries, txSeries }: { return (
-

Получено сегодня

+

Получено за период

{rxTotal.toFixed(1)} ГБ

- +
-

Отправлено сегодня

+

Отправлено за период

{txTotal.toFixed(1)} ГБ

- +
) @@ -630,7 +557,18 @@ function TotalsRow({ rxTotal, txTotal, rxSeries, txSeries }: { // ─── detail panels ──────────────────────────────────────────────────────────── -function ServerDetail({ sel, range, setRange }: { sel: ServerTraffic; range: Range; setRange: (r: Range) => void }) { +function ServerDetail({ + sel, range, setRange, liveRx, liveTx, liveHint, +}: { + sel: ServerTraffic + range: Range + setRange: (r: Range) => void + liveRx?: number + liveTx?: number + liveHint?: string +}) { + const rxNow = liveRx ?? sel.rxNow + const txNow = liveTx ?? sel.txNow return ( <> @@ -640,13 +578,43 @@ function ServerDetail({ sel, range, setRange }: { sel: ServerTraffic; range: Ran {sel.site} - - {sel.status === "offline" ? : } -
- } label="RX сейчас" value={fmtMbps(sel.rxNow)} /> - } label="TX сейчас" value={fmtMbps(sel.txNow)} /> - } label="Пик RX" value={fmtMbps(sel.rxPeak)} /> - } label="Сессий" value={`${sel.sessions}`} /> + {sel.status === "offline" ? : } +
+ , + iconClassName: "text-success", + }, + { + id: "tx-now", + label: "TX сейчас", + value: fmtRate(txNow), + hint: liveHint, + icon: , + iconClassName: "text-info", + }, + { + id: "rx-peak", + label: "Пик RX", + value: fmtRate(sel.rxPeak), + icon: , + iconClassName: "text-warning", + }, + { + id: "tx-peak", + label: "Пик TX", + value: fmtRate(sel.txPeak), + icon: , + iconClassName: "text-warning", + }, + ]} + />
{sel.greClients.length > 0 && ( @@ -680,12 +648,11 @@ function UserDetail({ sel, range, setRange }: { sel: UserTraffic; range: Range; ) : ( <> - - +
- } label="RX сейчас" value={fmtMbps(sel.rxNow)} /> - } label="TX сейчас" value={fmtMbps(sel.txNow)} /> - } label="Пик RX" value={fmtMbps(sel.rxPeak)} /> + } label="RX сейчас" value={fmtRate(sel.rxNow)} /> + } label="TX сейчас" value={fmtRate(sel.txNow)} /> + } label="Пик RX" value={fmtRate(sel.rxPeak)} /> } label="GRE-клиентов" value={`${sel.greClients.length}`} />
@@ -716,13 +683,12 @@ function GreDetail({ sel, range, setRange }: { sel: GreClientTraffic; range: Ran {sel.serverSite} - - {sel.status === "offline" ? : } + {sel.status === "offline" ? : }
- } label="RX сейчас" value={fmtMbps(sel.rxNow)} /> - } label="TX сейчас" value={fmtMbps(sel.txNow)} /> - } label="Пик RX" value={fmtMbps(sel.rxPeak)} /> - } label="Пик TX" value={fmtMbps(sel.txPeak)} /> + } label="RX сейчас" value={fmtRate(sel.rxNow)} /> + } label="TX сейчас" value={fmtRate(sel.txNow)} /> + } label="Пик RX" value={fmtRate(sel.rxPeak)} /> + } label="Пик TX" value={fmtRate(sel.txPeak)} />
@@ -778,6 +744,12 @@ export default function TrafficPage() { const [detailBusy, setDetailBusy] = useState(false) const [liveDetailServer, setLiveDetailServer] = useState(null) const effectiveMode: GroupMode = isLive ? "servers" : groupMode + const { sample: liveSample, error: liveStreamError } = useTrafficLive({ + enabled: isLive && effectiveMode === "servers" && liveServers.some((s) => s.id === selectedId), + backendUrl, + serverId: selectedId, + iface: selectedIface, + }) const toLiveServer = (s: LiveTrafficServer): ServerTraffic => { return { @@ -917,6 +889,7 @@ export default function TrafficPage() { }, [sortField, sortDir, q]) const selServer = useMemo(() => activeServerTraffic.find(s => s.id === selectedId) ?? activeServerTraffic[0], [selectedId, activeServerTraffic]) + const detailServer = liveDetailServer?.id === selectedId ? liveDetailServer : selServer const selUser = useMemo(() => userTraffic.find(u => u.id === selectedId) ?? userTraffic[0], [selectedId]) const selGre = useMemo(() => greClients.find(c => c.id === selectedId) ?? greClients[0], [selectedId]) @@ -965,27 +938,39 @@ export default function TrafficPage() {
- {/* ── summary stat cards ── */} -
- {[ - { icon: , label: "RX сейчас", value: fmtMbps(totalRx) }, - { icon: , label: "TX сейчас", value: fmtMbps(totalTx) }, - { icon: , label: "Пик RX", value: fmtMbps(peakRx) }, - { icon: , label: "Пик TX", value: fmtMbps(peakTx) }, - ].map(({ icon, label, value }) => ( - - - -
-

{label}

-

{value}

-
-
- - ))} -
+ , + iconClassName: "text-success", + }, + { + id: "tx", + label: "TX сейчас", + value: fmtRate(totalTx), + icon: , + iconClassName: "text-info", + }, + { + id: "peak-rx", + label: "Пик RX", + value: fmtRate(peakRx), + icon: , + iconClassName: "text-warning", + }, + { + id: "peak-tx", + label: "Пик TX", + value: fmtRate(peakTx), + icon: , + iconClassName: "text-warning", + }, + ]} + />
@@ -1091,7 +1076,16 @@ export default function TrafficPage() {
)} - {effectiveMode === "servers" && (liveDetailServer ?? selServer) && } + {effectiveMode === "servers" && detailServer && ( + + )} {effectiveMode === "users" && } {effectiveMode === "gre" && } diff --git a/app/(main)/uptime/page.tsx b/app/(main)/uptime/page.tsx index e91fc83..0b95e00 100644 --- a/app/(main)/uptime/page.tsx +++ b/app/(main)/uptime/page.tsx @@ -4,7 +4,7 @@ import { useState, useMemo, useEffect, useRef, useCallback } from "react" import { PageHeader } from "@/components/page-header" import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit" import { Frame, FramePanel } from "@/components/reui/frame" -import { IconTile } from "@/components/reui/icon-tile" +import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" @@ -961,29 +961,58 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe )} - {/* ── KPI summary ───────────────────────────────────────────────────── */} -
- {[ - { icon: , label: String(rows.length), sub: "серверов всего", color: "text-foreground" }, - { icon: , label: `${avgCpu}%`, sub: "средний CPU", color: resPctColor(avgCpu) }, - { icon: , label: `${avgRam}%`, sub: "средний RAM", color: resPctColor(avgRam) }, - { icon: , label: String(highCpu), sub: "CPU > 85%", color: highCpu > 0 ? "text-red-500" : "text-muted-foreground" }, - { icon: , label: String(highRam), sub: "RAM > 85%", color: highRam > 0 ? "text-red-500" : "text-muted-foreground" }, - { icon: , label: String(highHdd), sub: "Диск > 85%", color: highHdd > 0 ? "text-amber-500" : "text-muted-foreground" }, - ].map(kpi => ( - - - -
-

{kpi.label}

-

{kpi.sub}

-
-
- - ))} -
+ , + iconClassName: "text-muted-foreground", + }, + { + id: "cpu", + label: "Средний CPU", + value: `${avgCpu}%`, + icon: , + iconClassName: avgCpu >= 85 ? "text-destructive" : avgCpu >= 70 ? "text-warning" : "text-success", + variant: avgCpu >= 85 ? "destructive" : avgCpu >= 70 ? "warning" : "default", + }, + { + id: "ram", + label: "Средний RAM", + value: `${avgRam}%`, + icon: , + iconClassName: avgRam >= 85 ? "text-destructive" : avgRam >= 70 ? "text-warning" : "text-success", + variant: avgRam >= 85 ? "destructive" : avgRam >= 70 ? "warning" : "default", + }, + { + id: "high-cpu", + label: "CPU > 85%", + value: highCpu, + icon: , + iconClassName: highCpu > 0 ? "text-destructive" : "text-muted-foreground", + variant: highCpu > 0 ? "destructive" : "default", + }, + { + id: "high-ram", + label: "RAM > 85%", + value: highRam, + icon: , + iconClassName: highRam > 0 ? "text-destructive" : "text-muted-foreground", + variant: highRam > 0 ? "destructive" : "default", + }, + { + id: "high-hdd", + label: "Диск > 85%", + value: highHdd, + icon: , + iconClassName: highHdd > 0 ? "text-warning" : "text-muted-foreground", + variant: highHdd > 0 ? "warning" : "default", + }, + ]} + /> {/* ── Table ─────────────────────────────────────────────────────────── */} @@ -2022,28 +2051,42 @@ export default function UptimePage() { const maxTx = doneRuns.length ? Math.max(...doneRuns.map(r => r.txAvgMbps)) : null const maxRx = doneRuns.length ? Math.max(...doneRuns.map(r => r.rxAvgMbps)) : null return ( -
- {[ - { label: "Speed-пробы", value: speedProbes.length, unit: "шт", color: "" }, - { label: "Тестов выполнено", value: doneRuns.length, unit: "run", color: "" }, - { label: "Макс TX", value: maxTx != null ? `${maxTx}` : "—", unit: maxTx != null ? "Мбит/с" : "", color: "text-[var(--chart-tx)]" }, - { label: "Макс RX", value: maxRx != null ? `${maxRx}` : "—", unit: maxRx != null ? "Мбит/с" : "", color: "text-[var(--chart-rx)]" }, - ].map(k => ( - - -

{k.label}

-
- {k.value} - {k.unit && {k.unit}} -
- {runningCnt > 0 && k.label === "Тестов выполнено" && ( -

- {runningCnt} выполняется -

- )} -
- - ))} +
+ , + iconClassName: "text-muted-foreground", + }, + { + id: "runs", + label: "Тестов выполнено", + value: `${doneRuns.length} run`, + hint: runningCnt > 0 ? `${runningCnt} выполняется` : undefined, + icon: , + iconClassName: runningCnt > 0 ? "text-warning" : "text-muted-foreground", + variant: runningCnt > 0 ? "warning" : "default", + }, + { + id: "max-tx", + label: "Макс TX", + value: maxTx != null ? `${maxTx} Мбит/с` : "—", + icon: , + iconClassName: "text-info", + }, + { + id: "max-rx", + label: "Макс RX", + value: maxRx != null ? `${maxRx} Мбит/с` : "—", + icon: , + iconClassName: "text-success", + }, + ]} + />
) })()} diff --git a/app/(main)/vxlan/page.tsx b/app/(main)/vxlan/page.tsx index 4842448..95e5d27 100644 --- a/app/(main)/vxlan/page.tsx +++ b/app/(main)/vxlan/page.tsx @@ -4,14 +4,12 @@ import { useMemo, useState } from "react" import { PageHeader } from "@/components/page-header" import { vxlanTunnels, servers } from "@/lib/data" import type { VxlanTunnel } from "@/lib/data" -import { Frame, FramePanel } from "@/components/reui/frame" -import { IconTile } from "@/components/reui/icon-tile" +import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { OpsPanel } from "@/components/ops-panel" import { DataPageCard } from "@/components/data-page-card" import { DataPageToolbar } from "@/components/data-page-toolbar" import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid" import { Button } from "@/components/ui/button" -import { cn } from "@/lib/utils" import { NetworkIcon, PlusIcon, CodeXmlIcon, LayersIcon, } from "lucide-react" @@ -119,27 +117,39 @@ export default function VxlanPage() {
- {/* KPI */} -
- {[ - { label: "Туннелей", value: vxlanTunnels.length, icon: }, - { label: "Активных", value: upCount, icon: }, - { label: "Уникальных VNI", value: vnis, icon: }, - { label: "Серверов", value: new Set(vxlanTunnels.map((t) => t.serverId)).size, icon: }, - ].map((s) => ( - - - -
-

{s.label}

-

{s.value}

-
-
- - ))} -
+ , + iconClassName: "text-muted-foreground", + }, + { + id: "up", + label: "Активных", + value: upCount, + icon: , + iconClassName: "text-success", + }, + { + id: "vni", + label: "Уникальных VNI", + value: vnis, + icon: , + iconClassName: "text-info", + }, + { + id: "servers", + label: "Серверов", + value: new Set(vxlanTunnels.map((t) => t.serverId)).size, + icon: , + iconClassName: "text-primary", + }, + ]} + /> {/* Info banner */}
diff --git a/app/(main)/wireguard/page.tsx b/app/(main)/wireguard/page.tsx index 74dd8dd..344ead2 100644 --- a/app/(main)/wireguard/page.tsx +++ b/app/(main)/wireguard/page.tsx @@ -23,12 +23,6 @@ import { import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { OpsPanel } from "@/components/ops-panel" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" import { AlertDialog, AlertDialogAction, @@ -57,16 +51,16 @@ import { WgCreateSheet, type WgCreateFormState } from "@/components/wireguard/wg import { WgImportSheet } from "@/components/wireguard/wg-import-sheet" import { WgExportSheet } from "@/components/wireguard/wg-export-sheet" import { WgPeerSheet, type WgPeerFormState } from "@/components/wireguard/wg-peer-sheet" +import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ALL_SERVERS_ID, - ServerTileRail, type ServerTileItem, } from "@/components/server-tile-rail" import { toast } from "sonner" import { ShieldCheckIcon, PlusIcon, KeyRoundIcon, UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon, - ServerIcon, Trash2Icon, CodeXmlIcon, AlertCircleIcon, + Trash2Icon, CodeXmlIcon, AlertCircleIcon, } from "lucide-react" type WgWorkspaceTab = "interfaces" | "peers" | "cli" @@ -186,7 +180,6 @@ export default function WireGuardPage() { const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID) const [workspaceTab, setWorkspaceTab] = useState("interfaces") const [statusFilter, setStatusFilter] = useState("all") - const [railOpen, setRailOpen] = useState(false) const [search, setSearch] = useState("") const [createOpen, setCreateOpen] = useState(false) const [importOpen, setImportOpen] = useState(false) @@ -338,6 +331,8 @@ export default function WireGuardPage() { return displayServers.map((s) => ({ id: s.id, name: s.name, + host: s.host, + site: s.site, country: s.country, status: s.status, type: s.type, @@ -347,16 +342,6 @@ export default function WireGuardPage() { })) }, [displayServers, displayIfaces]) - const selectedLabel = - effectiveServerId === ALL_SERVERS_ID - ? "Все серверы" - : (displayServers.find((s) => s.id === effectiveServerId)?.name ?? "Сервер") - - function handleSelectServer(id: string) { - setSelectedServerId(id) - setRailOpen(false) - } - async function handleCreate(form: WgCreateFormState) { if (!isLive) { toast.info("Создание на роутер доступно только в live-режиме") @@ -543,59 +528,45 @@ export default function WireGuardPage() { ) - const rail = ( - + - ) - - return ( -
- - - {isLive && ( - + )} + - )} - - - - } - /> - -
- - -
+ + + } + /> + } + >
-
-
- - - - - Серверы - -
- -
-
-
+ -
+ ) } diff --git a/backend/package.json b/backend/package.json index 6572c0a..8849a00 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,7 +12,8 @@ "db:migrate": "drizzle-kit migrate", "db:studio": "drizzle-kit studio", "test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts", - "test:wireguard": "npx tsx src/services/wireguard-config.test.ts" + "test:wireguard": "npx tsx src/services/wireguard-config.test.ts", + "test:traffic-rate": "tsx src/services/traffic-rate.test.ts" }, "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/backend/src/index.ts b/backend/src/index.ts index 35684da..b6e87e3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -24,6 +24,7 @@ import certificatesRoutes from "./routes/certificates.js" import systemDatabaseRoutes from "./routes/system-database.js" import eventsRoutes from "./routes/events.js" import wireguardRoutes from "./routes/wireguard.js" +import firewallRoutes from "./routes/firewall.js" import { refreshScheduler, stopScheduler } from "./services/scheduler.js" export async function buildApp(opts?: { @@ -105,6 +106,7 @@ export async function buildApp(opts?: { await app.register(systemDatabaseRoutes, { prefix: "/api" }) await app.register(eventsRoutes, { prefix: "/api" }) await app.register(wireguardRoutes, { prefix: "/api" }) + await app.register(firewallRoutes, { prefix: "/api" }) if (opts?.startScheduler !== false) { refreshScheduler() diff --git a/backend/src/lib/permissions.test.ts b/backend/src/lib/permissions.test.ts index 9715ffd..05e53e0 100644 --- a/backend/src/lib/permissions.test.ts +++ b/backend/src/lib/permissions.test.ts @@ -17,6 +17,10 @@ assert.equal( permissionForRequest("GET", "/api/system/database/backup"), "mm:settings:admin", ) +assert.equal( + permissionForRequest("GET", "/api/traffic/servers/1/live"), + "mm:traffic:read", +) assert.equal( permissionForRequest("GET", "/api/unknown-thing"), "mm:dashboard:read", @@ -29,5 +33,13 @@ assert.equal( permissionForRequest("POST", "/api/wireguard/interfaces"), "mm:network:write", ) +assert.equal( + permissionForRequest("GET", "/api/firewall/all"), + "mm:network:read", +) +assert.equal( + permissionForRequest("POST", "/api/firewall/rules"), + "mm:network:write", +) console.log("permissions.test.ts: ok") diff --git a/backend/src/lib/permissions.ts b/backend/src/lib/permissions.ts index bb53778..b1b47db 100644 --- a/backend/src/lib/permissions.ts +++ b/backend/src/lib/permissions.ts @@ -142,7 +142,8 @@ const RULES: Rule[] = [ p.startsWith("/api/probes") || p.startsWith("/api/internet-path") || p.startsWith("/api/exec") || - p.startsWith("/api/wireguard"), + p.startsWith("/api/wireguard") || + p.startsWith("/api/firewall"), permission: "mm:network:read", }, { @@ -154,7 +155,8 @@ const RULES: Rule[] = [ p.startsWith("/api/probes") || p.startsWith("/api/internet-path") || p.startsWith("/api/exec") || - p.startsWith("/api/wireguard"), + p.startsWith("/api/wireguard") || + p.startsWith("/api/firewall"), permission: "mm:network:write", }, ] diff --git a/backend/src/routes/firewall.ts b/backend/src/routes/firewall.ts new file mode 100644 index 0000000..7a49ba7 --- /dev/null +++ b/backend/src/routes/firewall.ts @@ -0,0 +1,294 @@ +import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" +import { z } from "zod" +import { MikrotikClient, MikrotikError, encodeRosId, firewallRestPath } from "../services/mikrotik.js" +import { getEnabledServerById } from "../services/wireguard-live.js" +import { listFirewallAll } from "../services/firewall-live.js" +import type { FirewallFamily, FirewallTable } from "../types/server.js" + +const FamilySchema = z.enum(["ip", "ip6"]) +const TableSchema = z.enum(["filter", "nat", "mangle", "raw"]) + +const RuleKeySchema = z.object({ + serverId: z.string().min(1), + family: FamilySchema, + table: TableSchema, + rosId: z.string().min(1), +}) + +const RuleWriteSchema = z.object({ + serverId: z.string().min(1), + family: FamilySchema, + table: TableSchema, + rosId: z.string().min(1).optional(), + chain: z.string().min(1), + action: z.string().min(1), + protocol: z.string().optional(), + srcAddress: z.string().optional(), + dstAddress: z.string().optional(), + srcAddressList: z.string().optional(), + dstAddressList: z.string().optional(), + srcPort: z.string().optional(), + dstPort: z.string().optional(), + inInterface: z.string().optional(), + outInterface: z.string().optional(), + connectionState: z.string().optional(), + comment: z.string().optional(), + disabled: z.boolean().optional(), + log: z.boolean().optional(), + logPrefix: z.string().optional(), + tlsHost: z.string().optional(), + layer7Proto: z.string().optional(), +}) + +const RulePatchSchema = RuleKeySchema.extend({ + disabled: z.boolean(), +}) + +const RuleMoveSchema = RuleKeySchema.extend({ + destinationRosId: z.string().min(1).optional(), +}) + +const AddressKeySchema = z.object({ + serverId: z.string().min(1), + family: FamilySchema, + rosId: z.string().min(1), +}) + +const AddressWriteSchema = z.object({ + serverId: z.string().min(1), + family: FamilySchema, + rosId: z.string().min(1).optional(), + list: z.string().min(1), + address: z.string().min(1), + comment: z.string().optional(), + timeout: z.string().optional(), + disabled: z.boolean().optional(), +}) + +const AddressPatchSchema = AddressKeySchema.extend({ + disabled: z.boolean(), +}) + +function toRosBody(obj: Record): Record { + const out: Record = {} + for (const [k, v] of Object.entries(obj)) { + if (v !== undefined && v !== "") out[k] = v + } + return out +} + +function ruleToRos(d: z.infer): Record { + return toRosBody({ + chain: d.chain, + action: d.action, + protocol: d.protocol && d.protocol !== "all" ? d.protocol : undefined, + "src-address": d.srcAddress, + "dst-address": d.dstAddress, + "src-address-list": d.srcAddressList, + "dst-address-list": d.dstAddressList, + "src-port": d.srcPort, + "dst-port": d.dstPort, + "in-interface": d.inInterface, + "out-interface": d.outInterface, + "connection-state": d.connectionState, + comment: d.comment, + disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined, + log: d.log === true ? "yes" : d.log === false ? "no" : undefined, + "log-prefix": d.logPrefix, + "tls-host": d.tlsHost, + "layer7-protocol": d.layer7Proto, + }) +} + +function addressToRos(d: z.infer): Record { + return toRosBody({ + list: d.list, + address: d.address, + comment: d.comment, + timeout: d.timeout, + disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined, + }) +} + +function rosErr(e: unknown): string { + if (e instanceof MikrotikError) return e.message + if (e instanceof Error) return e.message + return String(e) +} + +function requireServer(serverId: string) { + return getEnabledServerById(serverId) +} + +const firewallRoutes: FastifyPluginAsyncZod = async (app) => { + app.get("/firewall/all", async (_req, reply) => { + const data = await listFirewallAll() + return reply.send(data) + }) + + app.post("/firewall/rules", async (req, reply) => { + const parsed = RuleWriteSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + const body = parsed.data + const server = requireServer(body.serverId) + if (!server) return reply.status(404).send({ error: "Сервер не найден" }) + const client = MikrotikClient.fromServer(server) + const path = firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable) + try { + await client.put(path, ruleToRos(body)) + return reply.status(201).send({ ok: true }) + } catch (e) { + return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) + } + }) + + app.put("/firewall/rules", async (req, reply) => { + const parsed = RuleWriteSchema.extend({ rosId: z.string().min(1) }).safeParse(req.body ?? {}) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + const body = parsed.data + const server = requireServer(body.serverId) + if (!server) return reply.status(404).send({ error: "Сервер не найден" }) + const client = MikrotikClient.fromServer(server) + const path = `${firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable)}/${encodeRosId(body.rosId)}` + try { + await client.patch(path, ruleToRos(body)) + return reply.send({ ok: true }) + } catch (e) { + return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) + } + }) + + app.patch("/firewall/rules", async (req, reply) => { + const parsed = RulePatchSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + const body = parsed.data + const server = requireServer(body.serverId) + if (!server) return reply.status(404).send({ error: "Сервер не найден" }) + const client = MikrotikClient.fromServer(server) + const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}` + try { + await client.patch(path, { disabled: body.disabled ? "yes" : "no" }) + return reply.send({ ok: true }) + } catch (e) { + return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) + } + }) + + app.delete("/firewall/rules", async (req, reply) => { + const parsed = RuleKeySchema.safeParse(req.body ?? {}) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + const body = parsed.data + const server = requireServer(body.serverId) + if (!server) return reply.status(404).send({ error: "Сервер не найден" }) + const client = MikrotikClient.fromServer(server) + const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}` + try { + await client.delete(path) + return reply.send({ ok: true }) + } catch (e) { + return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) + } + }) + + app.post("/firewall/rules/move", async (req, reply) => { + const parsed = RuleMoveSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + const body = parsed.data + const server = requireServer(body.serverId) + if (!server) return reply.status(404).send({ error: "Сервер не найден" }) + const client = MikrotikClient.fromServer(server) + const path = `${firewallRestPath(body.family, body.table)}/move` + try { + await client.post(path, { + numbers: body.rosId, + ...(body.destinationRosId ? { destination: body.destinationRosId } : {}), + }) + return reply.send({ ok: true }) + } catch (e) { + return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) + } + }) + + app.post("/firewall/address-lists", async (req, reply) => { + const parsed = AddressWriteSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + const body = parsed.data + const server = requireServer(body.serverId) + if (!server) return reply.status(404).send({ error: "Сервер не найден" }) + const client = MikrotikClient.fromServer(server) + try { + await client.put(firewallRestPath(body.family, "address-list"), addressToRos(body)) + return reply.status(201).send({ ok: true }) + } catch (e) { + return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) + } + }) + + app.put("/firewall/address-lists", async (req, reply) => { + const parsed = AddressWriteSchema.extend({ rosId: z.string().min(1) }).safeParse(req.body ?? {}) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + const body = parsed.data + const server = requireServer(body.serverId) + if (!server) return reply.status(404).send({ error: "Сервер не найден" }) + const client = MikrotikClient.fromServer(server) + const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}` + try { + await client.patch(path, addressToRos(body)) + return reply.send({ ok: true }) + } catch (e) { + return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) + } + }) + + app.patch("/firewall/address-lists", async (req, reply) => { + const parsed = AddressPatchSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + const body = parsed.data + const server = requireServer(body.serverId) + if (!server) return reply.status(404).send({ error: "Сервер не найден" }) + const client = MikrotikClient.fromServer(server) + const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}` + try { + await client.patch(path, { disabled: body.disabled ? "yes" : "no" }) + return reply.send({ ok: true }) + } catch (e) { + return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) + } + }) + + app.delete("/firewall/address-lists", async (req, reply) => { + const parsed = AddressKeySchema.safeParse(req.body ?? {}) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + const body = parsed.data + const server = requireServer(body.serverId) + if (!server) return reply.status(404).send({ error: "Сервер не найден" }) + const client = MikrotikClient.fromServer(server) + const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}` + try { + await client.delete(path) + return reply.send({ ok: true }) + } catch (e) { + return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` }) + } + }) +} + +export default firewallRoutes diff --git a/backend/src/routes/traffic.ts b/backend/src/routes/traffic.ts index d524cc3..03902f7 100644 --- a/backend/src/routes/traffic.ts +++ b/backend/src/routes/traffic.ts @@ -1,3 +1,4 @@ +import { env } from "../config.js" import { desc, eq } from "drizzle-orm" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import { db } from "../db/index.js" @@ -12,6 +13,15 @@ import { import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js" import { refreshScheduler } from "../services/scheduler.js" import { appendEvent } from "../modules/events/service/events-service.js" +import { MikrotikClient } from "../services/mikrotik.js" +import { getEnabledServerById } from "../services/wireguard-live.js" +import { + bpsToMbps, + buildTrafficFromSamples, + isLoopbackName, + parseMonitorTraffic, + rateBpsFromDelta, +} from "../services/traffic-rate.js" type SnapshotRow = typeof serverSnapshots.$inferSelect @@ -40,6 +50,9 @@ interface TrafficInterfaceDto { txNow: number } +const LIVE_TICK_MS = 1500 +const LIVE_ROS_TIMEOUT_MS = 4000 + function latestSnapshot(serverId: number): SnapshotRow | undefined { return db .select() @@ -61,14 +74,6 @@ function rangeToMinutes(range: string | undefined): number { } } -function toSeries(values: number[], target = 60): number[] { - if (values.length === 0) return Array(target).fill(0) - if (values.length === target) return values - if (values.length > target) return values.slice(values.length - target) - const head = Array(target - values.length).fill(values[0] ?? 0) - return [...head, ...values] -} - function buildServerTraffic( s: typeof servers.$inferSelect, status: TrafficServerDto["status"], @@ -82,88 +87,135 @@ function buildServerTraffic( running: boolean disabled: boolean }>, + rangeStartMs: number, + rangeEndMs: number, onlyInterface?: string, ): TrafficServerDto { - const filteredRows = onlyInterface - ? rows.filter((r) => r.interfaceName === onlyInterface) - : rows - - if (filteredRows.length === 0) { - return { - id: String(s.id), - name: s.name || s.host, - site: s.site || "—", - country: s.country || "UN", - status, - rxNow: 0, - txNow: 0, - rxPeak: 0, - txPeak: 0, - rxTotal: 0, - txTotal: 0, - sessions: 0, - rxSeries: Array(60).fill(0), - txSeries: Array(60).fill(0), - } - } - - const bySampleTs = new Map() - const byIface = new Map() - for (const r of filteredRows) { - const ts = r.sampledAt - const cur = bySampleTs.get(ts) ?? { rx: 0, tx: 0 } - cur.rx += Math.max(0, r.rxBps) / 1_000_000 - cur.tx += Math.max(0, r.txBps) / 1_000_000 - bySampleTs.set(ts, cur) - const arr = byIface.get(r.interfaceName) ?? [] - arr.push(r) - byIface.set(r.interfaceName, arr) - } - - const seriesPoints = [...bySampleTs.entries()] - .sort((a, b) => a[0].localeCompare(b[0])) - .map(([, v]) => ({ rx: Math.round(v.rx), tx: Math.round(v.tx) })) - const rxSeries = toSeries(seriesPoints.map((p) => p.rx)) - const txSeries = toSeries(seriesPoints.map((p) => p.tx)) - const rxNow = rxSeries[rxSeries.length - 1] ?? 0 - const txNow = txSeries[txSeries.length - 1] ?? 0 - const rxPeak = rxSeries.reduce((m, v) => Math.max(m, v), 0) - const txPeak = txSeries.reduce((m, v) => Math.max(m, v), 0) - - let rxBytesDelta = 0 - let txBytesDelta = 0 - let sessions = 0 - for (const arr of byIface.values()) { - const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt)) - const first = sorted[0] - const last = sorted[sorted.length - 1] - if (first && last) { - const dRx = last.rxBytes - first.rxBytes - const dTx = last.txBytes - first.txBytes - rxBytesDelta += dRx >= 0 ? dRx : last.rxBytes - txBytesDelta += dTx >= 0 ? dTx : last.txBytes - if (last.running && !last.disabled) sessions += 1 - } - } - + const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, onlyInterface) return { id: String(s.id), name: s.name || s.host, site: s.site || "—", country: s.country || "UN", status, - rxNow, - txNow, - rxPeak, - txPeak, - rxTotal: Number((rxBytesDelta / (1024 ** 3)).toFixed(1)), - txTotal: Number((txBytesDelta / (1024 ** 3)).toFixed(1)), - sessions, - rxSeries, - txSeries, + rxNow: built.rxNow, + txNow: built.txNow, + rxPeak: built.rxPeak, + txPeak: built.txPeak, + rxTotal: built.rxTotalGiB, + txTotal: built.txTotalGiB, + sessions: built.sessions, + rxSeries: built.rxSeries, + txSeries: built.txSeries, } } +function snapshotStatus(serverId: number): TrafficServerDto["status"] { + const snap = latestSnapshot(serverId) + return snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded") +} + +function ifaceNowMbps( + prev: { rxBytes: number; txBytes: number; sampledAt: string } | undefined, + last: { rxBytes: number; txBytes: number; sampledAt: string; rxBps: number; txBps: number }, +): { rxNow: number; txNow: number } { + if (!prev) { + return { rxNow: bpsToMbps(last.rxBps), txNow: bpsToMbps(last.txBps) } + } + const t0 = Date.parse(prev.sampledAt) + const t1 = Date.parse(last.sampledAt) + const rxBps = rateBpsFromDelta(prev.rxBytes, last.rxBytes, t0, t1) + const txBps = rateBpsFromDelta(prev.txBytes, last.txBytes, t0, t1) + return { + rxNow: bpsToMbps(rxBps ?? last.rxBps), + txNow: bpsToMbps(txBps ?? last.txBps), + } +} + +function writeSse(raw: NodeJS.WritableStream, event: string, data: unknown) { + raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) +} + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new Error("aborted")) + return + } + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort) + resolve() + }, ms) + const onAbort = () => { + clearTimeout(timer) + reject(new Error("aborted")) + } + signal.addEventListener("abort", onAbort, { once: true }) + }) +} + +function flattenMonitor(raw: unknown): unknown[] { + if (Array.isArray(raw)) return raw + if (raw != null) return [raw] + return [] +} + +async function listRunningIfaceNames(client: MikrotikClient): Promise { + const ifaces = await client.get>( + "/interface", + LIVE_ROS_TIMEOUT_MS, + ) + return ifaces + .filter((i) => (i.running ?? "false") === "true" + && (i.disabled ?? "false") !== "true" + && !isLoopbackName(i.name ?? "")) + .map((i) => i.name ?? "") + .filter(Boolean) +} + +async function monitorTrafficOnce( + client: MikrotikClient, + onlyInterface: string | undefined, + cache: { names: string[]; joinedFailed: boolean }, + signal: AbortSignal, +): Promise { + if (onlyInterface) { + return client.post( + "/interface/monitor-traffic", + { interface: onlyInterface, once: "" }, + LIVE_ROS_TIMEOUT_MS, + signal, + ) + } + if (cache.names.length === 0) { + cache.names = await listRunningIfaceNames(client) + } + if (cache.names.length === 0) return [] + if (!cache.joinedFailed) { + try { + return await client.post( + "/interface/monitor-traffic", + { interface: cache.names.join(","), once: "" }, + LIVE_ROS_TIMEOUT_MS, + signal, + ) + } catch { + cache.joinedFailed = true + } + } + const chunks = await Promise.all( + cache.names.map((name) => + client.post( + "/interface/monitor-traffic", + { interface: name, once: "" }, + LIVE_ROS_TIMEOUT_MS, + signal, + ).then(flattenMonitor).catch(() => [] as unknown[]), + ), + ) + return chunks.flat() +} + const trafficRoutes: FastifyPluginAsyncZod = async (app) => { app.get("/traffic/settings", async (_req, reply) => { const settings = getTrafficSettings() @@ -243,20 +295,77 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => { app.get("/traffic/servers", async (req, reply) => { const q = req.query as { range?: string } const minutes = rangeToMinutes(q.range) - const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString() + const rangeEndMs = Date.now() + const rangeStartMs = rangeEndMs - minutes * 60_000 + const sinceIso = new Date(rangeStartMs).toISOString() const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all() const data = allServers.map((s): TrafficServerDto => { - const snap = latestSnapshot(s.id) - const status: TrafficServerDto["status"] = - snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded") - const rows = readServerSamplesInRange(s.id, sinceIso) - return buildServerTraffic(s, status, rows) + return buildServerTraffic(s, snapshotStatus(s.id), rows, rangeStartMs, rangeEndMs) }) return reply.send({ servers: data }) }) + app.get("/traffic/servers/:id/live", async (req, reply) => { + const p = req.params as { id?: string | number } + const q = req.query as { iface?: string } + const server = getEnabledServerById(p.id ?? "") + if (!server || !server.enabled) return reply.status(404).send({ error: "Server not found" }) + + const onlyInterface = q.iface && q.iface !== "__all__" ? q.iface : undefined + const abort = new AbortController() + const onClose = () => abort.abort() + req.raw.on("close", onClose) + + reply.hijack() + req.raw.setTimeout(0) + reply.raw.setTimeout(0) + const origin = typeof req.headers.origin === "string" ? req.headers.origin : "" + const allowed = env.CORS_ORIGIN + const sseHeaders: Record = { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + } + if (origin && (allowed === "*" || allowed === origin)) { + sseHeaders["Access-Control-Allow-Origin"] = origin + sseHeaders["Access-Control-Allow-Credentials"] = "true" + sseHeaders["Access-Control-Allow-Headers"] = "Authorization, Accept" + sseHeaders.Vary = "Origin" + } + reply.raw.writeHead(200, sseHeaders) + reply.raw.write(":\n\n") + + const client = MikrotikClient.fromServer(server) + const cache = { names: onlyInterface ? [onlyInterface] : [] as string[], joinedFailed: false } + + try { + while (!abort.signal.aborted) { + try { + const raw = await monitorTrafficOnce(client, onlyInterface, cache, abort.signal) + const sample = parseMonitorTraffic(raw, { onlyInterface }) + writeSse(reply.raw, "sample", sample) + } catch (error) { + if (abort.signal.aborted) break + const msg = error instanceof Error ? error.message : String(error) + writeSse(reply.raw, "error", { error: msg }) + } + await sleep(LIVE_TICK_MS, abort.signal) + } + } catch { + /* abort / disconnect */ + } finally { + req.raw.off("close", onClose) + try { + reply.raw.end() + } catch { + /* already closed */ + } + } + }) + app.get("/traffic/servers/:id/interfaces", async (req, reply) => { const p = req.params as { id?: string | number } const serverId = Number.parseInt(String(p.id ?? ""), 10) @@ -270,6 +379,7 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => { const rows = readServerSamplesInRange(serverId, sinceIso) const byIface = new Map() for (const r of rows) { + if (isLoopbackName(r.interfaceName)) continue const arr = byIface.get(r.interfaceName) ?? [] arr.push(r) byIface.set(r.interfaceName, arr) @@ -277,12 +387,17 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => { const interfaces: TrafficInterfaceDto[] = [...byIface.entries()].map(([name, arr]) => { const sorted = arr.sort((a, b) => a.sampledAt.localeCompare(b.sampledAt)) const last = sorted[sorted.length - 1] + const prev = sorted[sorted.length - 2] + if (!last) { + return { name, running: false, disabled: true, rxNow: 0, txNow: 0 } + } + const now = ifaceNowMbps(prev, last) return { name, - running: Boolean(last?.running), - disabled: Boolean(last?.disabled), - rxNow: Math.round((last?.rxBps ?? 0) / 1_000_000), - txNow: Math.round((last?.txBps ?? 0) / 1_000_000), + running: Boolean(last.running), + disabled: Boolean(last.disabled), + rxNow: now.rxNow, + txNow: now.txNow, } }).sort((a, b) => (b.rxNow + b.txNow) - (a.rxNow + a.txNow)) @@ -298,15 +413,20 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => { if (!server) return reply.status(404).send({ error: "Server not found" }) const minutes = rangeToMinutes(q.range) - const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString() + const rangeEndMs = Date.now() + const rangeStartMs = rangeEndMs - minutes * 60_000 + const sinceIso = new Date(rangeStartMs).toISOString() const rows = readServerSamplesInRange(server.id, sinceIso) - const snap = latestSnapshot(server.id) - const status: TrafficServerDto["status"] = - snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded") - const data = buildServerTraffic(server, status, rows, q.iface && q.iface !== "__all__" ? q.iface : undefined) + const data = buildServerTraffic( + server, + snapshotStatus(server.id), + rows, + rangeStartMs, + rangeEndMs, + q.iface && q.iface !== "__all__" ? q.iface : undefined, + ) return reply.send({ server: data }) }) } export default trafficRoutes - diff --git a/backend/src/services/firewall-live.ts b/backend/src/services/firewall-live.ts new file mode 100644 index 0000000..55c0da4 --- /dev/null +++ b/backend/src/services/firewall-live.ts @@ -0,0 +1,198 @@ +import { eq } from "drizzle-orm" +import { db } from "../db/index.js" +import { servers } from "../db/schema.js" +import { + MikrotikClient, + firewallRestPath, +} from "./mikrotik.js" +import type { + FirewallFamily, + FirewallTable, + RosFirewallAddressList, + RosFirewallFilter, +} from "../types/server.js" + +type ServerRow = typeof servers.$inferSelect + +export interface FirewallRuleDto { + id: string + rosId: string + serverId: string + serverName: string + family: FirewallFamily + table: FirewallTable + chain: string + action: string + proto: string + src: string + dst: string + port: string + iface: string + comment: string + enabled: boolean + hits: number + log: boolean + logPrefix: string + tlsHost?: string + layer7Proto?: string +} + +export interface FirewallAddressListDto { + id: string + rosId: string + serverId: string + serverName: string + family: FirewallFamily + list: string + address: string + comment: string + disabled: boolean + timeout?: string +} + +const TABLES: FirewallTable[] = ["filter", "nat", "mangle", "raw"] +const FAMILIES: FirewallFamily[] = ["ip", "ip6"] + +function dash(v: string | undefined): string { + const s = v?.trim() ?? "" + return s.length > 0 ? s : "—" +} + +function rosDisabled(v: string | undefined): boolean { + return v === "true" || v === "yes" +} + +function parseHits(raw: RosFirewallFilter): number { + const n = Number.parseInt(raw.packets ?? "0", 10) + return Number.isFinite(n) ? n : 0 +} + +export function ruleUiId( + serverId: string | number, + family: FirewallFamily, + table: FirewallTable, + rosId: string, +): string { + return `${serverId}:${family}:${table}:${rosId}` +} + +export function addressUiId( + serverId: string | number, + family: FirewallFamily, + rosId: string, +): string { + return `${serverId}:${family}:address-list:${rosId}` +} + +export function mapFirewallRule( + server: ServerRow, + family: FirewallFamily, + table: FirewallTable, + raw: RosFirewallFilter, + idx: number, +): FirewallRuleDto { + const rosId = raw[".id"] || `*${idx}` + const src = raw["src-address"] || raw["src-address-list"] + const dst = raw["dst-address"] || raw["dst-address-list"] + const port = raw["dst-port"] || raw["src-port"] + const iface = raw["in-interface"] || raw["out-interface"] + return { + id: ruleUiId(server.id, family, table, rosId), + rosId, + serverId: String(server.id), + serverName: server.name || server.host, + family, + table, + chain: raw.chain || "", + action: raw.action || "", + proto: raw.protocol || "all", + src: dash(src), + dst: dash(dst), + port: dash(port), + iface: dash(iface), + comment: raw.comment ?? "", + enabled: !rosDisabled(raw.disabled), + hits: parseHits(raw), + log: raw.log === "true" || raw.log === "yes", + logPrefix: raw["log-prefix"] ?? "", + tlsHost: raw["tls-host"], + layer7Proto: raw["layer7-protocol"], + } +} + +export function mapAddressList( + server: ServerRow, + family: FirewallFamily, + raw: RosFirewallAddressList, + idx: number, +): FirewallAddressListDto { + const rosId = raw[".id"] || `*${idx}` + return { + id: addressUiId(server.id, family, rosId), + rosId, + serverId: String(server.id), + serverName: server.name || server.host, + family, + list: raw.list || "", + address: raw.address || "", + comment: raw.comment ?? "", + disabled: rosDisabled(raw.disabled), + timeout: raw.timeout, + } +} + +async function safeGet(fn: () => Promise, fallback: T[] = []): Promise { + try { + const rows = await fn() + return Array.isArray(rows) ? rows : fallback + } catch { + return fallback + } +} + +export async function fetchServerFirewall(server: ServerRow): Promise<{ + rules: FirewallRuleDto[] + addressLists: FirewallAddressListDto[] +}> { + const client = MikrotikClient.fromServer(server) + const ruleJobs = FAMILIES.flatMap((family) => + TABLES.map(async (table) => { + const raw = await safeGet(() => client.getFirewallRules(family, table)) + return raw.map((row, idx) => mapFirewallRule(server, family, table, row, idx)) + }), + ) + const listJobs = FAMILIES.map(async (family) => { + const raw = await safeGet(() => client.getFirewallAddressList(family)) + return raw.map((row, idx) => mapAddressList(server, family, row, idx)) + }) + const [ruleChunks, listChunks] = await Promise.all([ + Promise.all(ruleJobs), + Promise.all(listJobs), + ]) + return { + rules: ruleChunks.flat(), + addressLists: listChunks.flat(), + } +} + +export async function listFirewallAll(): Promise<{ + rules: FirewallRuleDto[] + addressLists: FirewallAddressListDto[] +}> { + const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all() + const perServer = await Promise.all( + allServers.map(async (server) => { + try { + return await fetchServerFirewall(server) + } catch { + return { rules: [] as FirewallRuleDto[], addressLists: [] as FirewallAddressListDto[] } + } + }), + ) + return { + rules: perServer.flatMap((r) => r.rules), + addressLists: perServer.flatMap((r) => r.addressLists), + } +} + +export { firewallRestPath, FAMILIES, TABLES } diff --git a/backend/src/services/mikrotik.ts b/backend/src/services/mikrotik.ts index 7696797..7607879 100644 --- a/backend/src/services/mikrotik.ts +++ b/backend/src/services/mikrotik.ts @@ -7,7 +7,8 @@ import type { RosBgpSession, RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance, RosBfdSession, - RosIpRoute, RosFirewallFilter, RosLogEntry, RosPingResult, + RosIpRoute, RosFirewallFilter, RosFirewallAddressList, RosLogEntry, RosPingResult, + FirewallFamily, FirewallTable, } from "../types/server.js" // ── connection params ───────────────────────────────────────────────────────── @@ -338,6 +339,19 @@ function matchesUploadedFile(entryName: string, requested: string): boolean { || entryName.endsWith(`/${base}`) } +export function firewallRestPath( + family: FirewallFamily, + table: FirewallTable | "address-list", +): string { + const root = family === "ip6" ? "/ipv6/firewall" : "/ip/firewall" + return `${root}/${table}` +} + +export function encodeRosId(rosId: string): string { + const id = rosId.startsWith("*") ? rosId : `*${rosId.replace(/^\*/, "")}` + return encodeURIComponent(id) +} + // ── MikrotikClient ───────────────────────────────────────────────────────────── export class MikrotikClient { @@ -461,7 +475,15 @@ export class MikrotikClient { } async getFirewallFilters(): Promise { - return this.get("/ip/firewall/filter") + return this.getFirewallRules("ip", "filter") + } + + async getFirewallRules(family: FirewallFamily, table: FirewallTable): Promise { + return this.get(firewallRestPath(family, table)) + } + + async getFirewallAddressList(family: FirewallFamily): Promise { + return this.get(firewallRestPath(family, "address-list")) } async getLogs(limit = 50): Promise { diff --git a/backend/src/services/traffic-collector.ts b/backend/src/services/traffic-collector.ts index 8f39fc1..89cbb5d 100644 --- a/backend/src/services/traffic-collector.ts +++ b/backend/src/services/traffic-collector.ts @@ -1,9 +1,10 @@ -import { and, asc, eq, gte, lt } from "drizzle-orm" +import { and, asc, desc, eq, gte, lt } from "drizzle-orm" import { db } from "../db/index.js" import { servers, trafficSamples, trafficSettings } from "../db/schema.js" import type { TrafficRunSnapshot } from "../types/scheduler-run-snapshot.js" import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js" import { MikrotikClient } from "./mikrotik.js" +import { bpsToMbps, rateBpsFromDelta, shouldIncludeIface } from "./traffic-rate.js" interface RosIfaceTraffic { name?: string @@ -50,6 +51,31 @@ function cleanupOldSamples(retentionDays: number) { .run() } +function readPreviousWave(serverId: number): Map { + const last = db + .select({ sampledAt: trafficSamples.sampledAt }) + .from(trafficSamples) + .where(eq(trafficSamples.serverId, serverId)) + .orderBy(desc(trafficSamples.sampledAt)) + .limit(1) + .all()[0] + if (!last) return new Map() + const rows = db + .select({ + interfaceName: trafficSamples.interfaceName, + rxBytes: trafficSamples.rxBytes, + txBytes: trafficSamples.txBytes, + sampledAt: trafficSamples.sampledAt, + }) + .from(trafficSamples) + .where(and( + eq(trafficSamples.serverId, serverId), + eq(trafficSamples.sampledAt, last.sampledAt), + )) + .all() + return new Map(rows.map((r) => [r.interfaceName, r])) +} + export async function collectTrafficOnce(): Promise { const sampledAt = new Date().toISOString() if (collecting) { @@ -80,26 +106,42 @@ export async function collectTrafficOnce(): Promise { try { const client = MikrotikClient.fromServer(srv) const ifaces = await client.get("/interface") - let sumRx = 0 - let sumTx = 0 - for (const i of ifaces) { - sumRx += toNum(i["rx-bits-per-second"]) / 1_000_000 - sumTx += toNum(i["tx-bits-per-second"]) / 1_000_000 - } - if (ifaces.length > 0) { - db.insert(trafficSamples).values( - ifaces.map((i) => ({ - serverId: srv.id, - interfaceName: i.name ?? "unknown", - sampledAt: now, - rxBytes: toNum(i["rx-byte"]), - txBytes: toNum(i["tx-byte"]), - rxBps: toNum(i["rx-bits-per-second"]), - txBps: toNum(i["tx-bits-per-second"]), - running: (i.running ?? "false") === "true", - disabled: (i.disabled ?? "false") === "true", - })), - ).run() + const prevWave = readPreviousWave(srv.id) + const nowMs = Date.parse(now) + let sumRxMbps = 0 + let sumTxMbps = 0 + const rows = ifaces.map((i) => { + const interfaceName = i.name ?? "unknown" + const rxBytes = toNum(i["rx-byte"]) + const txBytes = toNum(i["tx-byte"]) + const running = (i.running ?? "false") === "true" + const disabled = (i.disabled ?? "false") === "true" + const prev = prevWave.get(interfaceName) + const prevMs = prev ? Date.parse(prev.sampledAt) : NaN + const rxBps = prev && Number.isFinite(prevMs) + ? (rateBpsFromDelta(prev.rxBytes, rxBytes, prevMs, nowMs) ?? 0) + : 0 + const txBps = prev && Number.isFinite(prevMs) + ? (rateBpsFromDelta(prev.txBytes, txBytes, prevMs, nowMs) ?? 0) + : 0 + if (shouldIncludeIface(interfaceName, running, disabled)) { + sumRxMbps += bpsToMbps(rxBps) + sumTxMbps += bpsToMbps(txBps) + } + return { + serverId: srv.id, + interfaceName, + sampledAt: now, + rxBytes, + txBytes, + rxBps, + txBps, + running, + disabled, + } + }) + if (rows.length > 0) { + db.insert(trafficSamples).values(rows).run() } snapshot.servers.push({ serverId: srv.id, @@ -107,8 +149,8 @@ export async function collectTrafficOnce(): Promise { host: srv.host, ok: true, interfaces: ifaces.length, - sumRxMbps: Math.round(sumRx), - sumTxMbps: Math.round(sumTx), + sumRxMbps: Math.round(sumRxMbps * 1000) / 1000, + sumTxMbps: Math.round(sumTxMbps * 1000) / 1000, }) } catch (err) { snapshot.servers.push({ diff --git a/backend/src/services/traffic-rate.test.ts b/backend/src/services/traffic-rate.test.ts new file mode 100644 index 0000000..bb77768 --- /dev/null +++ b/backend/src/services/traffic-rate.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict" +import { + bpsToMbps, + bucketAvg, + buildTrafficFromSamples, + isLoopbackName, + parseMonitorTraffic, + rateBpsFromDelta, + shouldIncludeIface, + type TrafficSampleLike, +} from "./traffic-rate.js" + +assert.equal(isLoopbackName("lo"), true) +assert.equal(isLoopbackName("loopback"), true) +assert.equal(isLoopbackName("ether1"), false) +assert.equal(shouldIncludeIface("lo", true, false), false) +assert.equal(shouldIncludeIface("ether1", true, false), true) +assert.equal(shouldIncludeIface("ether1", false, false), false) +assert.equal(shouldIncludeIface("ether1", true, true), false) +assert.equal(shouldIncludeIface("lo", true, false, "lo"), true) + +assert.equal(rateBpsFromDelta(1000, 2000, 0, 1000), 8000) +assert.equal(rateBpsFromDelta(1000, 500, 0, 1000), null) +assert.equal(rateBpsFromDelta(1000, 2000, 1000, 1000), null) +assert.equal(bpsToMbps(1_500_000), 1.5) +assert.equal(bpsToMbps(400_000), 0.4) + +const buckets = bucketAvg( + [ + { t: 0, v: 10 }, + { t: 1000, v: 20 }, + ], + 0, + 1000, + 4, +) +assert.equal(buckets.length, 4) +assert.equal(buckets[0], 10) +assert.equal(buckets[3], 20) +assert.equal(buckets[1], 0) +assert.equal(buckets[2], 0) + +const t0 = "2026-09-06T10:00:00.000Z" +const t1 = "2026-09-06T10:00:30.000Z" +const t2 = "2026-09-06T10:01:00.000Z" +const start = Date.parse(t0) +const end = Date.parse(t2) + +const samples: TrafficSampleLike[] = [ + { interfaceName: "ether1", sampledAt: t0, rxBytes: 1_000_000, txBytes: 500_000, rxBps: 0, txBps: 0, running: true, disabled: false }, + { interfaceName: "ether1", sampledAt: t1, rxBytes: 1_000_000 + 3_750_000, txBytes: 500_000 + 1_875_000, rxBps: 0, txBps: 0, running: true, disabled: false }, + { interfaceName: "ether1", sampledAt: t2, rxBytes: 100, txBytes: 50, rxBps: 0, txBps: 0, running: true, disabled: false }, + { interfaceName: "lo", sampledAt: t0, rxBytes: 0, txBytes: 0, rxBps: 0, txBps: 0, running: true, disabled: false }, + { interfaceName: "lo", sampledAt: t1, rxBytes: 9_000_000, txBytes: 9_000_000, rxBps: 0, txBps: 0, running: true, disabled: false }, +] + +const built = buildTrafficFromSamples(samples, start, end) +assert.ok(built.rxPeak > 0, "peak RX from delta") +assert.equal(built.rxNow, 1, "last valid delta after reset skip") +assert.ok(built.rxSeries.some((v) => v > 0), "bucket series not flat") +assert.ok(built.rxPeak <= 1.1, "lo excluded from peak") + +const live = parseMonitorTraffic([ + { name: "ether1", "rx-bits-per-second": "2000000", "tx-bits-per-second": "500000" }, + { name: "lo", "rx-bits-per-second": "8000000", "tx-bits-per-second": "8000000" }, +]) +assert.equal(live.rxMbps, 2) +assert.equal(live.txMbps, 0.5) + +const onceOnly = parseMonitorTraffic( + { name: "ether1", "rx-bits-per-second": "1000000", "tx-bits-per-second": "0" }, + { onlyInterface: "ether1" }, +) +assert.equal(onceOnly.rxMbps, 1) + +console.log("traffic-rate tests ok") diff --git a/backend/src/services/traffic-rate.ts b/backend/src/services/traffic-rate.ts new file mode 100644 index 0000000..7e65c67 --- /dev/null +++ b/backend/src/services/traffic-rate.ts @@ -0,0 +1,221 @@ +/** Чистые формулы трафика: дельты счётчиков, корзины series, monitor-traffic. */ + +export const SERIES_POINTS = 60 + +export interface TrafficSampleLike { + interfaceName: string + sampledAt: string + rxBytes: number + txBytes: number + rxBps: number + txBps: number + running: boolean + disabled: boolean +} + +export interface RatePoint { + t: number + rxMbps: number + txMbps: number +} + +export interface BuiltTrafficSeries { + rxNow: number + txNow: number + rxPeak: number + txPeak: number + rxTotalGiB: number + txTotalGiB: number + sessions: number + rxSeries: number[] + txSeries: number[] +} + +export interface MonitorLiveSample { + rxMbps: number + txMbps: number + at: string +} + +export function isLoopbackName(name: string): boolean { + return /^(lo|loopback)(\d+)?$/i.test(name.trim()) +} + +export function shouldIncludeIface( + name: string, + running: boolean, + disabled: boolean, + onlyInterface?: string, +): boolean { + if (onlyInterface) return name === onlyInterface + if (isLoopbackName(name)) return false + return running && !disabled +} + +export function bpsToMbps(bps: number): number { + if (!Number.isFinite(bps) || bps <= 0) return 0 + return Math.round((bps / 1_000_000) * 1000) / 1000 +} + +/** bits/s из соседних счётчиков. null = нельзя (Δt≤0 или сброс). */ +export function rateBpsFromDelta( + prevBytes: number, + nextBytes: number, + prevAtMs: number, + nextAtMs: number, +): number | null { + const dtSec = (nextAtMs - prevAtMs) / 1000 + if (!(dtSec > 0) || !Number.isFinite(dtSec)) return null + if (nextBytes < prevBytes) return null + return Math.round(((nextBytes - prevBytes) * 8) / dtSec) +} + +export function bucketAvg( + points: Array<{ t: number; v: number }>, + rangeStartMs: number, + rangeEndMs: number, + target = SERIES_POINTS, +): number[] { + const buckets = Array.from({ length: target }, () => 0) + const counts = Array.from({ length: target }, () => 0) + const span = rangeEndMs - rangeStartMs + if (span <= 0 || points.length === 0) return buckets + for (const p of points) { + const ratio = (p.t - rangeStartMs) / span + const i = Math.min(target - 1, Math.max(0, Math.floor(ratio * target))) + buckets[i] += p.v + counts[i] += 1 + } + return buckets.map((sum, i) => (counts[i] > 0 ? sum / counts[i] : 0)) +} + +function parseIsoMs(iso: string): number { + const t = Date.parse(iso) + return Number.isFinite(t) ? t : 0 +} + +export function buildTrafficFromSamples( + rows: TrafficSampleLike[], + rangeStartMs: number, + rangeEndMs: number, + onlyInterface?: string, +): BuiltTrafficSeries { + const empty: BuiltTrafficSeries = { + rxNow: 0, + txNow: 0, + rxPeak: 0, + txPeak: 0, + rxTotalGiB: 0, + txTotalGiB: 0, + sessions: 0, + rxSeries: Array.from({ length: SERIES_POINTS }, () => 0), + txSeries: Array.from({ length: SERIES_POINTS }, () => 0), + } + if (rows.length === 0) return empty + + const byIface = new Map() + for (const r of rows) { + const arr = byIface.get(r.interfaceName) ?? [] + arr.push(r) + byIface.set(r.interfaceName, arr) + } + + const rxPoints: Array<{ t: number; v: number }> = [] + const txPoints: Array<{ t: number; v: number }> = [] + const byTs = new Map() + + let rxBytesDelta = 0 + let txBytesDelta = 0 + let sessions = 0 + + for (const [name, arr] of byIface) { + if (onlyInterface) { + if (name !== onlyInterface) continue + } else if (isLoopbackName(name)) { + continue + } + + const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt)) + const last = sorted[sorted.length - 1] + if (!last) continue + if (!onlyInterface && (!last.running || last.disabled)) continue + + if (last.running && !last.disabled) sessions += 1 + + const first = sorted[0] + if (first) { + const dRx = last.rxBytes - first.rxBytes + const dTx = last.txBytes - first.txBytes + rxBytesDelta += dRx >= 0 ? dRx : last.rxBytes + txBytesDelta += dTx >= 0 ? dTx : last.txBytes + } + + for (let i = 1; i < sorted.length; i++) { + const prev = sorted[i - 1] + const cur = sorted[i] + if (!prev || !cur) continue + if (!onlyInterface && (!cur.running || cur.disabled)) continue + const t0 = parseIsoMs(prev.sampledAt) + const t1 = parseIsoMs(cur.sampledAt) + const rxBps = rateBpsFromDelta(prev.rxBytes, cur.rxBytes, t0, t1) + const txBps = rateBpsFromDelta(prev.txBytes, cur.txBytes, t0, t1) + if (rxBps == null && txBps == null) continue + const rxMbps = bpsToMbps(rxBps ?? 0) + const txMbps = bpsToMbps(txBps ?? 0) + const acc = byTs.get(t1) ?? { rx: 0, tx: 0 } + acc.rx += rxMbps + acc.tx += txMbps + byTs.set(t1, acc) + } + } + + for (const [t, v] of byTs) { + rxPoints.push({ t, v: v.rx }) + txPoints.push({ t, v: v.tx }) + } + + const rxSeries = bucketAvg(rxPoints, rangeStartMs, rangeEndMs) + const txSeries = bucketAvg(txPoints, rangeStartMs, rangeEndMs) + const lastTs = [...byTs.keys()].sort((a, b) => a - b).at(-1) + const last = lastTs != null ? byTs.get(lastTs) : undefined + const rxPeak = rxPoints.reduce((m, p) => Math.max(m, p.v), 0) + const txPeak = txPoints.reduce((m, p) => Math.max(m, p.v), 0) + + return { + rxNow: last?.rx ?? 0, + txNow: last?.tx ?? 0, + rxPeak, + txPeak, + rxTotalGiB: Number((rxBytesDelta / (1024 ** 3)).toFixed(1)), + txTotalGiB: Number((txBytesDelta / (1024 ** 3)).toFixed(1)), + sessions, + rxSeries, + txSeries, + } +} + +export function parseMonitorTraffic( + raw: unknown, + opts?: { onlyInterface?: string }, +): MonitorLiveSample { + const items = Array.isArray(raw) ? raw : raw != null ? [raw] : [] + let rxBps = 0 + let txBps = 0 + for (const item of items) { + if (!item || typeof item !== "object") continue + const rec = item as Record + const name = String(rec.name ?? rec.interface ?? "") + if (opts?.onlyInterface) { + if (name && name !== opts.onlyInterface) continue + } else if (isLoopbackName(name)) { + continue + } + rxBps += Number.parseFloat(String(rec["rx-bits-per-second"] ?? 0)) || 0 + txBps += Number.parseFloat(String(rec["tx-bits-per-second"] ?? 0)) || 0 + } + return { + rxMbps: bpsToMbps(rxBps), + txMbps: bpsToMbps(txBps), + at: new Date().toISOString(), + } +} diff --git a/backend/src/types/server.ts b/backend/src/types/server.ts index 3ea3b20..0f28eb3 100644 --- a/backend/src/types/server.ts +++ b/backend/src/types/server.ts @@ -345,8 +345,24 @@ export interface RosFirewallFilter { "dynamic"?: string "bytes"?: string "packets"?: string + "log"?: string + "log-prefix"?: string } +export interface RosFirewallAddressList { + ".id": string + list: string + address: string + comment?: string + disabled?: string + timeout?: string + dynamic?: string + "creation-time"?: string +} + +export type FirewallFamily = "ip" | "ip6" +export type FirewallTable = "filter" | "nat" | "mangle" | "raw" + export interface RosLogEntry { ".id": string "time": string diff --git a/components/data-grids/firewall-rules-data-grid.tsx b/components/data-grids/firewall-rules-data-grid.tsx index 7a5aa8e..d6b0be6 100644 --- a/components/data-grids/firewall-rules-data-grid.tsx +++ b/components/data-grids/firewall-rules-data-grid.tsx @@ -23,8 +23,11 @@ import { DATA_GRID_CELL_PAD, DATA_GRID_CELL_PAD_FIRST, DATA_GRID_CELL_PAD_LAST, + DATA_GRID_CONTAINER_CLASS, } from "@/components/data-grids/shared/data-grid-layout" import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header" +import { DataGridContainer, DataGridTableDndRowHandle, DataGridTableDndRows } from "@/components/reui/data-grid" +import type { DragEndEvent } from "@dnd-kit/core" import { EmptyState } from "@/components/empty-state" import { CopyIcon, @@ -89,16 +92,41 @@ interface FirewallRulesDataGridProps { rules: FirewallRule[] onToggle: (id: string) => void onEdit: (rule: FirewallRule) => void + onDelete?: (rule: FirewallRule) => void + onReorder?: (activeId: string, overId: string) => void + showServer?: boolean } -function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGridProps) { +function FirewallRulesDataGrid({ + rules, + onToggle, + onEdit, + onDelete, + onReorder, + showServer = false, +}: FirewallRulesDataGridProps) { const indexedRules = useMemo( () => rules.map((rule, index) => ({ ...rule, _index: index + 1 })), [rules], ) + const reorderable = Boolean(onReorder) + const indexPad = reorderable ? DATA_GRID_CELL_PAD : DATA_GRID_CELL_PAD_FIRST const columns = useMemo[]>( () => [ + ...(reorderable + ? [{ + id: "drag", + header: () => Порядок, + enableSorting: false, + cell: () => , + size: 40, + meta: { + headerClassName: DATA_GRID_CELL_PAD_FIRST, + cellClassName: DATA_GRID_CELL_PAD_FIRST, + }, + } satisfies ColumnDef] + : []), { id: "index", accessorKey: "_index", @@ -114,10 +142,23 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri ), size: 48, meta: { - headerClassName: DATA_GRID_CELL_PAD_FIRST, - cellClassName: DATA_GRID_CELL_PAD_FIRST, + headerClassName: indexPad, + cellClassName: indexPad, }, }, + ...(showServer + ? [{ + id: "server", + accessorFn: (row: FirewallRule & { _index: number }) => row.serverName ?? row.serverId ?? "", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.serverName || row.original.serverId || "—"} + + ), + meta: { headerTitle: "Сервер", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD }, + } satisfies ColumnDef] + : []), { id: "chain", accessorKey: "chain", @@ -261,7 +302,7 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri {r.enabled ? "Отключить" : "Включить"} - + onDelete?.(r)}> Удалить правило @@ -274,17 +315,24 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST }, }, ], - [onEdit, onToggle], + [onEdit, onToggle, onDelete, showServer, reorderable, indexPad], ) const table = useReactTable({ data: indexedRules, columns, getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), + ...(reorderable ? {} : { getSortedRowModel: getSortedRowModel() }), + enableSorting: !reorderable, getRowId: (row) => row.id, }) + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event + if (!over || active.id === over.id || !onReorder) return + onReorder(String(active.id), String(over.id)) + } + if (rules.length === 0) { return ( + > + {reorderable ? ( + + r.id)} + handleDragEnd={handleDragEnd} + /> + + ) : undefined} + ) } diff --git a/components/data-page-toolbar.tsx b/components/data-page-toolbar.tsx index 4d76984..c36c662 100644 --- a/components/data-page-toolbar.tsx +++ b/components/data-page-toolbar.tsx @@ -31,6 +31,7 @@ function DataPageToolbarFrame({ } interface DataPageToolbarProps { + leading?: ReactNode search?: string onSearchChange?: (value: string) => void searchPlaceholder?: string @@ -48,6 +49,7 @@ interface DataPageToolbarProps { } function DataPageToolbar({ + leading, search, onSearchChange, searchPlaceholder = "Поиск…", @@ -61,6 +63,7 @@ function DataPageToolbar({ }: DataPageToolbarProps) { return ( + {leading} {segmented && ( +
+ {label} +
+ ) +} + +function CustomTooltip({ + active, + payload, +}: { + active?: boolean + payload?: { + dataKey: string + color: string + value: number + }[] +}) { + if (!active || !payload?.length) return null + return ( +
+ {payload.map((item) => ( +
+
+ {item.dataKey === "rx" ? "RX" : "TX"}: +
+
+ {fmtRate(item.value)} +
+
+ ))} +
+ ) +} + +function toChartData(rx: number[], tx: number[], rangeMinutes: number) { + const n = Math.max(rx.length, tx.length, 1) + const denom = Math.max(n - 1, 1) + return Array.from({ length: n }, (_, i) => { + const minutesAgo = Math.round((1 - i / denom) * rangeMinutes) + return { + time: formatRangeAgo(minutesAgo), + rx: rx[i] ?? 0, + tx: tx[i] ?? 0, + } + }) +} + +export function TrafficRxTxChart({ + rx, + tx, + range = "1h", +}: { + rx: number[] + tx: number[] + range?: string +}) { + const rangeMinutes = TRAFFIC_RANGE_MINUTES[range] ?? 60 + const data = toChartData(rx, tx, rangeMinutes) + const tickEvery = Math.max(1, Math.ceil(data.length / 6)) + + return ( + + + + + + + fmtRate(Number(v))} + tickMargin={8} + width={72} + /> + } /> + + + + +
+ + +
+
+ + ) +} diff --git a/components/server-rail-layout.tsx b/components/server-rail-layout.tsx new file mode 100644 index 0000000..7812d4a --- /dev/null +++ b/components/server-rail-layout.tsx @@ -0,0 +1,236 @@ +"use client" + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react" +import { PanelLeftCloseIcon, ServerIcon } from "lucide-react" +import { Button } from "@/components/ui/button" +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + ALL_SERVERS_ID, + ServerTileRail, + hostnameOf, + type ServerTileItem, +} from "@/components/server-tile-rail" +import { cn } from "@/lib/utils" + +/** Preview: https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/list-9 */ + +const STORAGE_KEY = "mm:server-rail-collapsed" +const DESKTOP_MQ = "(min-width: 768px)" + +function readCollapsed(): boolean { + try { + return window.localStorage.getItem(STORAGE_KEY) === "1" + } catch { + return false + } +} + +function writeCollapsed(value: boolean) { + try { + window.localStorage.setItem(STORAGE_KEY, value ? "1" : "0") + } catch { + // quota / private mode + } +} + +interface ServerRailContextValue { + openRail: () => void + expandRail: () => void + collapsed: boolean + selectedLabel: string +} + +const ServerRailContext = createContext(null) + +function useServerRail() { + const ctx = useContext(ServerRailContext) + if (!ctx) { + throw new Error("ServerRailMobileButton must be used inside ServerRailLayout") + } + return ctx +} + +function ServerRailMobileButton() { + const { openRail, expandRail, collapsed, selectedLabel } = useServerRail() + return ( + + ) +} + +function ServerRailLayout({ + items, + selectedId, + onSelect, + showAll = true, + allCount = 0, + showCount = true, + showType = true, + headerRight, + loading = false, + extra, + header, + banner, + contentClassName, + children, +}: { + items: ServerTileItem[] + selectedId: string + onSelect: (id: string) => void + showAll?: boolean + allCount?: number + showCount?: boolean + showType?: boolean + headerRight?: ReactNode + loading?: boolean + extra?: ReactNode + header?: ReactNode + banner?: ReactNode + contentClassName?: string + children: ReactNode +}) { + const [railOpen, setRailOpen] = useState(false) + const [collapsed, setCollapsed] = useState(false) + const [hydrated, setHydrated] = useState(false) + + useEffect(() => { + setCollapsed(readCollapsed()) + setHydrated(true) + }, []) + + useEffect(() => { + if (!hydrated) return + writeCollapsed(collapsed) + }, [collapsed, hydrated]) + + const handleSelect = useCallback( + (id: string) => { + onSelect(id) + setRailOpen(false) + }, + [onSelect], + ) + + const expandRail = useCallback(() => { + setCollapsed(false) + }, []) + + const selectedLabel = useMemo(() => { + if (showAll && selectedId === ALL_SERVERS_ID) return "Все серверы" + const item = items.find((entry) => entry.id === selectedId) + return item ? hostnameOf(item) : "Сервер" + }, [items, selectedId, showAll]) + + const ctx = useMemo( + () => ({ + openRail: () => setRailOpen(true), + expandRail, + collapsed, + selectedLabel, + }), + [collapsed, expandRail, selectedLabel], + ) + + const railHeaderRight = ( + <> + {headerRight} + + + ) + + const rail = ( + + ) + + return ( + +
+ {header} + {banner} +
+ {!collapsed ? ( + + ) : null} +
+ {children} +
+
+ + + + Серверы + +
+ + {extra} +
+
+
+
+
+ ) +} + +export { ServerRailLayout, ServerRailMobileButton } diff --git a/components/server-tile-rail.tsx b/components/server-tile-rail.tsx index eb796b9..28309ad 100644 --- a/components/server-tile-rail.tsx +++ b/components/server-tile-rail.tsx @@ -1,6 +1,6 @@ "use client" -import { useMemo, useState, type ReactNode } from "react" +import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react" import type { ServerStatus, ServerType } from "@/lib/data" import { Flag } from "@/components/flag" import { StatusDot } from "@/components/status-dot" @@ -12,7 +12,15 @@ import { FramePanel, FrameTitle, } from "@/components/reui/frame" +import { + Item, + ItemActions, + ItemContent, + ItemMedia, + ItemTitle, +} from "@/components/ui/item" import { ScrollArea } from "@/components/ui/scroll-area" +import { Spinner } from "@/components/ui/spinner" import { InputGroup, InputGroupAddon, @@ -21,7 +29,7 @@ import { import { cn } from "@/lib/utils" import { LayersIcon, SearchIcon, ServerIcon } from "lucide-react" -/** Preview: https://reui.io/preview/base/list-9 · https://reui.io/docs/components/base/icon-tile · https://reui.io/preview/base/components/c-input-group-1 */ +/** Preview: https://reui.io/preview/base/list-9 · https://reui.io/docs/components/base/icon-tile · https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/badge */ export const ALL_SERVERS_ID = "all" export interface ServerTileItem { @@ -34,6 +42,9 @@ export interface ServerTileItem { enabled?: boolean selectable?: boolean title?: string + site?: string + host?: string + meta?: string } function typeBadgeVariant( @@ -58,12 +69,22 @@ function ServerTypeBadge({ type }: { type: ServerType }) { ) } +function hostnameOf(item: ServerTileItem): string { + return item.name || item.host || "" +} + function matchesQuery(item: ServerTileItem, q: string): boolean { if (!q) return true - const hay = [item.name, item.title ?? ""].join(" ").toLowerCase() + const hay = [item.name, item.title ?? "", item.host ?? "", item.site ?? "", item.country ?? ""] + .join(" ") + .toLowerCase() return hay.includes(q) } +function isSelectable(item: ServerTileItem): boolean { + return item.selectable !== false +} + function ServerTileRail({ items, selectedId, @@ -74,6 +95,7 @@ function ServerTileRail({ showCount = true, showType = true, headerRight, + loading = false, className, }: { items: ServerTileItem[] @@ -85,18 +107,106 @@ function ServerTileRail({ showCount?: boolean showType?: boolean headerRight?: ReactNode + loading?: boolean className?: string }) { const [query, setQuery] = useState("") const q = query.trim().toLowerCase() + const listRef = useRef(null) const filtered = useMemo( () => items.filter((item) => matchesQuery(item, q)), [items, q], ) + const uniqueSites = useMemo(() => { + const sites = new Set( + filtered.map((item) => item.site?.trim()).filter((site): site is string => Boolean(site)), + ) + return sites.size + }, [filtered]) + + const groupBySite = uniqueSites >= 2 && !q + + const groups = useMemo(() => { + if (!groupBySite) { + return [{ site: "", items: filtered }] + } + const bySite = new Map() + for (const item of filtered) { + const site = item.site?.trim() || "—" + const list = bySite.get(site) + if (list) list.push(item) + else bySite.set(site, [item]) + } + return [...bySite.entries()].map(([site, siteItems]) => ({ site, items: siteItems })) + }, [filtered, groupBySite]) + const showAllTile = showAll && !q + const resolvedId = useMemo(() => { + if (showAll && selectedId === ALL_SERVERS_ID) return ALL_SERVERS_ID + if (items.some((item) => item.id === selectedId)) return selectedId + if (showAll) return ALL_SERVERS_ID + return items.find(isSelectable)?.id ?? items[0]?.id ?? "" + }, [items, selectedId, showAll]) + + useEffect(() => { + if (resolvedId && resolvedId !== selectedId) onSelect(resolvedId) + }, [onSelect, resolvedId, selectedId]) + + const selectableIds = useMemo(() => { + const ids: string[] = [] + if (showAllTile) ids.push(ALL_SERVERS_ID) + for (const item of filtered) { + if (isSelectable(item)) ids.push(item.id) + } + return ids + }, [filtered, showAllTile]) + + function moveSelection(delta: number) { + if (selectableIds.length === 0) return + const idx = selectableIds.indexOf(resolvedId) + const nextIdx = + idx < 0 + ? delta > 0 ? 0 : selectableIds.length - 1 + : Math.min(selectableIds.length - 1, Math.max(0, idx + delta)) + const nextId = selectableIds[nextIdx] + if (nextId) onSelect(nextId) + } + + function handleListKeyDown(event: KeyboardEvent) { + if (loading) return + if (event.key === "ArrowDown") { + event.preventDefault() + moveSelection(1) + return + } + if (event.key === "ArrowUp") { + event.preventDefault() + moveSelection(-1) + return + } + if (event.key === "Home") { + event.preventDefault() + if (selectableIds[0]) onSelect(selectableIds[0]) + return + } + if (event.key === "End") { + event.preventDefault() + const last = selectableIds[selectableIds.length - 1] + if (last) onSelect(last) + } + } + + const allItem: ServerTileItem = { + id: ALL_SERVERS_ID, + name: "Все серверы", + title: "Все серверы", + count: allCount, + meta: String(allCount), + } + return ( @@ -106,10 +216,16 @@ function ServerTileRail({ Серверы + + {loading ? : null} + {headerRight} + +
+ ) : headerRight || loading ? ( +
+ {loading ? : null} {headerRight}
- ) : headerRight ? ( -
{headerRight}
) : null} @@ -120,18 +236,27 @@ function ServerTileRail({ onChange={(e) => setQuery(e.target.value)} placeholder="Поиск…" aria-label="Поиск сервера" + disabled={loading} /> -
+
{showAllTile ? ( onSelect(ALL_SERVERS_ID)} - title="Все серверы" - name="Все" - count={showCount ? allCount : undefined} + showCount={showCount} + showType={false} icon={
@@ -175,62 +310,76 @@ function ServerTileRail({ } function ServerTileButton({ + item, selected, onSelect, - title, - name, - count, - enabled = true, - selectable = true, - status, - type, + showCount, + showType, + showSite = true, icon, }: { + item: ServerTileItem selected: boolean onSelect: () => void - title: string - name: string - count?: number - enabled?: boolean - selectable?: boolean - status?: ServerStatus - type?: ServerType + showCount: boolean + showType: boolean + showSite?: boolean icon: ReactNode }) { + const isAll = item.id === ALL_SERVERS_ID + const selectable = isSelectable(item) + const hostname = isAll ? "Все серверы" : hostnameOf(item) + const meta = item.meta ?? (showCount && item.count != null ? String(item.count) : undefined) + const showStatus = Boolean(item.status && item.status !== "online") + return ( - + ) } -export { ServerTileRail, ServerTypeBadge } +export { ServerTileRail, ServerTypeBadge, hostnameOf } diff --git a/components/ui/chart.tsx b/components/ui/chart.tsx new file mode 100644 index 0000000..503501e --- /dev/null +++ b/components/ui/chart.tsx @@ -0,0 +1,372 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" +import * as RechartsPrimitive from "recharts" +import type { TooltipValueType } from "recharts" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +const INITIAL_DIMENSION = { width: 320, height: 200 } as const +type TooltipNameType = number | string + +export type ChartConfig = Record< + string, + { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +> + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +function ChartContainer({ + id, + className, + children, + config, + initialDimension = INITIAL_DIMENSION, + ...props +}: React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + initialDimension?: { + width: number + height: number + } +}) { + const uniqueId = React.useId() + const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme ?? config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +