feat(ui): integrate KpiStatGrid for enhanced statistics display
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 39s
Docker images / publish-release (push) Successful in 8s

Replaced existing KPI display implementations across multiple pages with the new KpiStatGrid component for a more consistent and visually appealing presentation of statistics. Updated the Backups, BGP, Certificates, Communities, Containers, Dashboard, and Data Collection pages to utilize the KpiStatGrid, improving the overall user experience and maintainability of the codebase. Additionally, added new dependencies in package.json for required libraries.
This commit is contained in:
Denozordec
2026-09-06 17:58:05 +07:00
parent 6123660346
commit fe32c9313a
47 changed files with 5371 additions and 1531 deletions
+34 -23
View File
@@ -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() {
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Всего бэкапов", value: backupList.length, icon: <HardDriveIcon className="size-4" /> },
{ label: "Авто", value: autoCount, icon: <ClockIcon className="size-4" /> },
{ label: "Вручную", value: manualCount, icon: <PlusIcon className="size-4" /> },
{ label: "Серверов охвачено",value: serverCount, icon: <ServerIcon className="size-4" /> },
].map((s) => (
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-center gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</div>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка бэкапов"
items={[
{
id: "all",
label: "Всего бэкапов",
value: backupList.length,
icon: <HardDriveIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "auto",
label: "Авто",
value: autoCount,
icon: <ClockIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "manual",
label: "Вручную",
value: manualCount,
icon: <PlusIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "servers",
label: "Серверов охвачено",
value: serverCount,
icon: <ServerIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{/* Info bar */}
<div className="flex items-center gap-4 text-xs text-muted-foreground px-1">
+69 -33
View File
@@ -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 (
<div className="flex flex-col gap-5">
{/* summary row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{ 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 => (
<Frame key={s.label} className="h-full">
<FramePanel className="flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка префиксов BGP"
items={[
{
id: "rx",
label: "Всего префиксов",
value: fmtNum(totalRx),
icon: <DownloadIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "active",
label: "Активных маршрутов",
value: fmtNum(totalActive),
icon: <GitMergeIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "ebgp",
label: "eBGP сессий",
value: ebgpSessions,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "ibgp",
label: "iBGP сессий",
value: ibgpSessions,
icon: <ServerIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
]}
/>
<div className="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-5">
{/* prefixes by peer — horizontal bar chart */}
@@ -722,22 +740,40 @@ export default function BgpPage() {
</span>
)}
{/* KPI strip */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{ 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 => (
<Frame key={s.label} className="h-full">
<FramePanel className="flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка BGP"
items={[
{
id: "sessions",
label: "Сессий всего",
value: sessions.length,
icon: <GitMergeIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "established",
label: "Established",
value: established,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "not-estab",
label: "Не установлено",
value: notEstab,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground",
variant: notEstab > 0 ? "warning" : "default",
},
{
id: "prefixes",
label: "Получено префиксов",
value: fmtNum(totalRx),
icon: <DownloadIcon className="size-4" />,
iconClassName: "text-info",
},
]}
/>
{/* alert: not-established sessions */}
{notEstab > 0 && (
+100 -60
View File
@@ -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 (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
<KpiStatGrid
aria-label="Сводка сертификатов"
items={[
{
id: "all",
label: "Всего",
value: displayCerts.length,
icon: <ShieldCheckIcon className="size-4 text-muted-foreground" />,
icon: <ShieldCheckIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "valid",
label: "Действующих",
value: displayCerts.filter((c) => c.status === "valid").length,
icon: <BadgeCheckIcon className="size-4 text-emerald-500" />,
icon: <BadgeCheckIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "expiring",
label: "Истекают",
value: expiring.length,
icon: <AlertTriangleIcon className="size-4 text-amber-500" />,
icon: <AlertTriangleIcon className="size-4" />,
iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground",
variant: expiring.length > 0 ? "warning" : "default",
},
{
id: "expired",
label: "Истёкших",
value: expired.length,
icon: <AlertCircleIcon className="size-4 text-red-500" />,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: expired.length > 0 ? "text-destructive" : "text-muted-foreground",
variant: expired.length > 0 ? "destructive" : "default",
},
].map((s) => (
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</div>
</FramePanel>
</Frame>
))}
</div>
]}
/>
)
}
@@ -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<ServerTileItem[]>(() => (
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<string, Server>()
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 (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
actions={
<>
<Button
variant="outline"
size="sm"
disabled={!liveReady || loadState === "loading"}
onClick={() => {
void handleRefresh()
}}
>
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
Обновить
</Button>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />
Импорт
</Button>
<Button size="sm" disabled={!liveReady || issueBusy} onClick={() => { setIssueStep(1); setIssueOpen(true) }}>
<PlusIcon className="size-4" />
Выпустить сертификат
</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<>
<ServerRailLayout
items={certRailItems}
selectedId={selectedServerId}
onSelect={setSelectedServerId}
showAll
allCount={displayServers.length}
loading={isLive && loadState === "loading" && displayServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
actions={
<>
<ServerRailMobileButton />
<Button
variant="outline"
size="sm"
disabled={!liveReady || loadState === "loading"}
onClick={() => {
void handleRefresh()
}}
>
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
Обновить
</Button>
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />
Импорт
</Button>
<Button
size="sm"
disabled={!liveReady || issueBusy}
onClick={() => {
setIssueStep(1)
if (selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
setIssueOpen(true)
}}
>
<PlusIcon className="size-4" />
Выпустить сертификат
</Button>
</>
}
/>
}
>
<div className="flex flex-col gap-5">
{isLive && backendStatus === false && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
@@ -673,7 +713,7 @@ export default function CertificatesPage() {
</div>
)}
<CertPartKpi displayCerts={displayCerts} expiring={expiring} expired={expired} />
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} />
{liveReady && (
<CertPartAcmeSettings
@@ -720,7 +760,7 @@ export default function CertificatesPage() {
<CertPartReference />
</div>
</div>
</ServerRailLayout>
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
@@ -753,7 +793,7 @@ export default function CertificatesPage() {
<StepperContent key={s} value={s}>
<CertPartIssueForm
step={s as 1 | 2 | 3 | 4}
serverList={serverList}
serverList={displayServers}
issueServerId={issueServerId}
setIssueServerId={setIssueServerId}
issueCertName={issueCertName}
@@ -812,6 +852,6 @@ export default function CertificatesPage() {
toast.success(`Файл ${files[0]?.name} готов к импорту на роутер`)
}}
/>
</div>
</>
)
}
+34 -16
View File
@@ -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() {
</p>
)}
{/* ── summary ── */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ 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 }) => (
<Frame key={label} className="h-full">
<FramePanel className="flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{value}</p>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка communities"
items={[
{
id: "all",
label: "Всего communities",
value: String(listData.length),
icon: <TagIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "enabled",
label: "Активных",
value: String(listData.filter((c) => c.enabled).length),
icon: <CheckIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "standard",
label: "Стандартных",
value: String(listData.filter((c) => c.type === "standard").length),
icon: <TagIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "filters",
label: "Использует фильтры",
value: String(new Set(listData.flatMap((c) => c.filterIds)).size),
icon: <FilterIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
<div className="grid grid-cols-[1fr_320px] gap-5">
{/* ── main table ── */}
+35 -22
View File
@@ -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() {
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* KPI */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Всего", value: routerContainers.length, icon: <BoxIcon className="size-4 text-muted-foreground" /> },
{ label: "Running", value: running, icon: <PlayIcon className="size-4 text-emerald-500" /> },
{ label: "Stopped", value: stopped, icon: <StopCircleIcon className="size-4 text-muted-foreground" /> },
{ label: "Ошибок", value: errors, icon: <AlertCircleIcon className="size-4 text-red-500" /> },
].map((s) => (
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</div>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка контейнеров"
items={[
{
id: "all",
label: "Всего",
value: routerContainers.length,
icon: <BoxIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "running",
label: "Running",
value: running,
icon: <PlayIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "stopped",
label: "Stopped",
value: stopped,
icon: <StopCircleIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "errors",
label: "Ошибок",
value: errors,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: "text-destructive",
variant: errors > 0 ? "destructive" : "default",
},
]}
/>
{/* Info banner */}
<div className="flex items-start gap-3 rounded-lg bg-violet-500/5 border border-violet-500/20 px-4 py-3 text-sm">
+49 -90
View File
@@ -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 (
<Frame className="h-full">
<FramePanel className="relative isolate flex h-full flex-col overflow-hidden">
<div className="relative z-10 flex items-start gap-3">
{icon ? (
<IconTile
variant="elevated"
aria-hidden="true"
className="size-10.5 text-muted-foreground"
>
{icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{label}</p>
<div className="flex items-baseline gap-1.5">
<span className="text-2xl leading-none font-bold tabular-nums tracking-tight">{value}</span>
{unit ? <span className="text-muted-foreground text-sm">{unit}</span> : null}
</div>
{delta ? (
<p className={`text-xs mt-1 flex items-center gap-1 ${deltaDir === "up" ? "text-[var(--status-online-fg)]" : deltaDir === "down" ? "text-[var(--status-offline-fg)]" : "text-muted-foreground"}`}>
{delta}
</p>
) : null}
</div>
</div>
{spark && spark.length > 1 ? (
<div className="absolute right-4 bottom-4 opacity-60">
<Sparkline data={spark} width={80} height={32} color={sparkColor ?? "currentColor"} filled />
</div>
) : null}
</FramePanel>
</Frame>
)
}
function fmtIntRu(n: number): string {
return n.toLocaleString("ru-RU")
}
@@ -725,49 +680,53 @@ export default function DashboardPage() {
<div className="flex-1 overflow-y-auto">
<div className="p-6 flex flex-col gap-6">
{/* KPI row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Серверы онлайн"
value={dashboardKpi.servers.value}
unit={dashboardKpi.servers.unit}
delta={dashboardKpi.servers.delta}
deltaDir={dashboardKpi.servers.deltaDir}
spark={dashboardKpi.servers.spark}
sparkColor={dashboardKpi.servers.sparkColor}
icon={<ServerIcon aria-hidden />}
/>
<StatCard
label="Активные фильтры"
value={dashboardKpi.filters.value}
unit={dashboardKpi.filters.unit}
delta={dashboardKpi.filters.delta}
deltaDir={dashboardKpi.filters.deltaDir}
spark={dashboardKpi.filters.spark}
sparkColor={dashboardKpi.filters.sparkColor}
icon={<FilterIcon aria-hidden />}
/>
<StatCard
label="BGP-префиксы"
value={dashboardKpi.bgp.value}
unit={dashboardKpi.bgp.unit}
delta={dashboardKpi.bgp.delta}
deltaDir={dashboardKpi.bgp.deltaDir}
spark={dashboardKpi.bgp.spark}
sparkColor={dashboardKpi.bgp.sparkColor}
icon={<GitMergeIcon aria-hidden />}
/>
<StatCard
label="Активные алерты"
value={dashboardKpi.alerts.value}
unit={dashboardKpi.alerts.unit}
delta={dashboardKpi.alerts.delta}
deltaDir={dashboardKpi.alerts.deltaDir}
spark={dashboardKpi.alerts.spark}
sparkColor={dashboardKpi.alerts.sparkColor}
icon={<BellIcon aria-hidden />}
/>
</div>
<KpiStatGrid
aria-label="Сводка дашборда"
items={[
{
id: "servers",
label: "Серверы онлайн",
value: dashboardKpi.servers.unit
? `${dashboardKpi.servers.value} ${dashboardKpi.servers.unit}`
: dashboardKpi.servers.value,
hint: dashboardKpi.servers.delta,
icon: <ServerIcon className="size-4" />,
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: <FilterIcon className="size-4" />,
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: <GitMergeIcon className="size-4" />,
iconClassName: "text-primary",
},
{
id: "alerts",
label: "Активные алерты",
value: dashboardKpi.alerts.unit
? `${dashboardKpi.alerts.value} ${dashboardKpi.alerts.unit}`
: dashboardKpi.alerts.value,
hint: dashboardKpi.alerts.delta,
icon: <BellIcon className="size-4" />,
iconClassName: dashboardKpi.alerts.deltaDir === "down" ? "text-destructive" : "text-muted-foreground",
variant: dashboardKpi.alerts.deltaDir === "down" ? "destructive" : "default",
},
]}
/>
{/* Latency chart + Events */}
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4">
+32 -23
View File
@@ -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: <CalendarClockIcon className="size-4 text-muted-foreground" />,
icon: <CalendarClockIcon className="size-4" />,
},
{
label: "Сейчас выполняется",
value: String(runningJobsCount),
sub: "Фоновые прогоны планировщика",
icon: <LoaderCircleIcon className="size-4 text-amber-500" />,
icon: <LoaderCircleIcon className="size-4" />,
},
{
label: "Трафик — последний сбор",
@@ -982,16 +981,16 @@ export default function DataCollectionPage() {
: "—",
sub: trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных",
icon: trafficCollector?.lastError ? (
<XCircleIcon className="size-4 text-destructive" />
<XCircleIcon className="size-4" />
) : (
<CheckCircleIcon className="size-4 text-emerald-500" />
<CheckCircleIcon className="size-4" />
),
},
{
label: "Журнал (в списке)",
value: String(schedulerRuns.length),
sub: errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет",
icon: <DatabaseIcon className="size-4 text-sky-500" />,
icon: <DatabaseIcon className="size-4" />,
},
],
[
@@ -1064,22 +1063,32 @@ export default function DataCollectionPage() {
{isLive && (
<>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((s) => (
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="min-w-0 flex-1 flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-xl leading-none font-bold tabular-nums truncate">{s.value}</p>
<p className="text-[11px] text-muted-foreground mt-1 leading-snug line-clamp-2">{s.sub}</p>
</div>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка сбора данных"
items={stats.map((s, i) => ({
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,
}))}
/>
<DataPageCard>
<div className="border-b border-border px-5 py-4">
+133 -137
View File
@@ -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<ServerTileItem[]>(() => (
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 (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Фильтры" }]}
actions={
<>
{isLive && (
<>
<Button
variant="outline"
size="sm"
onClick={syncFromRouter}
disabled={syncBusy !== null}
title="Синхронизация Router → БД"
>
{syncBusy === "from" ? "Синк Router → DB…" : "Router → DB"}
</Button>
<Button
variant="outline"
size="sm"
onClick={syncToRouter}
disabled={syncBusy !== null}
title="Синхронизация БД → Router"
>
{syncBusy === "to" ? "Синк DB → Router…" : "DB → Router"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void fetchRouterCompare()}
disabled={syncBusy !== null || routerCompareLoading}
title="Сравнить правила в БД с цепочкой bgp-in на MikroTik"
className="gap-1.5"
>
{routerCompareLoading ? (
<LoaderCircleIcon className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
)}
Сверить
</Button>
</>
)}
<Button variant="outline" size="sm" onClick={() => setPreviewOpen(true)}>
<FileCodeIcon className="size-4" />RouterOS
</Button>
<Button
variant="outline" size="sm"
onClick={() => setCopyOpen(true)}
disabled={currentRules.length === 0}
title="Копировать правила на другой сервер"
>
<CopyIcon className="size-4" />Копировать
</Button>
<Button size="sm" onClick={openCreate}>
<PlusIcon className="size-4" />Новое правило
</Button>
</>
}
/>
{isLive && liveLoadState === "error" && (
<div className="shrink-0 border-b border-destructive/30 bg-destructive/10 px-6 py-2.5 text-xs text-destructive flex items-center gap-2">
<AlertCircleIcon className="size-3.5 shrink-0" />
Бекенд недоступен показаны демо-данные из lib/data. Проверьте URL бекенда в настройках.
</div>
)}
{/* ── summary bar + server chips (same pattern as monitoring) ── */}
<div className="border-b bg-muted/20 px-6 py-3 flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">Всего правил</span>
<span className="font-semibold tabular-nums">{totalRules}</span>
</div>
{(() => {
const bhTotal = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0)
if (bhTotal === 0) return null
return (
<span className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded border
bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20">
{bhTotal} blackhole
</span>
)
})()}
<div className="w-px h-4 bg-border mx-1 shrink-0" />
{allServers.map(s => {
const count = rulesets.find(r => r.serverId === s.id)?.rules.length ?? 0
const active = selectedServerId === s.id
return (
<button key={s.id}
onClick={() => { setSelectedServerId(s.id); setSearch("") }}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
active
? "bg-foreground text-background border-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
!s.enabled && !active && "opacity-40",
)}>
<StatusDot status={s.status} />
<Flag code={s.country} size={12} />
<span className="font-mono">{s.name}</span>
<TypeChip type={s.type} />
<span className={cn(
"tabular-nums font-semibold",
active ? "" : count > 0 ? "text-foreground" : "opacity-40",
)}>{count}</span>
</button>
)
})}
</div>
{/* ── toolbar ── */}
<div className="px-6 py-3 flex items-center gap-3 border-b flex-wrap shrink-0">
<div className="relative min-w-[200px] max-w-xs flex-1">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input className="pl-8 h-8 text-sm" placeholder="Community, gateway, описание…"
value={search} onChange={e => setSearch(e.target.value)} />
{search && (
<button onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3.5" />
</button>
)}
</div>
<p className="text-xs text-muted-foreground ml-auto">
{filteredRules.length !== currentRules.length
? `${filteredRules.length} из ${currentRules.length} правил`
: `${currentRules.length} правил`
<>
<ServerRailLayout
items={filterRailItems}
selectedId={selectedServerId}
onSelect={handleSelectServer}
showAll={false}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Фильтры" }]}
actions={
<>
<ServerRailMobileButton />
{isLive && (
<>
<Button
variant="outline"
size="sm"
onClick={syncFromRouter}
disabled={syncBusy !== null}
title="Синхронизация Router → БД"
>
{syncBusy === "from" ? "Синк Router → DB…" : "Router → DB"}
</Button>
<Button
variant="outline"
size="sm"
onClick={syncToRouter}
disabled={syncBusy !== null}
title="Синхронизация БД → Router"
>
{syncBusy === "to" ? "Синк DB → Router…" : "DB → Router"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void fetchRouterCompare()}
disabled={syncBusy !== null || routerCompareLoading}
title="Сравнить правила в БД с цепочкой bgp-in на MikroTik"
className="gap-1.5"
>
{routerCompareLoading ? (
<LoaderCircleIcon className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
)}
Сверить
</Button>
</>
)}
<Button variant="outline" size="sm" onClick={() => setPreviewOpen(true)}>
<FileCodeIcon className="size-4" />RouterOS
</Button>
<Button
variant="outline" size="sm"
onClick={() => setCopyOpen(true)}
disabled={currentRules.length === 0}
title="Копировать правила на другой сервер"
>
<CopyIcon className="size-4" />Копировать
</Button>
<Button size="sm" onClick={openCreate}>
<PlusIcon className="size-4" />Новое правило
</Button>
</>
}
</p>
</div>
{/* ── main content ── */}
<div className="flex-1 overflow-y-auto p-6">
/>
}
banner={
<>
{isLive && liveLoadState === "error" && (
<div className="shrink-0 border-b border-destructive/30 bg-destructive/10 px-6 py-2.5 text-xs text-destructive flex items-center gap-2">
<AlertCircleIcon className="size-3.5 shrink-0" />
Бекенд недоступен показаны демо-данные из lib/data. Проверьте URL бекенда в настройках.
</div>
)}
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
<div className="relative min-w-[200px] max-w-xs flex-1">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input className="pl-8 h-8 text-sm" placeholder="Community, gateway, описание…"
value={search} onChange={e => setSearch(e.target.value)} />
{search && (
<button onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3.5" />
</button>
)}
</div>
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">Всего правил</span>
<span className="font-semibold tabular-nums">{totalRules}</span>
</div>
{(() => {
const bhTotal = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0)
if (bhTotal === 0) return null
return (
<span className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded border
bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20">
{bhTotal} blackhole
</span>
)
})()}
<p className="text-xs text-muted-foreground ml-auto">
{filteredRules.length !== currentRules.length
? `${filteredRules.length} из ${currentRules.length} правил`
: `${currentRules.length} правил`
}
</p>
</div>
</>
}
>
<div className="flex flex-col gap-4">
{/* RouterOS 7.x BGP extensions — только демо из lib/data (моки) */}
@@ -1817,7 +1813,7 @@ export default function FiltersPage() {
</DataPageCard>
</div>
</div>
</ServerRailLayout>
<RuleSheet
key={`${sheetMode}-${editingId ?? "new"}-${selectedServerId}`}
@@ -1858,6 +1854,6 @@ export default function FiltersPage() {
recRoutesByServer={recRoutesByServer}
ensureRecursiveFor={ensureRecursiveRoutes}
/>
</div>
</>
)
}
File diff suppressed because it is too large Load Diff
+108 -64
View File
@@ -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<PageTab>("tunnels")
const [tabFilter, setTabFilter] = useState<TabFilter>("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<ServerTileItem[]>(() => (
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 (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
actions={
<>
<Button
variant="outline"
size="sm"
onClick={() => { void loadLive() }}
disabled={!isLive || dataLoading}
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
>
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
Обновить
</Button>
<Button
variant="outline"
size="sm"
onClick={() => { void syncJhToDb() }}
disabled={!isLive || syncJhBusy || dataLoading}
title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE"
>
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} />
JH БД
</Button>
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
<PlusIcon className="size-4" />Добавить туннель
</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<>
<ServerRailLayout
items={greRailItems}
selectedId={selectedServerId}
onSelect={setSelectedServerId}
showAll
allCount={displayServers.length}
loading={isLive && dataLoading && displayServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
actions={
<>
<ServerRailMobileButton />
<Button
variant="outline"
size="sm"
onClick={() => { void loadLive() }}
disabled={!isLive || dataLoading}
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
>
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
Обновить
</Button>
<Button
variant="outline"
size="sm"
onClick={() => { void syncJhToDb() }}
disabled={!isLive || syncJhBusy || dataLoading}
title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE"
>
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} />
JH БД
</Button>
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
<PlusIcon className="size-4" />Добавить туннель
</Button>
</>
}
/>
}
>
<div className="flex flex-col gap-5">
{/* Legacy banner */}
@@ -421,27 +453,39 @@ export default function GrePage() {
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
{[
{ label: "Всего туннелей", value: displayTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
{ label: "Активно", value: upCount, icon: <ShieldCheckIcon className="size-4 text-emerald-500" /> },
{ label: "Защищены IPsec", value: ipsecCount, icon: <LockIcon className="size-4 text-violet-400" /> },
{ label: "IP-пулов", value: displayPools.length, icon: <NetworkIcon className="size-4 text-sky-400" /> },
].map((s) => (
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</div>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка GRE"
items={[
{
id: "tunnels",
label: "Всего туннелей",
value: displayTunnels.length,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "up",
label: "Активно",
value: upCount,
icon: <ShieldCheckIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "ipsec",
label: "Защищены IPsec",
value: ipsecCount,
icon: <LockIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "pools",
label: "IP-пулов",
value: displayPools.length,
icon: <DatabaseIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{/* Page tabs */}
<div className="flex items-center gap-1 border-b">
@@ -545,7 +589,7 @@ export default function GrePage() {
</div>
</OpsPanel>
</div>
</div>
</ServerRailLayout>
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
<CodeExportSheet
@@ -801,6 +845,6 @@ export default function GrePage() {
</SheetFooter>
</SheetContent>
</Sheet>
</div>
</>
)
}
+150 -133
View File
@@ -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<string, number> = {}
const byRouter: Record<string, OspfItem[]> = {}
@@ -921,20 +924,33 @@ function NeighborsTab({
return (
<div className="flex flex-col gap-5">
<div className="grid grid-cols-3 gap-3">
{[
{ 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 => (
<Frame key={s.label} className="h-full">
<FramePanel className="flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка соседей OSPF"
items={[
{
id: "neighbors",
label: "Всего соседей",
value: neighbors.length,
icon: <UsersIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "full",
label: "Full",
value: fullCount,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "not-full",
label: "Не Full",
value: neighbors.length - fullCount,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: neighbors.length - fullCount > 0 ? "text-warning" : "text-muted-foreground",
variant: neighbors.length - fullCount > 0 ? "warning" : "default",
},
]}
/>
{graphNodes.length > 0 && (
<div className="flex rounded-xl overflow-hidden border border-white/[0.06]">
@@ -1044,21 +1060,41 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ 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 => (
<Frame key={s.label} className="h-full">
<FramePanel className="flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка BFD"
items={[
{
id: "sessions",
label: "Сессий BFD",
value: sessions.length,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "up",
label: "Up",
value: upCount,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "down",
label: "Down / Admin",
value: downCount,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: downCount > 0 ? "text-destructive" : "text-muted-foreground",
variant: downCount > 0 ? "destructive" : "default",
},
{
id: "init",
label: "Init / другие",
value: initCount,
icon: <LayersIcon className="size-4" />,
iconClassName: initCount > 0 ? "text-warning" : "text-muted-foreground",
variant: initCount > 0 ? "warning" : "default",
},
]}
/>
{sessions.length === 0 && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-8 text-center text-sm text-muted-foreground">
@@ -1096,7 +1132,7 @@ const TABS: Array<{ id: OspfTab; label: string; icon: React.ReactNode }> = [
export default function OspfPage() {
const [activeTab, setActiveTab] = useState<OspfTab>("interfaces")
const [filterServerId, setFilterServerId] = useState<string>("all")
const [filterServerId, setFilterServerId] = useState<string>(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<ServerTileItem[]>(() => (
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 (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "OSPF" }]}
actions={
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
}
/>
<div className="border-b bg-background shrink-0">
<div className="flex items-center px-6">
{TABS.map(t => (
<button key={t.id} onClick={() => setActiveTab(t.id)}
className={cn(
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === t.id
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
)}>
{t.icon}{t.label}
</button>
))}
</div>
</div>
{/* ── server filter chips (same pattern as Filters page) ─────────────── */}
{ospfServers.length > 0 && (
<div className="border-b bg-muted/20 px-6 py-2.5 flex items-center gap-2 flex-wrap shrink-0">
{/* "All" chip */}
<button
onClick={() => setFilterServerId("all")}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
filterServerId === "all"
? "bg-foreground text-background border-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
)}>
Все серверы
<span className={cn(
"tabular-nums font-semibold",
filterServerId === "all" ? "" : "text-foreground/60",
)}>{items.length}</span>
</button>
<div className="w-px h-4 bg-border shrink-0" />
{ospfServers.map(s => {
const counts = serverCounts[s.id]
const active = filterServerId === s.id
return (
<button key={s.id} onClick={() => setFilterServerId(s.id)}
<ServerRailLayout
items={ospfRailItems}
selectedId={filterServerId}
onSelect={setFilterServerId}
showAll
allCount={ospfServers.length}
loading={isLive && loading && ospfServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "OSPF" }]}
actions={
<>
<ServerRailMobileButton />
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
</>
}
/>
}
banner={
<div className="border-b bg-background shrink-0">
<div className="flex items-center px-6">
{TABS.map(t => (
<button key={t.id} onClick={() => setActiveTab(t.id)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
active
? "bg-foreground text-background border-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === t.id
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
)}>
{s.country ? <Flag code={s.country} size={12} /> : null}
{s.site && (
<span className={cn(
"inline-block px-1 py-0 rounded text-[9px] font-bold leading-4",
active
? "bg-white/20"
: "bg-muted-foreground/15 text-foreground/70",
)}>{s.site}</span>
)}
<span className="font-mono">{s.label.replace(/^mt-/, "")}</span>
{counts && (
<span className={cn(
"tabular-nums text-[10px]",
active ? "opacity-80" : "text-foreground/50",
)}>
{counts.neighbors}n · {counts.ifaces}i
{counts.bfd > 0 && ` · ${counts.bfd}b`}
</span>
)}
{t.icon}{t.label}
</button>
)
})}
))}
</div>
</div>
)}
<div className="flex-1 overflow-y-auto p-6">
}
>
<div className="flex flex-col gap-5">
{/* data source banner */}
@@ -1346,21 +1353,32 @@ export default function OspfPage() {
</span>
)}
{/* KPI strip */}
<div className="grid grid-cols-3 gap-3">
{[
{ label: "Роутеров", value: totalRouters },
{ label: "Интерфейсов", value: totalInterfaces },
{ label: "Зон (Area)", value: totalAreas },
].map(s => (
<Frame key={s.label} className="h-full">
<FramePanel className="flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка OSPF"
items={[
{
id: "routers",
label: "Роутеров",
value: totalRouters,
icon: <RouterIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "ifaces",
label: "Интерфейсов",
value: totalInterfaces,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "areas",
label: "Зон (Area)",
value: totalAreas,
icon: <LayersIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{activeTab === "interfaces" && (
<InterfacesTab
@@ -1384,7 +1402,6 @@ export default function OspfPage() {
{activeTab === "bfd" && <BfdTab sessions={displayBfdSessions} />}
</div>
</div>
</div>
</ServerRailLayout>
)
}
+53 -32
View File
@@ -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<React.SetStateAction<SchedRule[]>>
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<SchedType>("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<ServerTileItem[]>(() => (
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 (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]}
actions={
running.length > 0
? <Button variant="outline" size="sm" onClick={() => {
liveProbeRunRef.current?.ctrl.abort()
setTests(p => p.map(t => (t.status === "running"
? { ...t, status: "done" as const, totalLines: t.lines.length }
: t)))
}}>
<SquareIcon className="size-4" />Остановить все
</Button>
: undefined
}
/>
<div className="flex-1 overflow-y-auto p-6">
<ServerRailLayout
items={probeRailItems}
selectedId={srcId}
onSelect={setSrcId}
showAll={false}
loading={isLive && liveLoad === "loading" && allServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]}
actions={
<>
<ServerRailMobileButton />
{running.length > 0
? <Button variant="outline" size="sm" onClick={() => {
liveProbeRunRef.current?.ctrl.abort()
setTests(p => p.map(t => (t.status === "running"
? { ...t, status: "done" as const, totalLines: t.lines.length }
: t)))
}}>
<SquareIcon className="size-4" />Остановить все
</Button>
: null}
</>
}
/>
}
>
<div className="flex flex-col gap-4">
{isLive && liveLoad === "error" && (
@@ -916,7 +947,7 @@ export default function ProbesPage() {
{isLive && (
<p className="text-xs text-muted-foreground">
Режим «Живые»: 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.
</p>
)}
@@ -945,16 +976,6 @@ export default function ProbesPage() {
{/* main config row */}
<div className="flex flex-wrap items-end gap-3">
{/* source server */}
<div>
<OptionLabel>Источник</OptionLabel>
<NativeSelect value={srcId} onChange={setSrcId} className="min-w-[175px]">
{allServers.filter(s => s.enabled).map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</NativeSelect>
</div>
{/* target — all tools except bandwidth */}
{tool !== "bandwidth" && (
<div className="flex-1 min-w-[140px]">
@@ -1169,13 +1190,13 @@ export default function ProbesPage() {
setRules={setRules}
serverOptions={allServers}
tunnelsForServer={sid => greTunnels.filter(t => t.serverId === sid)}
defaultSrc={srcId}
/>
)
)}
</div>
</div>
</div>
</div>
</div>
</ServerRailLayout>
)
}
+72 -76
View File
@@ -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<ServerTileItem[]>(() => (
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 (
<div className="flex h-full flex-col">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Рекурсивные маршруты" }]}
actions={
<>
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}>
{busy === "from" ? "Синхронизация..." : "Router => DB"}
</Button>
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}>
{busy === "to" ? "Применение..." : "DB => Router"}
</Button>
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}>
<SaveIcon className="size-4" />Сохранить в БД
</Button>
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}>
<PlusIcon className="size-4" />Добавить
</Button>
</>
}
/>
<div className="border-b bg-muted/20 px-6 py-3 flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">Всего маршрутов</span>
<span className="font-semibold tabular-nums">{rows.length}</span>
</div>
<div className="w-px h-4 bg-border mx-1 shrink-0" />
{servers.map((s) => {
const count = s.id === selectedServerId ? rows.length : 0
const active = selectedServerId === s.id
return (
<button key={s.id}
onClick={() => setSelectedServerId(s.id)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
active
? "bg-foreground text-background border-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
!s.enabled && !active && "opacity-40",
)}>
<StatusDot status={s.status} />
<Flag code={s.country} size={12} />
<span className="font-mono">{s.name}</span>
<TypeChip type={s.type} />
<span className={cn(
"tabular-nums font-semibold",
active ? "" : count > 0 ? "text-foreground" : "opacity-40",
)}>{count}</span>
</button>
)
})}
</div>
<div className="px-6 py-3 flex items-center gap-3 border-b flex-wrap shrink-0">
<div className="relative min-w-[200px] max-w-xs flex-1">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
value={search} onChange={(e) => setSearch(e.target.value)} />
{search && (
<button onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3.5" />
</button>
<>
<ServerRailLayout
items={rrRailItems}
selectedId={selectedServerId}
onSelect={setSelectedServerId}
showAll={false}
showCount={false}
loading={isLive && !liveServerListReady}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Рекурсивные маршруты" }]}
actions={
<>
<ServerRailMobileButton />
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}>
{busy === "from" ? "Синхронизация..." : "Router => DB"}
</Button>
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}>
{busy === "to" ? "Применение..." : "DB => Router"}
</Button>
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}>
<SaveIcon className="size-4" />Сохранить в БД
</Button>
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}>
<PlusIcon className="size-4" />Добавить
</Button>
</>
}
/>
}
banner={
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
<div className="relative min-w-[200px] max-w-xs flex-1">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
value={search} onChange={(e) => setSearch(e.target.value)} />
{search && (
<button onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3.5" />
</button>
)}
</div>
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">Всего маршрутов</span>
<span className="font-semibold tabular-nums">{rows.length}</span>
</div>
<p className="text-xs text-muted-foreground ml-auto">
{filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
</p>
{opError && (
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{opError}
</div>
)}
</div>
<p className="text-xs text-muted-foreground ml-auto">
{filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
</p>
{opError && (
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{opError}
</div>
)}
</div>
<div className="flex-1 overflow-y-auto p-6">
}
>
{!isLive ? (
<Frame dense className="w-full">
<FramePanel className="p-6 text-sm text-muted-foreground">
@@ -692,7 +688,7 @@ export default function RecursiveRoutesPage() {
</button>
</DataPageCard>
)}
</div>
</ServerRailLayout>
<RouteSheet
open={sheetOpen}
@@ -702,6 +698,6 @@ export default function RecursiveRoutesPage() {
onClose={() => setSheetOpen(false)}
gateways={gatewayOptions}
/>
</div>
</>
)
}
+23 -29
View File
@@ -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: <MonitorIcon className="size-4 text-muted-foreground" />,
icon: <MonitorIcon className="size-4" />,
},
{
label: "JumpHost",
value: jh.length,
sub: jhSub,
icon: <ServerIcon className="size-4 text-violet-400" />,
icon: <ServerIcon className="size-4" />,
},
{
label: "Exit Node",
value: ex.length,
sub: exSub,
icon: <NetworkIcon className="size-4 text-emerald-500" />,
icon: <NetworkIcon className="size-4" />,
},
{
label: "Переключений",
value: totalSwitches,
sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально",
icon: (
<ZapIcon
className={cn(
"size-4",
totalSwitches > 0 ? "text-amber-500" : "text-muted-foreground",
)}
/>
),
icon: <ZapIcon className="size-4" />,
},
]
}, [useLiveData, data, liveJumpHosts, liveExitNodes, totalSwitches])
@@ -762,23 +755,24 @@ export default function RouteOptimizerPage() {
)}
</div>
{/* Stats chips */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{statsChips.map((s) => (
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-xl leading-none font-bold tabular-nums">{s.value}</p>
<p className="text-[10px] text-muted-foreground">{s.sub}</p>
</div>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка оптимизатора"
items={statsChips.map((s, i) => ({
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 && (
<div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
+34 -23
View File
@@ -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() {
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ label: "Всего серверов", value: counts.all, icon: <ServerIcon className="size-4" />, iconClass: "text-muted-foreground" },
{ label: "Онлайн", value: counts.online, icon: <CheckCircleIcon className="size-4" />, iconClass: "text-[var(--status-online-fg)]" },
{ label: "JH + Exit Node", value: counts["jump-host"] + counts["exit-node"], icon: <NetworkIcon className="size-4" />, iconClass: "text-muted-foreground" },
{ label: "Home Router", value: counts["home-router"],icon: <HomeIcon className="size-4" />, iconClass: "text-muted-foreground" },
].map(s => (
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className={cn("size-10.5", s.iconClass)}>
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</div>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка серверов"
items={[
{
id: "all",
label: "Всего серверов",
value: counts.all,
icon: <ServerIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "online",
label: "Онлайн",
value: counts.online,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "jh-en",
label: "JH + Exit Node",
value: counts["jump-host"] + counts["exit-node"],
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "home",
label: "Home Router",
value: counts["home-router"],
icon: <HomeIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
]}
/>
{/* Table */}
<DataPageCard>
+41 -90
View File
@@ -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<TermServer[]>([])
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
? <Loader2Icon className="size-3.5 animate-spin text-muted-foreground" />
: <Badge variant="success-light" size="xs">LIVE</Badge>
const railHeaderRight = isLive && !serversLoading
? <Badge variant="success-light" size="xs">LIVE</Badge>
: undefined
const rail = (
<ServerTileRail
items={railItems}
selectedId={selected?.uid ?? selectedUid}
onSelect={handleSelectServer}
showAll={false}
showCount={false}
showType={false}
headerRight={railHeaderRight}
className="min-h-0 flex-1"
/>
)
function QuickCmds() {
return (
<Frame dense spacing="sm" className="min-h-0 shrink-0">
@@ -595,42 +573,39 @@ export default function TerminalPage() {
}
return (
<div className="flex h-full flex-col">
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
actions={
<>
<Button
size="sm"
variant="outline"
className="md:hidden"
onClick={() => setRailOpen(true)}
>
<ServerIcon className="size-4" />
{selected?.name ?? "Сервер"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setRefreshKey((k) => k + 1)
if (isLive) setSelectedUid("")
}}
>
<RefreshCwIcon className="size-4" />
Переподключить
</Button>
</>
}
/>
<div className="flex min-h-0 flex-1">
<aside className="hidden min-h-0 w-60 shrink-0 flex-col gap-3 p-3 pr-0 md:flex">
{rail}
<QuickCmds />
</aside>
<div className="min-w-0 flex-1 overflow-hidden p-3 md:p-4">
<ServerRailLayout
items={railItems}
selectedId={selected?.uid ?? selectedUid}
onSelect={handleSelectServer}
showAll={false}
showCount={false}
showType={false}
headerRight={railHeaderRight}
loading={serversLoading}
extra={<QuickCmds />}
contentClassName="overflow-hidden p-3 md:p-4"
header={
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
actions={
<>
<ServerRailMobileButton />
<Button
variant="outline"
size="sm"
onClick={() => {
setRefreshKey((k) => k + 1)
if (isLive) setSelectedUid("")
}}
>
<RefreshCwIcon className="size-4" />
Переподключить
</Button>
</>
}
/>
}
>
{selected ? (
<Terminal
key={termKey}
@@ -643,30 +618,6 @@ export default function TerminalPage() {
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
</div>
)}
</div>
</div>
<Sheet open={railOpen} onOpenChange={setRailOpen}>
<SheetContent side="left" className="flex w-72 flex-col gap-3 p-3" showCloseButton>
<SheetHeader className="px-1 pt-1">
<SheetTitle>Серверы</SheetTitle>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-3">
<ServerTileRail
items={railItems}
selectedId={selected?.uid ?? selectedUid}
onSelect={handleSelectServer}
showAll={false}
showCount={false}
showType={false}
showHeader={false}
headerRight={railHeaderRight}
className="min-h-0 flex-1"
/>
<QuickCmds />
</div>
</SheetContent>
</Sheet>
</div>
</ServerRailLayout>
)
}
+128 -134
View File
@@ -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 (
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height, display: "block" }} preserveAspectRatio="none">
<path d={area(rx)} fill="hsl(var(--primary))" fillOpacity={0.12} />
<polyline points={line(rx)} fill="none" stroke="hsl(var(--primary))" strokeWidth="1.4" strokeLinejoin="round" />
<path d={area(tx)} fill="#3b82f6" fillOpacity={0.10} />
<polyline points={line(tx)} fill="none" stroke="#3b82f6" strokeWidth="1.4" strokeLinejoin="round" />
</svg>
)
}
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 (
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: H, display: "block" }}>
<defs>
<linearGradient id="rx-big" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.22" />
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity="0" />
</linearGradient>
<linearGradient id="tx-big" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.18" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0" />
</linearGradient>
</defs>
{gridVals.map((p, i) => {
const y = pad.t + ih * (1 - p)
return (
<g key={i}>
<line x1={pad.l} x2={W - pad.r} y1={y} y2={y}
stroke="hsl(var(--border))" strokeDasharray={p === 0 ? "0" : "2 4"} />
<text x={pad.l - 8} y={y + 4} textAnchor="end" fontSize="10"
fill="hsl(var(--muted-foreground))" fontFamily="monospace">
{fmtMbps(Math.round(maxVal * p))}
</text>
</g>
)
})}
<path d={area(rx)} fill="url(#rx-big)" />
<polyline points={poly(rx)} fill="none" stroke="hsl(var(--primary))" strokeWidth="1.8" strokeLinejoin="round" />
<path d={area(tx)} fill="url(#tx-big)" />
<polyline points={poly(tx)} fill="none" stroke="#3b82f6" strokeWidth="1.8" strokeLinejoin="round" />
{[0, 12, 24, 36, 48, 59].map(i => (
<text key={i} x={xAt(i, 60)} y={H - 8} textAnchor="middle" fontSize="10"
fill="hsl(var(--muted-foreground))" fontFamily="monospace">
-{60 - i}м
</text>
))}
<path d={area(rx)} fill="var(--chart-rx)" fillOpacity={0.12} />
<polyline points={line(rx)} fill="none" stroke="var(--chart-rx)" strokeWidth="1.4" strokeLinejoin="round" />
<path d={area(tx)} fill="var(--chart-tx)" fillOpacity={0.10} />
<polyline points={line(tx)} fill="none" stroke="var(--chart-tx)" strokeWidth="1.4" strokeLinejoin="round" />
</svg>
)
}
@@ -401,11 +341,11 @@ function ServerCard({ s, selected, onClick }: { s: ServerTraffic; selected: bool
<div className="flex justify-between mt-2 gap-2">
<div className="flex items-center gap-1 text-[11px]">
<ArrowDownIcon className="size-3 text-emerald-500" />
<span className="font-mono font-medium text-emerald-500">{fmtMbps(s.rxNow)}</span>
<span className="font-mono font-medium text-emerald-500">{fmtRate(s.rxNow)}</span>
</div>
<div className="flex items-center gap-1 text-[11px]">
<ArrowUpIcon className="size-3 text-blue-500" />
<span className="font-mono font-medium text-blue-500">{fmtMbps(s.txNow)}</span>
<span className="font-mono font-medium text-blue-500">{fmtRate(s.txNow)}</span>
</div>
{s.greClients.length > 0 && (
<div className="flex items-center gap-0.5 text-[10px] text-muted-foreground">
@@ -445,11 +385,11 @@ function UserCard({ u, selected, onClick }: { u: UserTraffic; selected: boolean;
<div className="flex justify-between mt-2 gap-2">
<div className="flex items-center gap-1 text-[11px]">
<ArrowDownIcon className="size-3 text-emerald-500" />
<span className="font-mono font-medium text-emerald-500">{fmtMbps(u.rxNow)}</span>
<span className="font-mono font-medium text-emerald-500">{fmtRate(u.rxNow)}</span>
</div>
<div className="flex items-center gap-1 text-[11px]">
<ArrowUpIcon className="size-3 text-blue-500" />
<span className="font-mono font-medium text-blue-500">{fmtMbps(u.txNow)}</span>
<span className="font-mono font-medium text-blue-500">{fmtRate(u.txNow)}</span>
</div>
</div>
</>
@@ -483,11 +423,11 @@ function GreCard({ c, selected, onClick }: { c: GreClientTraffic; selected: bool
<div className="flex justify-between mt-2 gap-2">
<div className="flex items-center gap-1 text-[11px]">
<ArrowDownIcon className="size-3 text-emerald-500" />
<span className="font-mono font-medium text-emerald-500">{fmtMbps(c.rxNow)}</span>
<span className="font-mono font-medium text-emerald-500">{fmtRate(c.rxNow)}</span>
</div>
<div className="flex items-center gap-1 text-[11px]">
<ArrowUpIcon className="size-3 text-blue-500" />
<span className="font-mono font-medium text-blue-500">{fmtMbps(c.txNow)}</span>
<span className="font-mono font-medium text-blue-500">{fmtRate(c.txNow)}</span>
</div>
</div>
</button>
@@ -525,10 +465,10 @@ function GreClientRow({ c, showServer = false }: { c: GreClientTraffic; showServ
{/* live RX / TX */}
<div className="shrink-0 text-right leading-tight">
<div className="flex items-center gap-1 justify-end text-xs font-mono font-medium text-emerald-600 dark:text-emerald-400">
<ArrowDownIcon className="size-3" />{fmtMbps(c.rxNow)}
<ArrowDownIcon className="size-3" />{fmtRate(c.rxNow)}
</div>
<div className="flex items-center gap-1 justify-end text-xs font-mono font-medium text-blue-600 dark:text-blue-400">
<ArrowUpIcon className="size-3" />{fmtMbps(c.txNow)}
<ArrowUpIcon className="size-3" />{fmtRate(c.txNow)}
</div>
</div>
@@ -581,19 +521,6 @@ function DetailHeader({ range, setRange, children }: {
)
}
function ChartLegend() {
return (
<div className="flex gap-5 mb-2">
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-block size-2 rounded-full bg-primary/70" />RX (входящий)
</span>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-block size-2 rounded-full bg-blue-500/70" />TX (исходящий)
</span>
</div>
)
}
function OfflinePlaceholder({ text = "Нет данных — объект недоступен" }: { text?: string }) {
return (
<div className="flex items-center justify-center h-[220px] text-muted-foreground/40">
@@ -611,18 +538,18 @@ function TotalsRow({ rxTotal, txTotal, rxSeries, txSeries }: {
return (
<div className="mt-4 pt-4 border-t grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-muted-foreground mb-1">Получено сегодня</p>
<p className="text-xs text-muted-foreground mb-1">Получено за период</p>
<p className="text-lg font-semibold tabular-nums">
{rxTotal.toFixed(1)} <span className="text-sm font-normal text-muted-foreground">ГБ</span>
</p>
<Sparkline data={rxSeries} width={180} height={28} color="hsl(var(--primary))" filled />
<Sparkline data={rxSeries} width={180} height={28} color="var(--chart-rx)" filled />
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">Отправлено сегодня</p>
<p className="text-xs text-muted-foreground mb-1">Отправлено за период</p>
<p className="text-lg font-semibold tabular-nums">
{txTotal.toFixed(1)} <span className="text-sm font-normal text-muted-foreground">ГБ</span>
</p>
<Sparkline data={txSeries} width={180} height={28} color="#3b82f6" filled />
<Sparkline data={txSeries} width={180} height={28} color="var(--chart-tx)" filled />
</div>
</div>
)
@@ -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 (
<>
<DetailHeader range={range} setRange={setRange}>
@@ -640,13 +578,43 @@ function ServerDetail({ sel, range, setRange }: { sel: ServerTraffic; range: Ran
<Flag code={sel.country} />{sel.site}
</span>
</DetailHeader>
<ChartLegend />
{sel.status === "offline" ? <OfflinePlaceholder /> : <BigChart rx={sel.rxSeries} tx={sel.txSeries} />}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4 pt-4 border-t">
<StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX сейчас" value={fmtMbps(sel.rxNow)} />
<StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX сейчас" value={fmtMbps(sel.txNow)} />
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик RX" value={fmtMbps(sel.rxPeak)} />
<StatChip icon={<TrendingDownIcon className="size-3.5 text-purple-500" />} label="Сессий" value={`${sel.sessions}`} />
{sel.status === "offline" ? <OfflinePlaceholder /> : <TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />}
<div className="mt-4 pt-4 border-t">
<KpiStatGrid
aria-label="Скорость выбранного сервера"
items={[
{
id: "rx-now",
label: "RX сейчас",
value: fmtRate(rxNow),
hint: liveHint,
icon: <ArrowDownIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "tx-now",
label: "TX сейчас",
value: fmtRate(txNow),
hint: liveHint,
icon: <ArrowUpIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "rx-peak",
label: "Пик RX",
value: fmtRate(sel.rxPeak),
icon: <TrendingUpIcon className="size-4" />,
iconClassName: "text-warning",
},
{
id: "tx-peak",
label: "Пик TX",
value: fmtRate(sel.txPeak),
icon: <TrendingDownIcon className="size-4" />,
iconClassName: "text-warning",
},
]}
/>
</div>
<TotalsRow rxTotal={sel.rxTotal} txTotal={sel.txTotal} rxSeries={sel.rxSeries} txSeries={sel.txSeries} />
{sel.greClients.length > 0 && (
@@ -680,12 +648,11 @@ function UserDetail({ sel, range, setRange }: { sel: UserTraffic; range: Range;
<OfflinePlaceholder text="Нет GRE-клиентов у этого пользователя" />
) : (
<>
<ChartLegend />
<BigChart rx={sel.rxSeries} tx={sel.txSeries} />
<TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4 pt-4 border-t">
<StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX сейчас" value={fmtMbps(sel.rxNow)} />
<StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX сейчас" value={fmtMbps(sel.txNow)} />
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик RX" value={fmtMbps(sel.rxPeak)} />
<StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX сейчас" value={fmtRate(sel.rxNow)} />
<StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX сейчас" value={fmtRate(sel.txNow)} />
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик RX" value={fmtRate(sel.rxPeak)} />
<StatChip icon={<CableIcon className="size-3.5 text-purple-500" />} label="GRE-клиентов" value={`${sel.greClients.length}`} />
</div>
<TotalsRow rxTotal={sel.rxTotal} txTotal={sel.txTotal} rxSeries={sel.rxSeries} txSeries={sel.txSeries} />
@@ -716,13 +683,12 @@ function GreDetail({ sel, range, setRange }: { sel: GreClientTraffic; range: Ran
<Flag code={sel.serverCountry} />{sel.serverSite}
</span>
</DetailHeader>
<ChartLegend />
{sel.status === "offline" ? <OfflinePlaceholder /> : <BigChart rx={sel.rxSeries} tx={sel.txSeries} />}
{sel.status === "offline" ? <OfflinePlaceholder /> : <TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4 pt-4 border-t">
<StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX сейчас" value={fmtMbps(sel.rxNow)} />
<StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX сейчас" value={fmtMbps(sel.txNow)} />
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик RX" value={fmtMbps(sel.rxPeak)} />
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик TX" value={fmtMbps(sel.txPeak)} />
<StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX сейчас" value={fmtRate(sel.rxNow)} />
<StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX сейчас" value={fmtRate(sel.txNow)} />
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик RX" value={fmtRate(sel.rxPeak)} />
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик TX" value={fmtRate(sel.txPeak)} />
</div>
<TotalsRow rxTotal={sel.rxTotal} txTotal={sel.txTotal} rxSeries={sel.rxSeries} txSeries={sel.txSeries} />
<div className="mt-4 pt-4 border-t grid grid-cols-2 sm:grid-cols-3 gap-4">
@@ -778,6 +744,12 @@ export default function TrafficPage() {
const [detailBusy, setDetailBusy] = useState(false)
const [liveDetailServer, setLiveDetailServer] = useState<ServerTraffic | null>(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() {
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* ── summary stat cards ── */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ icon: <ArrowDownIcon className="size-3.5 text-emerald-500" />, label: "RX сейчас", value: fmtMbps(totalRx) },
{ icon: <ArrowUpIcon className="size-3.5 text-blue-500" />, label: "TX сейчас", value: fmtMbps(totalTx) },
{ icon: <TrendingUpIcon className="size-3.5 text-amber-500" />, label: "Пик RX", value: fmtMbps(peakRx) },
{ icon: <TrendingUpIcon className="size-3.5 text-amber-500" />, label: "Пик TX", value: fmtMbps(peakTx) },
].map(({ icon, label, value }) => (
<Frame key={label} className="h-full overflow-hidden">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{label}</p>
<p className="text-2xl leading-none font-bold tabular-nums tracking-tight">{value}</p>
</div>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка трафика"
items={[
{
id: "rx",
label: "RX сейчас",
value: fmtRate(totalRx),
icon: <ArrowDownIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "tx",
label: "TX сейчас",
value: fmtRate(totalTx),
icon: <ArrowUpIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "peak-rx",
label: "Пик RX",
value: fmtRate(peakRx),
icon: <TrendingUpIcon className="size-4" />,
iconClassName: "text-warning",
},
{
id: "peak-tx",
label: "Пик TX",
value: fmtRate(peakTx),
icon: <TrendingUpIcon className="size-4" />,
iconClassName: "text-warning",
},
]}
/>
<div className="grid grid-cols-[300px_1fr] gap-5 items-start">
@@ -1091,7 +1076,16 @@ export default function TrafficPage() {
</div>
</div>
)}
{effectiveMode === "servers" && (liveDetailServer ?? selServer) && <ServerDetail sel={(liveDetailServer ?? selServer)!} range={range} setRange={setRange} />}
{effectiveMode === "servers" && detailServer && (
<ServerDetail
sel={detailServer}
range={range}
setRange={setRange}
liveRx={liveSample?.rxMbps}
liveTx={liveSample?.txMbps}
liveHint={liveSample ? "live" : (liveStreamError ? "история" : undefined)}
/>
)}
{effectiveMode === "users" && <UserDetail sel={selUser} range={range} setRange={setRange} />}
{effectiveMode === "gre" && <GreDetail sel={selGre} range={range} setRange={setRange} />}
</FramePanel>
+89 -46
View File
@@ -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
</Alert>
)}
{/* ── KPI summary ───────────────────────────────────────────────────── */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{[
{ icon: <ServerIcon className="size-4 text-muted-foreground" />, label: String(rows.length), sub: "серверов всего", color: "text-foreground" },
{ icon: <CpuIcon className="size-4" />, label: `${avgCpu}%`, sub: "средний CPU", color: resPctColor(avgCpu) },
{ icon: <HardDriveIcon className="size-4" />, label: `${avgRam}%`, sub: "средний RAM", color: resPctColor(avgRam) },
{ icon: <AlertCircleIcon className="size-4" />, label: String(highCpu), sub: "CPU > 85%", color: highCpu > 0 ? "text-red-500" : "text-muted-foreground" },
{ icon: <AlertCircleIcon className="size-4" />, label: String(highRam), sub: "RAM > 85%", color: highRam > 0 ? "text-red-500" : "text-muted-foreground" },
{ icon: <AlertCircleIcon className="size-4" />, label: String(highHdd), sub: "Диск > 85%", color: highHdd > 0 ? "text-amber-500" : "text-muted-foreground" },
].map(kpi => (
<Frame key={kpi.sub} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className={cn("size-10.5", kpi.color)}>
{kpi.icon}
</IconTile>
<div className="min-w-0 flex flex-col gap-0.5">
<p className={cn("text-xl leading-none font-bold tabular-nums", kpi.color)}>{kpi.label}</p>
<p className="text-[11px] text-muted-foreground">{kpi.sub}</p>
</div>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка ресурсов"
items={[
{
id: "servers",
label: "Серверов всего",
value: rows.length,
icon: <ServerIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "cpu",
label: "Средний CPU",
value: `${avgCpu}%`,
icon: <CpuIcon className="size-4" />,
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: <HardDriveIcon className="size-4" />,
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: <AlertCircleIcon className="size-4" />,
iconClassName: highCpu > 0 ? "text-destructive" : "text-muted-foreground",
variant: highCpu > 0 ? "destructive" : "default",
},
{
id: "high-ram",
label: "RAM > 85%",
value: highRam,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: highRam > 0 ? "text-destructive" : "text-muted-foreground",
variant: highRam > 0 ? "destructive" : "default",
},
{
id: "high-hdd",
label: "Диск > 85%",
value: highHdd,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: highHdd > 0 ? "text-warning" : "text-muted-foreground",
variant: highHdd > 0 ? "warning" : "default",
},
]}
/>
{/* ── Table ─────────────────────────────────────────────────────────── */}
<DataPageCard>
@@ -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 (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 px-6 py-4 border-b bg-muted/10 shrink-0">
{[
{ 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 => (
<Frame key={k.label} className="h-full">
<FramePanel className="flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{k.label}</p>
<div className="flex items-baseline gap-1">
<span className={cn("text-2xl leading-none font-bold tabular-nums", k.color)}>{k.value}</span>
{k.unit && <span className="text-xs text-muted-foreground">{k.unit}</span>}
</div>
{runningCnt > 0 && k.label === "Тестов выполнено" && (
<p className="text-[11px] text-[var(--status-degraded-fg)] flex items-center gap-1 mt-0.5">
<RefreshCwIcon className="size-2.5 animate-spin" />{runningCnt} выполняется
</p>
)}
</FramePanel>
</Frame>
))}
<div className="px-6 py-4 border-b bg-muted/10 shrink-0">
<KpiStatGrid
aria-label="Сводка speed-проб"
items={[
{
id: "probes",
label: "Speed-пробы",
value: `${speedProbes.length} шт`,
icon: <ArrowUpDownIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "runs",
label: "Тестов выполнено",
value: `${doneRuns.length} run`,
hint: runningCnt > 0 ? `${runningCnt} выполняется` : undefined,
icon: <PlayIcon className="size-4" />,
iconClassName: runningCnt > 0 ? "text-warning" : "text-muted-foreground",
variant: runningCnt > 0 ? "warning" : "default",
},
{
id: "max-tx",
label: "Макс TX",
value: maxTx != null ? `${maxTx} Мбит/с` : "—",
icon: <ArrowUpIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "max-rx",
label: "Макс RX",
value: maxRx != null ? `${maxRx} Мбит/с` : "—",
icon: <ArrowDownIcon className="size-4" />,
iconClassName: "text-success",
},
]}
/>
</div>
)
})()}
+34 -24
View File
@@ -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() {
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* KPI */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Туннелей", value: vxlanTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
{ label: "Активных", value: upCount, icon: <LayersIcon className="size-4 text-emerald-500" /> },
{ label: "Уникальных VNI", value: vnis, icon: <LayersIcon className="size-4 text-sky-400" /> },
{ label: "Серверов", value: new Set(vxlanTunnels.map((t) => t.serverId)).size, icon: <NetworkIcon className="size-4 text-violet-400" /> },
].map((s) => (
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</div>
</FramePanel>
</Frame>
))}
</div>
<KpiStatGrid
aria-label="Сводка VXLAN"
items={[
{
id: "tunnels",
label: "Туннелей",
value: vxlanTunnels.length,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "up",
label: "Активных",
value: upCount,
icon: <LayersIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "vni",
label: "Уникальных VNI",
value: vnis,
icon: <LayersIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "servers",
label: "Серверов",
value: new Set(vxlanTunnels.map((t) => t.serverId)).size,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{/* Info banner */}
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
+41 -89
View File
@@ -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<WgWorkspaceTab>("interfaces")
const [statusFilter, setStatusFilter] = useState<WgStatusFilter>("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() {
</Button>
)
const rail = (
<ServerTileRail
return (
<>
<ServerRailLayout
items={railItems}
selectedId={effectiveServerId}
onSelect={handleSelectServer}
onSelect={setSelectedServerId}
showAll
allCount={displayIfaces.length}
/>
)
return (
<div className="flex h-full flex-col">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
actions={
<>
<Button
size="sm"
variant="outline"
className="md:hidden"
onClick={() => setRailOpen(true)}
>
<ServerIcon className="size-4" />
{selectedLabel}
</Button>
{isLive && (
<Button
size="sm"
variant="outline"
disabled={loading}
onClick={() => void loadLive()}
>
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
Обновить
loading={isLive && loading && displayServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
actions={
<>
<ServerRailMobileButton />
{isLive && (
<Button
size="sm"
variant="outline"
disabled={loading}
onClick={() => void loadLive()}
>
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
Обновить
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />
Импорт
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />
Импорт
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<PlusIcon className="size-4" />
Новый интерфейс
</Button>
</>
}
/>
<div className="flex min-h-0 flex-1">
<aside className="hidden min-h-0 w-60 shrink-0 p-3 pr-0 md:flex">
{rail}
</aside>
<div className="min-w-0 flex-1 overflow-y-auto p-4 md:p-6">
<Button size="sm" onClick={() => setCreateOpen(true)}>
<PlusIcon className="size-4" />
Новый интерфейс
</Button>
</>
}
/>
}
>
<div className="flex flex-col gap-4">
<KpiStatGrid
aria-label="Сводка WireGuard"
@@ -785,26 +756,7 @@ export default function WireGuardPage() {
</TabsContent>
</Tabs>
</div>
</div>
</div>
<Sheet open={railOpen} onOpenChange={setRailOpen}>
<SheetContent side="left" className="flex w-72 flex-col gap-3 p-3" showCloseButton>
<SheetHeader className="px-1 pt-1">
<SheetTitle>Серверы</SheetTitle>
</SheetHeader>
<div className="min-h-0 flex-1">
<ServerTileRail
items={railItems}
selectedId={effectiveServerId}
onSelect={handleSelectServer}
allCount={displayIfaces.length}
showHeader={false}
className="h-full"
/>
</div>
</SheetContent>
</Sheet>
</ServerRailLayout>
<WgCreateSheet
open={createOpen}
@@ -870,6 +822,6 @@ export default function WireGuardPage() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</>
)
}