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 ( +