feat(ui): integrate KpiStatGrid for enhanced statistics display
Replaced existing KPI display implementations across multiple pages with the new KpiStatGrid component for a more consistent and visually appealing presentation of statistics. Updated the Backups, BGP, Certificates, Communities, Containers, Dashboard, and Data Collection pages to utilize the KpiStatGrid, improving the overall user experience and maintainability of the codebase. Additionally, added new dependencies in package.json for required libraries.
This commit is contained in:
+34
-23
@@ -8,8 +8,7 @@ import { FileImportDialog } from "@/components/file-import-dialog"
|
|||||||
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
|
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
|
||||||
import { StatusBadge } from "@/components/status-badge"
|
import { StatusBadge } from "@/components/status-badge"
|
||||||
import type { Backup, Server } from "@/lib/data"
|
import type { Backup, Server } from "@/lib/data"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { IconTile } from "@/components/reui/icon-tile"
|
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { Button } from "@/components/ui/button"
|
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-1 overflow-y-auto p-6">
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
{/* Stats */}
|
<KpiStatGrid
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
aria-label="Сводка бэкапов"
|
||||||
{[
|
items={[
|
||||||
{ label: "Всего бэкапов", value: backupList.length, icon: <HardDriveIcon className="size-4" /> },
|
{
|
||||||
{ label: "Авто", value: autoCount, icon: <ClockIcon className="size-4" /> },
|
id: "all",
|
||||||
{ label: "Вручную", value: manualCount, icon: <PlusIcon className="size-4" /> },
|
label: "Всего бэкапов",
|
||||||
{ label: "Серверов охвачено",value: serverCount, icon: <ServerIcon className="size-4" /> },
|
value: backupList.length,
|
||||||
].map((s) => (
|
icon: <HardDriveIcon className="size-4" />,
|
||||||
<Frame key={s.label} className="h-full">
|
iconClassName: "text-muted-foreground",
|
||||||
<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}
|
id: "auto",
|
||||||
</IconTile>
|
label: "Авто",
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
value: autoCount,
|
||||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
icon: <ClockIcon className="size-4" />,
|
||||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
iconClassName: "text-info",
|
||||||
</div>
|
},
|
||||||
</FramePanel>
|
{
|
||||||
</Frame>
|
id: "manual",
|
||||||
))}
|
label: "Вручную",
|
||||||
</div>
|
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 */}
|
{/* Info bar */}
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground px-1">
|
<div className="flex items-center gap-4 text-xs text-muted-foreground px-1">
|
||||||
|
|||||||
+69
-33
@@ -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 type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
|
||||||
import { BGP_AS_NAMES } from "@/lib/bgp/types"
|
import { BGP_AS_NAMES } from "@/lib/bgp/types"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -20,7 +21,7 @@ import {
|
|||||||
RefreshCwIcon, DownloadIcon, SearchIcon,
|
RefreshCwIcon, DownloadIcon, SearchIcon,
|
||||||
ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon,
|
ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon,
|
||||||
ArrowDownIcon, ClipboardCopyIcon, ServerIcon,
|
ArrowDownIcon, ClipboardCopyIcon, ServerIcon,
|
||||||
XIcon, AlertCircleIcon,
|
XIcon, AlertCircleIcon, GitMergeIcon, CheckCircleIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
@@ -463,22 +464,39 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
{/* summary row */}
|
<KpiStatGrid
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
aria-label="Сводка префиксов BGP"
|
||||||
{[
|
items={[
|
||||||
{ label: "Всего префиксов", value: fmtNum(totalRx), color: "text-emerald-600 dark:text-emerald-400" },
|
{
|
||||||
{ label: "Активных маршрутов", value: fmtNum(totalActive), color: "text-sky-600 dark:text-sky-400" },
|
id: "rx",
|
||||||
{ label: "eBGP сессий", value: ebgpSessions, color: "" },
|
label: "Всего префиксов",
|
||||||
{ label: "iBGP сессий", value: ibgpSessions, color: "" },
|
value: fmtNum(totalRx),
|
||||||
].map(s => (
|
icon: <DownloadIcon className="size-4" />,
|
||||||
<Frame key={s.label} className="h-full">
|
iconClassName: "text-success",
|
||||||
<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>
|
id: "active",
|
||||||
</FramePanel>
|
label: "Активных маршрутов",
|
||||||
</Frame>
|
value: fmtNum(totalActive),
|
||||||
))}
|
icon: <GitMergeIcon className="size-4" />,
|
||||||
</div>
|
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">
|
<div className="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-5">
|
||||||
{/* prefixes by peer — horizontal bar chart */}
|
{/* prefixes by peer — horizontal bar chart */}
|
||||||
@@ -722,22 +740,40 @@ export default function BgpPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* KPI strip */}
|
<KpiStatGrid
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
aria-label="Сводка BGP"
|
||||||
{[
|
items={[
|
||||||
{ label: "Сессий всего", value: sessions.length, color: "" },
|
{
|
||||||
{ label: "Established", value: established, color: "text-emerald-600 dark:text-emerald-400" },
|
id: "sessions",
|
||||||
{ label: "Не установлено", value: notEstab, color: notEstab > 0 ? "text-amber-500" : "text-muted-foreground" },
|
label: "Сессий всего",
|
||||||
{ label: "Получено префиксов", value: fmtNum(totalRx),color: "" },
|
value: sessions.length,
|
||||||
].map(s => (
|
icon: <GitMergeIcon className="size-4" />,
|
||||||
<Frame key={s.label} className="h-full">
|
iconClassName: "text-muted-foreground",
|
||||||
<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>
|
id: "established",
|
||||||
</FramePanel>
|
label: "Established",
|
||||||
</Frame>
|
value: established,
|
||||||
))}
|
icon: <CheckCircleIcon className="size-4" />,
|
||||||
</div>
|
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 */}
|
{/* alert: not-established sessions */}
|
||||||
{notEstab > 0 && (
|
{notEstab > 0 && (
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import { FileImportDialog } from "@/components/file-import-dialog"
|
|||||||
import { routerCertificates, servers as mockServers } from "@/lib/data"
|
import { routerCertificates, servers as mockServers } from "@/lib/data"
|
||||||
import type { CertStatus, Server } from "@/lib/data"
|
import type { CertStatus, Server } from "@/lib/data"
|
||||||
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
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 { OpsPanel } from "@/components/ops-panel"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
|
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 { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
@@ -119,42 +120,41 @@ function CertPartKpi({
|
|||||||
expired: CertificateDto[]
|
expired: CertificateDto[]
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<KpiStatGrid
|
||||||
{[
|
aria-label="Сводка сертификатов"
|
||||||
|
items={[
|
||||||
{
|
{
|
||||||
|
id: "all",
|
||||||
label: "Всего",
|
label: "Всего",
|
||||||
value: displayCerts.length,
|
value: displayCerts.length,
|
||||||
icon: <ShieldCheckIcon className="size-4 text-muted-foreground" />,
|
icon: <ShieldCheckIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "valid",
|
||||||
label: "Действующих",
|
label: "Действующих",
|
||||||
value: displayCerts.filter((c) => c.status === "valid").length,
|
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: "Истекают",
|
label: "Истекают",
|
||||||
value: expiring.length,
|
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: "Истёкших",
|
label: "Истёкших",
|
||||||
value: expired.length,
|
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 [issueTrustWww, setIssueTrustWww] = useState(true)
|
||||||
const [issueTrustApi, setIssueTrustApi] = useState(true)
|
const [issueTrustApi, setIssueTrustApi] = useState(true)
|
||||||
|
|
||||||
|
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||||
|
|
||||||
const [acmeDirectoryUrl, setAcmeDirectoryUrl] = useState(
|
const [acmeDirectoryUrl, setAcmeDirectoryUrl] = useState(
|
||||||
"https://acme-v02.api.letsencrypt.org/directory",
|
"https://acme-v02.api.letsencrypt.org/directory",
|
||||||
)
|
)
|
||||||
@@ -451,6 +453,27 @@ export default function CertificatesPage() {
|
|||||||
return routerCertificates.map(mockToDto)
|
return routerCertificates.map(mockToDto)
|
||||||
}, [prefsHydrated, isLive, certificates])
|
}, [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 serverById = useMemo(() => {
|
||||||
const map = new Map<string, Server>()
|
const map = new Map<string, Server>()
|
||||||
for (const s of isLive ? serverList : mockServers) map.set(s.id, s)
|
for (const s of isLive ? serverList : mockServers) map.set(s.id, s)
|
||||||
@@ -515,13 +538,13 @@ export default function CertificatesPage() {
|
|||||||
}, [isLive, loadLive, loadAcmeSettings])
|
}, [isLive, loadLive, loadAcmeSettings])
|
||||||
|
|
||||||
const expiring = useMemo(
|
const expiring = useMemo(
|
||||||
() => displayCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30),
|
() => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30),
|
||||||
[displayCerts],
|
[scopedCerts],
|
||||||
)
|
)
|
||||||
const expired = useMemo(() => displayCerts.filter((c) => c.status === "expired"), [displayCerts])
|
const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return displayCerts.filter((c) => {
|
return scopedCerts.filter((c) => {
|
||||||
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
||||||
if (!search) return true
|
if (!search) return true
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
@@ -532,7 +555,7 @@ export default function CertificatesPage() {
|
|||||||
c.sans.some((s) => s.includes(q))
|
c.sans.some((s) => s.includes(q))
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [displayCerts, search, statusFilter])
|
}, [scopedCerts, search, statusFilter])
|
||||||
|
|
||||||
async function handleRefresh() {
|
async function handleRefresh() {
|
||||||
if (!liveReady) return
|
if (!liveReady) return
|
||||||
@@ -625,35 +648,52 @@ export default function CertificatesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<>
|
||||||
<PageHeader
|
<ServerRailLayout
|
||||||
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
|
items={certRailItems}
|
||||||
actions={
|
selectedId={selectedServerId}
|
||||||
<>
|
onSelect={setSelectedServerId}
|
||||||
<Button
|
showAll
|
||||||
variant="outline"
|
allCount={displayServers.length}
|
||||||
size="sm"
|
loading={isLive && loadState === "loading" && displayServers.length === 0}
|
||||||
disabled={!liveReady || loadState === "loading"}
|
header={
|
||||||
onClick={() => {
|
<PageHeader
|
||||||
void handleRefresh()
|
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
|
||||||
}}
|
actions={
|
||||||
>
|
<>
|
||||||
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
|
<ServerRailMobileButton />
|
||||||
Обновить
|
<Button
|
||||||
</Button>
|
variant="outline"
|
||||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
size="sm"
|
||||||
<UploadIcon className="size-4" />
|
disabled={!liveReady || loadState === "loading"}
|
||||||
Импорт
|
onClick={() => {
|
||||||
</Button>
|
void handleRefresh()
|
||||||
<Button size="sm" disabled={!liveReady || issueBusy} onClick={() => { setIssueStep(1); setIssueOpen(true) }}>
|
}}
|
||||||
<PlusIcon className="size-4" />
|
>
|
||||||
Выпустить сертификат
|
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
|
||||||
</Button>
|
Обновить
|
||||||
</>
|
</Button>
|
||||||
}
|
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||||
/>
|
<UploadIcon className="size-4" />
|
||||||
|
Импорт
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
</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">
|
<div className="flex flex-col gap-5">
|
||||||
{isLive && backendStatus === false && (
|
{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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CertPartKpi displayCerts={displayCerts} expiring={expiring} expired={expired} />
|
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} />
|
||||||
|
|
||||||
{liveReady && (
|
{liveReady && (
|
||||||
<CertPartAcmeSettings
|
<CertPartAcmeSettings
|
||||||
@@ -720,7 +760,7 @@ export default function CertificatesPage() {
|
|||||||
|
|
||||||
<CertPartReference />
|
<CertPartReference />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ServerRailLayout>
|
||||||
|
|
||||||
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
|
<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">
|
<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}>
|
<StepperContent key={s} value={s}>
|
||||||
<CertPartIssueForm
|
<CertPartIssueForm
|
||||||
step={s as 1 | 2 | 3 | 4}
|
step={s as 1 | 2 | 3 | 4}
|
||||||
serverList={serverList}
|
serverList={displayServers}
|
||||||
issueServerId={issueServerId}
|
issueServerId={issueServerId}
|
||||||
setIssueServerId={setIssueServerId}
|
setIssueServerId={setIssueServerId}
|
||||||
issueCertName={issueCertName}
|
issueCertName={issueCertName}
|
||||||
@@ -812,6 +852,6 @@ export default function CertificatesPage() {
|
|||||||
toast.success(`Файл ${files[0]?.name} готов к импорту на роутер`)
|
toast.success(`Файл ${files[0]?.name} готов к импорту на роутер`)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ACTION_COLOR,
|
ACTION_COLOR,
|
||||||
} from "@/components/data-grids/communities-data-grid"
|
} from "@/components/data-grids/communities-data-grid"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -160,22 +161,39 @@ export default function CommunitiesPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── summary ── */}
|
<KpiStatGrid
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
aria-label="Сводка communities"
|
||||||
{[
|
items={[
|
||||||
{ label: "Всего communities", value: String(listData.length) },
|
{
|
||||||
{ label: "Активных", value: String(listData.filter(c => c.enabled).length) },
|
id: "all",
|
||||||
{ label: "Стандартных", value: String(listData.filter(c => c.type === "standard").length) },
|
label: "Всего communities",
|
||||||
{ label: "Использует фильтры",value: String(new Set(listData.flatMap(c => c.filterIds)).size) },
|
value: String(listData.length),
|
||||||
].map(({ label, value }) => (
|
icon: <TagIcon className="size-4" />,
|
||||||
<Frame key={label} className="h-full">
|
iconClassName: "text-muted-foreground",
|
||||||
<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>
|
id: "enabled",
|
||||||
</FramePanel>
|
label: "Активных",
|
||||||
</Frame>
|
value: String(listData.filter((c) => c.enabled).length),
|
||||||
))}
|
icon: <CheckIcon className="size-4" />,
|
||||||
</div>
|
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">
|
<div className="grid grid-cols-[1fr_320px] gap-5">
|
||||||
{/* ── main table ── */}
|
{/* ── main table ── */}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { routerContainers, servers } from "@/lib/data"
|
|||||||
import type { RouterContainer } from "@/lib/data"
|
import type { RouterContainer } from "@/lib/data"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
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 { OpsPanel } from "@/components/ops-panel"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
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-1 overflow-y-auto p-6">
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
{/* KPI */}
|
<KpiStatGrid
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
aria-label="Сводка контейнеров"
|
||||||
{[
|
items={[
|
||||||
{ label: "Всего", value: routerContainers.length, icon: <BoxIcon className="size-4 text-muted-foreground" /> },
|
{
|
||||||
{ label: "Running", value: running, icon: <PlayIcon className="size-4 text-emerald-500" /> },
|
id: "all",
|
||||||
{ label: "Stopped", value: stopped, icon: <StopCircleIcon className="size-4 text-muted-foreground" /> },
|
label: "Всего",
|
||||||
{ label: "Ошибок", value: errors, icon: <AlertCircleIcon className="size-4 text-red-500" /> },
|
value: routerContainers.length,
|
||||||
].map((s) => (
|
icon: <BoxIcon className="size-4" />,
|
||||||
<Frame key={s.label} className="h-full">
|
iconClassName: "text-muted-foreground",
|
||||||
<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}
|
id: "running",
|
||||||
</IconTile>
|
label: "Running",
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
value: running,
|
||||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
icon: <PlayIcon className="size-4" />,
|
||||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
iconClassName: "text-success",
|
||||||
</div>
|
},
|
||||||
</FramePanel>
|
{
|
||||||
</Frame>
|
id: "stopped",
|
||||||
))}
|
label: "Stopped",
|
||||||
</div>
|
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 */}
|
{/* 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">
|
<div className="flex items-start gap-3 rounded-lg bg-violet-500/5 border border-violet-500/20 px-4 py-3 text-sm">
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { IconTile } from "@/components/reui/icon-tile"
|
|
||||||
import { StatusDot } from "@/components/status-dot"
|
import { StatusDot } from "@/components/status-dot"
|
||||||
import { StatusBadge } from "@/components/status-badge"
|
import { StatusBadge } from "@/components/status-badge"
|
||||||
import { Sparkline } from "@/components/sparkline"
|
|
||||||
import { LatencyChart } from "@/components/dashboard/latency-chart"
|
import { LatencyChart } from "@/components/dashboard/latency-chart"
|
||||||
import { BandwidthChart } from "@/components/dashboard/bandwidth-chart"
|
import { BandwidthChart } from "@/components/dashboard/bandwidth-chart"
|
||||||
import { InternetPathMapCard } from "@/components/dashboard/internet-path-map"
|
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 {
|
function fmtIntRu(n: number): string {
|
||||||
return n.toLocaleString("ru-RU")
|
return n.toLocaleString("ru-RU")
|
||||||
}
|
}
|
||||||
@@ -725,49 +680,53 @@ export default function DashboardPage() {
|
|||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="p-6 flex flex-col gap-6">
|
<div className="p-6 flex flex-col gap-6">
|
||||||
|
|
||||||
{/* KPI row */}
|
<KpiStatGrid
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
aria-label="Сводка дашборда"
|
||||||
<StatCard
|
items={[
|
||||||
label="Серверы онлайн"
|
{
|
||||||
value={dashboardKpi.servers.value}
|
id: "servers",
|
||||||
unit={dashboardKpi.servers.unit}
|
label: "Серверы онлайн",
|
||||||
delta={dashboardKpi.servers.delta}
|
value: dashboardKpi.servers.unit
|
||||||
deltaDir={dashboardKpi.servers.deltaDir}
|
? `${dashboardKpi.servers.value} ${dashboardKpi.servers.unit}`
|
||||||
spark={dashboardKpi.servers.spark}
|
: dashboardKpi.servers.value,
|
||||||
sparkColor={dashboardKpi.servers.sparkColor}
|
hint: dashboardKpi.servers.delta,
|
||||||
icon={<ServerIcon aria-hidden />}
|
icon: <ServerIcon className="size-4" />,
|
||||||
/>
|
iconClassName: "text-muted-foreground",
|
||||||
<StatCard
|
variant: dashboardKpi.servers.deltaDir === "down" ? "warning" : "default",
|
||||||
label="Активные фильтры"
|
},
|
||||||
value={dashboardKpi.filters.value}
|
{
|
||||||
unit={dashboardKpi.filters.unit}
|
id: "filters",
|
||||||
delta={dashboardKpi.filters.delta}
|
label: "Активные фильтры",
|
||||||
deltaDir={dashboardKpi.filters.deltaDir}
|
value: dashboardKpi.filters.unit
|
||||||
spark={dashboardKpi.filters.spark}
|
? `${dashboardKpi.filters.value} ${dashboardKpi.filters.unit}`
|
||||||
sparkColor={dashboardKpi.filters.sparkColor}
|
: dashboardKpi.filters.value,
|
||||||
icon={<FilterIcon aria-hidden />}
|
hint: dashboardKpi.filters.delta,
|
||||||
/>
|
icon: <FilterIcon className="size-4" />,
|
||||||
<StatCard
|
iconClassName: "text-info",
|
||||||
label="BGP-префиксы"
|
},
|
||||||
value={dashboardKpi.bgp.value}
|
{
|
||||||
unit={dashboardKpi.bgp.unit}
|
id: "bgp",
|
||||||
delta={dashboardKpi.bgp.delta}
|
label: "BGP-префиксы",
|
||||||
deltaDir={dashboardKpi.bgp.deltaDir}
|
value: dashboardKpi.bgp.unit
|
||||||
spark={dashboardKpi.bgp.spark}
|
? `${dashboardKpi.bgp.value} ${dashboardKpi.bgp.unit}`
|
||||||
sparkColor={dashboardKpi.bgp.sparkColor}
|
: dashboardKpi.bgp.value,
|
||||||
icon={<GitMergeIcon aria-hidden />}
|
hint: dashboardKpi.bgp.delta,
|
||||||
/>
|
icon: <GitMergeIcon className="size-4" />,
|
||||||
<StatCard
|
iconClassName: "text-primary",
|
||||||
label="Активные алерты"
|
},
|
||||||
value={dashboardKpi.alerts.value}
|
{
|
||||||
unit={dashboardKpi.alerts.unit}
|
id: "alerts",
|
||||||
delta={dashboardKpi.alerts.delta}
|
label: "Активные алерты",
|
||||||
deltaDir={dashboardKpi.alerts.deltaDir}
|
value: dashboardKpi.alerts.unit
|
||||||
spark={dashboardKpi.alerts.spark}
|
? `${dashboardKpi.alerts.value} ${dashboardKpi.alerts.unit}`
|
||||||
sparkColor={dashboardKpi.alerts.sparkColor}
|
: dashboardKpi.alerts.value,
|
||||||
icon={<BellIcon aria-hidden />}
|
hint: dashboardKpi.alerts.delta,
|
||||||
/>
|
icon: <BellIcon className="size-4" />,
|
||||||
</div>
|
iconClassName: dashboardKpi.alerts.deltaDir === "down" ? "text-destructive" : "text-muted-foreground",
|
||||||
|
variant: dashboardKpi.alerts.deltaDir === "down" ? "destructive" : "default",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Latency chart + Events */}
|
{/* Latency chart + Events */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4">
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import { PageHeader } from "@/components/page-header"
|
|||||||
import { FormToggle } from "@/components/form-kit"
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Badge } from "@/components/reui/badge"
|
import { Badge } from "@/components/reui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { IconTile } from "@/components/reui/icon-tile"
|
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import {
|
import {
|
||||||
@@ -967,13 +966,13 @@ export default function DataCollectionPage() {
|
|||||||
sub: uptimeCollector?.scheduler?.jobs?.length
|
sub: uptimeCollector?.scheduler?.jobs?.length
|
||||||
? "По сохранённым задачам планировщика"
|
? "По сохранённым задачам планировщика"
|
||||||
: "По переключателям на этой странице",
|
: "По переключателям на этой странице",
|
||||||
icon: <CalendarClockIcon className="size-4 text-muted-foreground" />,
|
icon: <CalendarClockIcon className="size-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Сейчас выполняется",
|
label: "Сейчас выполняется",
|
||||||
value: String(runningJobsCount),
|
value: String(runningJobsCount),
|
||||||
sub: "Фоновые прогоны планировщика",
|
sub: "Фоновые прогоны планировщика",
|
||||||
icon: <LoaderCircleIcon className="size-4 text-amber-500" />,
|
icon: <LoaderCircleIcon className="size-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Трафик — последний сбор",
|
label: "Трафик — последний сбор",
|
||||||
@@ -982,16 +981,16 @@ export default function DataCollectionPage() {
|
|||||||
: "—",
|
: "—",
|
||||||
sub: trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных",
|
sub: trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных",
|
||||||
icon: trafficCollector?.lastError ? (
|
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: "Журнал (в списке)",
|
label: "Журнал (в списке)",
|
||||||
value: String(schedulerRuns.length),
|
value: String(schedulerRuns.length),
|
||||||
sub: errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет",
|
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 && (
|
{isLive && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<KpiStatGrid
|
||||||
{stats.map((s) => (
|
aria-label="Сводка сбора данных"
|
||||||
<Frame key={s.label} className="h-full">
|
items={stats.map((s, i) => ({
|
||||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
id: `dc-${i}`,
|
||||||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
label: s.label,
|
||||||
{s.icon}
|
value: s.value,
|
||||||
</IconTile>
|
hint: s.sub,
|
||||||
<div className="min-w-0 flex-1 flex flex-col gap-0.5">
|
icon: s.icon,
|
||||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
iconClassName:
|
||||||
<p className="text-xl leading-none font-bold tabular-nums truncate">{s.value}</p>
|
s.label === "Трафик — последний сбор" && trafficCollector?.lastError
|
||||||
<p className="text-[11px] text-muted-foreground mt-1 leading-snug line-clamp-2">{s.sub}</p>
|
? "text-destructive"
|
||||||
</div>
|
: s.label === "Сейчас выполняется" && runningJobsCount > 0
|
||||||
</FramePanel>
|
? "text-warning"
|
||||||
</Frame>
|
: s.label === "Журнал (в списке)" && errorRunsInView
|
||||||
))}
|
? "text-destructive"
|
||||||
</div>
|
: 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>
|
<DataPageCard>
|
||||||
<div className="border-b border-border px-5 py-4">
|
<div className="border-b border-border px-5 py-4">
|
||||||
|
|||||||
+133
-137
@@ -36,6 +36,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
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 ────────────────────────────────────────────────────────────────────
|
// ── helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1401,6 +1403,25 @@ export default function FiltersPage() {
|
|||||||
const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0]
|
const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0]
|
||||||
const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 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(
|
const currentRules = useMemo(
|
||||||
() => rulesets.find(r => r.serverId === selectedServerId)?.rules ?? [],
|
() => rulesets.find(r => r.serverId === selectedServerId)?.rules ?? [],
|
||||||
[rulesets, selectedServerId],
|
[rulesets, selectedServerId],
|
||||||
@@ -1566,142 +1587,117 @@ export default function FiltersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<>
|
||||||
<PageHeader
|
<ServerRailLayout
|
||||||
crumbs={[{ label: "Управление" }, { label: "Фильтры" }]}
|
items={filterRailItems}
|
||||||
actions={
|
selectedId={selectedServerId}
|
||||||
<>
|
onSelect={handleSelectServer}
|
||||||
{isLive && (
|
showAll={false}
|
||||||
<>
|
header={
|
||||||
<Button
|
<PageHeader
|
||||||
variant="outline"
|
crumbs={[{ label: "Управление" }, { label: "Фильтры" }]}
|
||||||
size="sm"
|
actions={
|
||||||
onClick={syncFromRouter}
|
<>
|
||||||
disabled={syncBusy !== null}
|
<ServerRailMobileButton />
|
||||||
title="Синхронизация Router → БД"
|
{isLive && (
|
||||||
>
|
<>
|
||||||
{syncBusy === "from" ? "Синк Router → DB…" : "Router → DB"}
|
<Button
|
||||||
</Button>
|
variant="outline"
|
||||||
<Button
|
size="sm"
|
||||||
variant="outline"
|
onClick={syncFromRouter}
|
||||||
size="sm"
|
disabled={syncBusy !== null}
|
||||||
onClick={syncToRouter}
|
title="Синхронизация Router → БД"
|
||||||
disabled={syncBusy !== null}
|
>
|
||||||
title="Синхронизация БД → Router"
|
{syncBusy === "from" ? "Синк Router → DB…" : "Router → DB"}
|
||||||
>
|
</Button>
|
||||||
{syncBusy === "to" ? "Синк DB → Router…" : "DB → Router"}
|
<Button
|
||||||
</Button>
|
variant="outline"
|
||||||
<Button
|
size="sm"
|
||||||
variant="outline"
|
onClick={syncToRouter}
|
||||||
size="sm"
|
disabled={syncBusy !== null}
|
||||||
onClick={() => void fetchRouterCompare()}
|
title="Синхронизация БД → Router"
|
||||||
disabled={syncBusy !== null || routerCompareLoading}
|
>
|
||||||
title="Сравнить правила в БД с цепочкой bgp-in на MikroTik"
|
{syncBusy === "to" ? "Синк DB → Router…" : "DB → Router"}
|
||||||
className="gap-1.5"
|
</Button>
|
||||||
>
|
<Button
|
||||||
{routerCompareLoading ? (
|
variant="outline"
|
||||||
<LoaderCircleIcon className="size-4 animate-spin" />
|
size="sm"
|
||||||
) : (
|
onClick={() => void fetchRouterCompare()}
|
||||||
<RefreshCwIcon className="size-4" />
|
disabled={syncBusy !== null || routerCompareLoading}
|
||||||
)}
|
title="Сравнить правила в БД с цепочкой bgp-in на MikroTik"
|
||||||
Сверить
|
className="gap-1.5"
|
||||||
</Button>
|
>
|
||||||
</>
|
{routerCompareLoading ? (
|
||||||
)}
|
<LoaderCircleIcon className="size-4 animate-spin" />
|
||||||
<Button variant="outline" size="sm" onClick={() => setPreviewOpen(true)}>
|
) : (
|
||||||
<FileCodeIcon className="size-4" />RouterOS
|
<RefreshCwIcon className="size-4" />
|
||||||
</Button>
|
)}
|
||||||
<Button
|
Сверить
|
||||||
variant="outline" size="sm"
|
</Button>
|
||||||
onClick={() => setCopyOpen(true)}
|
</>
|
||||||
disabled={currentRules.length === 0}
|
)}
|
||||||
title="Копировать правила на другой сервер"
|
<Button variant="outline" size="sm" onClick={() => setPreviewOpen(true)}>
|
||||||
>
|
<FileCodeIcon className="size-4" />RouterOS
|
||||||
<CopyIcon className="size-4" />Копировать
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
<Button size="sm" onClick={openCreate}>
|
variant="outline" size="sm"
|
||||||
<PlusIcon className="size-4" />Новое правило
|
onClick={() => setCopyOpen(true)}
|
||||||
</Button>
|
disabled={currentRules.length === 0}
|
||||||
</>
|
title="Копировать правила на другой сервер"
|
||||||
}
|
>
|
||||||
/>
|
<CopyIcon className="size-4" />Копировать
|
||||||
|
</Button>
|
||||||
{isLive && liveLoadState === "error" && (
|
<Button size="sm" onClick={openCreate}>
|
||||||
<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">
|
<PlusIcon className="size-4" />Новое правило
|
||||||
<AlertCircleIcon className="size-3.5 shrink-0" />
|
</Button>
|
||||||
Бекенд недоступен — показаны демо-данные из 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} правил`
|
|
||||||
}
|
}
|
||||||
</p>
|
/>
|
||||||
</div>
|
}
|
||||||
|
banner={
|
||||||
{/* ── main content ── */}
|
<>
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
{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">
|
<div className="flex flex-col gap-4">
|
||||||
|
|
||||||
{/* RouterOS 7.x BGP extensions — только демо из lib/data (моки) */}
|
{/* RouterOS 7.x BGP extensions — только демо из lib/data (моки) */}
|
||||||
@@ -1817,7 +1813,7 @@ export default function FiltersPage() {
|
|||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ServerRailLayout>
|
||||||
|
|
||||||
<RuleSheet
|
<RuleSheet
|
||||||
key={`${sheetMode}-${editingId ?? "new"}-${selectedServerId}`}
|
key={`${sheetMode}-${editingId ?? "new"}-${selectedServerId}`}
|
||||||
@@ -1858,6 +1854,6 @@ export default function FiltersPage() {
|
|||||||
recRoutesByServer={recRoutesByServer}
|
recRoutesByServer={recRoutesByServer}
|
||||||
ensureRecursiveFor={ensureRecursiveRoutes}
|
ensureRecursiveFor={ensureRecursiveRoutes}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+905
-154
File diff suppressed because it is too large
Load Diff
+108
-64
@@ -14,7 +14,7 @@ import { requestJson } from "@/shared/api/http-client"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
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 { OpsPanel } from "@/components/ops-panel"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||||
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
|
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Flag } from "@/components/flag"
|
|
||||||
import {
|
import {
|
||||||
PlusIcon, RefreshCwIcon, MoreHorizontalIcon,
|
PlusIcon, RefreshCwIcon, MoreHorizontalIcon,
|
||||||
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
|
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
|
||||||
@@ -35,6 +34,8 @@ import {
|
|||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
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 ─────────────────────────────────────────────────────────────
|
// ─── label maps ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -241,6 +242,7 @@ export default function GrePage() {
|
|||||||
const [pageTab, setPageTab] = useState<PageTab>("tunnels")
|
const [pageTab, setPageTab] = useState<PageTab>("tunnels")
|
||||||
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
|
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
|
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||||
|
|
||||||
const [tunnelOpen, setTunnelOpen] = useState(false)
|
const [tunnelOpen, setTunnelOpen] = useState(false)
|
||||||
const [poolOpen, setPoolOpen] = useState(false)
|
const [poolOpen, setPoolOpen] = useState(false)
|
||||||
@@ -293,6 +295,25 @@ export default function GrePage() {
|
|||||||
[isLive, displayTunnels],
|
[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(
|
const serverById = useMemo(
|
||||||
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
|
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
|
||||||
[displayServers],
|
[displayServers],
|
||||||
@@ -341,7 +362,7 @@ export default function GrePage() {
|
|||||||
}, [dataError])
|
}, [dataError])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return displayTunnels.filter((t) => {
|
return scopedTunnels.filter((t) => {
|
||||||
if (tabFilter === "up" && t.status !== "up") return false
|
if (tabFilter === "up" && t.status !== "up") return false
|
||||||
if (tabFilter === "ipsec" && !t.ipsec) return false
|
if (tabFilter === "ipsec" && !t.ipsec) return false
|
||||||
if (tabFilter === "plain" && 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)
|
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 upCount = displayTunnels.filter((t) => t.status === "up").length
|
||||||
const ipsecCount = displayTunnels.filter((t) => t.ipsec).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 }[] = [
|
const tunnelTabs: { value: TabFilter; label: string; count: number }[] = [
|
||||||
{ value: "all", label: "Все", count: displayTunnels.length },
|
{ value: "all", label: "Все", count: scopedTunnels.length },
|
||||||
{ value: "up", label: "Активные", count: upCount },
|
{ value: "up", label: "Активные", count: scopedUpCount },
|
||||||
{ value: "ipsec", label: "С IPsec", count: ipsecCount },
|
{ value: "ipsec", label: "С IPsec", count: scopedIpsecCount },
|
||||||
{ value: "plain", label: "Без IPsec", count: displayTunnels.length - ipsecCount },
|
{ value: "plain", label: "Без IPsec", count: scopedTunnels.length - scopedIpsecCount },
|
||||||
]
|
]
|
||||||
|
|
||||||
const greExportCode = useMemo(
|
const greExportCode = useMemo(
|
||||||
@@ -372,39 +395,48 @@ export default function GrePage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<>
|
||||||
<PageHeader
|
<ServerRailLayout
|
||||||
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
|
items={greRailItems}
|
||||||
actions={
|
selectedId={selectedServerId}
|
||||||
<>
|
onSelect={setSelectedServerId}
|
||||||
<Button
|
showAll
|
||||||
variant="outline"
|
allCount={displayServers.length}
|
||||||
size="sm"
|
loading={isLive && dataLoading && displayServers.length === 0}
|
||||||
onClick={() => { void loadLive() }}
|
header={
|
||||||
disabled={!isLive || dataLoading}
|
<PageHeader
|
||||||
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
|
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
|
||||||
>
|
actions={
|
||||||
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
|
<>
|
||||||
Обновить
|
<ServerRailMobileButton />
|
||||||
</Button>
|
<Button
|
||||||
<Button
|
variant="outline"
|
||||||
variant="outline"
|
size="sm"
|
||||||
size="sm"
|
onClick={() => { void loadLive() }}
|
||||||
onClick={() => { void syncJhToDb() }}
|
disabled={!isLive || dataLoading}
|
||||||
disabled={!isLive || syncJhBusy || dataLoading}
|
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
|
||||||
title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE"
|
>
|
||||||
>
|
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
|
||||||
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} />
|
Обновить
|
||||||
JH → БД
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
|
variant="outline"
|
||||||
<PlusIcon className="size-4" />Добавить туннель
|
size="sm"
|
||||||
</Button>
|
onClick={() => { void syncJhToDb() }}
|
||||||
</>
|
disabled={!isLive || syncJhBusy || dataLoading}
|
||||||
}
|
title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE"
|
||||||
/>
|
>
|
||||||
|
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} />
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
JH → БД
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
|
||||||
|
<PlusIcon className="size-4" />Добавить туннель
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
{/* Legacy banner */}
|
{/* Legacy banner */}
|
||||||
@@ -421,27 +453,39 @@ export default function GrePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
<KpiStatGrid
|
||||||
<div className="grid grid-cols-4 gap-4">
|
aria-label="Сводка GRE"
|
||||||
{[
|
items={[
|
||||||
{ label: "Всего туннелей", value: displayTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
|
{
|
||||||
{ label: "Активно", value: upCount, icon: <ShieldCheckIcon className="size-4 text-emerald-500" /> },
|
id: "tunnels",
|
||||||
{ label: "Защищены IPsec", value: ipsecCount, icon: <LockIcon className="size-4 text-violet-400" /> },
|
label: "Всего туннелей",
|
||||||
{ label: "IP-пулов", value: displayPools.length, icon: <NetworkIcon className="size-4 text-sky-400" /> },
|
value: displayTunnels.length,
|
||||||
].map((s) => (
|
icon: <NetworkIcon className="size-4" />,
|
||||||
<Frame key={s.label} className="h-full">
|
iconClassName: "text-muted-foreground",
|
||||||
<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}
|
id: "up",
|
||||||
</IconTile>
|
label: "Активно",
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
value: upCount,
|
||||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
icon: <ShieldCheckIcon className="size-4" />,
|
||||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
iconClassName: "text-success",
|
||||||
</div>
|
},
|
||||||
</FramePanel>
|
{
|
||||||
</Frame>
|
id: "ipsec",
|
||||||
))}
|
label: "Защищены IPsec",
|
||||||
</div>
|
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 */}
|
{/* Page tabs */}
|
||||||
<div className="flex items-center gap-1 border-b">
|
<div className="flex items-center gap-1 border-b">
|
||||||
@@ -545,7 +589,7 @@ export default function GrePage() {
|
|||||||
</div>
|
</div>
|
||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ServerRailLayout>
|
||||||
|
|
||||||
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
|
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
|
||||||
<CodeExportSheet
|
<CodeExportSheet
|
||||||
@@ -801,6 +845,6 @@ export default function GrePage() {
|
|||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+150
-133
@@ -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 { OspfRoutesDataGrid, routeTypeClass } from "@/components/data-grids/ospf-routes-data-grid"
|
||||||
import { OspfBfdDataGrid } from "@/components/data-grids/ospf-bfd-data-grid"
|
import { OspfBfdDataGrid } from "@/components/data-grids/ospf-bfd-data-grid"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import {
|
import {
|
||||||
RefreshCwIcon, WandSparklesIcon, SaveIcon, GripVerticalIcon,
|
RefreshCwIcon, WandSparklesIcon, SaveIcon, GripVerticalIcon,
|
||||||
NetworkIcon, RouteIcon, ShieldIcon, ActivityIcon, XIcon,
|
NetworkIcon, RouteIcon, ShieldIcon, ActivityIcon, XIcon,
|
||||||
|
LayersIcon, RouterIcon, UsersIcon, CheckCircleIcon, AlertCircleIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
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"
|
import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data"
|
||||||
|
|
||||||
// ─── types ────────────────────────────────────────────────────────────────────
|
// ─── types ────────────────────────────────────────────────────────────────────
|
||||||
@@ -707,7 +710,7 @@ function InterfacesTab({
|
|||||||
}, [grouped, isLive])
|
}, [grouped, isLive])
|
||||||
|
|
||||||
const needsOptimize = !isLive && items.some(item => hints[item.key] && hints[item.key].optimalCost !== item.cost)
|
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 uniqueLiveFallbackOpt = useMemo(() => {
|
||||||
const out: Record<string, number> = {}
|
const out: Record<string, number> = {}
|
||||||
const byRouter: Record<string, OspfItem[]> = {}
|
const byRouter: Record<string, OspfItem[]> = {}
|
||||||
@@ -921,20 +924,33 @@ function NeighborsTab({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<KpiStatGrid
|
||||||
{[
|
aria-label="Сводка соседей OSPF"
|
||||||
{ label: "Всего соседей", value: neighbors.length, color: "" },
|
items={[
|
||||||
{ 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" },
|
id: "neighbors",
|
||||||
].map(s => (
|
label: "Всего соседей",
|
||||||
<Frame key={s.label} className="h-full">
|
value: neighbors.length,
|
||||||
<FramePanel className="flex flex-col gap-0.5">
|
icon: <UsersIcon className="size-4" />,
|
||||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
iconClassName: "text-muted-foreground",
|
||||||
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
|
},
|
||||||
</FramePanel>
|
{
|
||||||
</Frame>
|
id: "full",
|
||||||
))}
|
label: "Full",
|
||||||
</div>
|
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 && (
|
{graphNodes.length > 0 && (
|
||||||
<div className="flex rounded-xl overflow-hidden border border-white/[0.06]">
|
<div className="flex rounded-xl overflow-hidden border border-white/[0.06]">
|
||||||
@@ -1044,21 +1060,41 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
<KpiStatGrid
|
||||||
{[
|
aria-label="Сводка BFD"
|
||||||
{ label: "Сессий BFD", value: sessions.length, color: "" },
|
items={[
|
||||||
{ 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" },
|
id: "sessions",
|
||||||
{ label: "Init / другие", value: initCount, color: initCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
|
label: "Сессий BFD",
|
||||||
].map(s => (
|
value: sessions.length,
|
||||||
<Frame key={s.label} className="h-full">
|
icon: <ActivityIcon className="size-4" />,
|
||||||
<FramePanel className="flex flex-col gap-0.5">
|
iconClassName: "text-muted-foreground",
|
||||||
<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>
|
id: "up",
|
||||||
</Frame>
|
label: "Up",
|
||||||
))}
|
value: upCount,
|
||||||
</div>
|
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 && (
|
{sessions.length === 0 && (
|
||||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-8 text-center text-sm text-muted-foreground">
|
<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() {
|
export default function OspfPage() {
|
||||||
const [activeTab, setActiveTab] = useState<OspfTab>("interfaces")
|
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 { mode, backendUrl } = useDataSource()
|
||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
@@ -1218,9 +1254,26 @@ export default function OspfPage() {
|
|||||||
}, [items, neighbors, bfdSessions])
|
}, [items, neighbors, bfdSessions])
|
||||||
|
|
||||||
// ── filtered display data ─────────────────────────────────────────────────────
|
// ── filtered display data ─────────────────────────────────────────────────────
|
||||||
const displayItems = filterServerId === "all" ? items : items.filter(i => i.routerKey === filterServerId)
|
const displayItems = filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId)
|
||||||
const displayNeighbors = filterServerId === "all" ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
|
const displayNeighbors = filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
|
||||||
const displayBfdSessions = filterServerId === "all" ? bfdSessions : bfdSessions.filter(b => b.serverId === 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)
|
// Graph always shows full topology (highlight is handled by node click inside tab)
|
||||||
// KPIs reflect the current filter
|
// KPIs reflect the current filter
|
||||||
@@ -1229,91 +1282,45 @@ export default function OspfPage() {
|
|||||||
const totalAreas = new Set(displayItems.map(i => i.area)).size
|
const totalAreas = new Set(displayItems.map(i => i.area)).size
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<ServerRailLayout
|
||||||
<PageHeader
|
items={ospfRailItems}
|
||||||
crumbs={[{ label: "Инструменты" }, { label: "OSPF" }]}
|
selectedId={filterServerId}
|
||||||
actions={
|
onSelect={setFilterServerId}
|
||||||
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
|
showAll
|
||||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
allCount={ospfServers.length}
|
||||||
Обновить
|
loading={isLive && loading && ospfServers.length === 0}
|
||||||
</Button>
|
header={
|
||||||
}
|
<PageHeader
|
||||||
/>
|
crumbs={[{ label: "Инструменты" }, { label: "OSPF" }]}
|
||||||
|
actions={
|
||||||
<div className="border-b bg-background shrink-0">
|
<>
|
||||||
<div className="flex items-center px-6">
|
<ServerRailMobileButton />
|
||||||
{TABS.map(t => (
|
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
|
||||||
<button key={t.id} onClick={() => setActiveTab(t.id)}
|
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||||
className={cn(
|
Обновить
|
||||||
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
</Button>
|
||||||
activeTab === t.id
|
</>
|
||||||
? "border-primary text-foreground"
|
}
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
|
/>
|
||||||
)}>
|
}
|
||||||
{t.icon}{t.label}
|
banner={
|
||||||
</button>
|
<div className="border-b bg-background shrink-0">
|
||||||
))}
|
<div className="flex items-center px-6">
|
||||||
</div>
|
{TABS.map(t => (
|
||||||
</div>
|
<button key={t.id} onClick={() => setActiveTab(t.id)}
|
||||||
|
|
||||||
{/* ── 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)}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
|
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||||
active
|
activeTab === t.id
|
||||||
? "bg-foreground text-background border-foreground"
|
? "border-primary text-foreground"
|
||||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
|
||||||
)}>
|
)}>
|
||||||
{s.country ? <Flag code={s.country} size={12} /> : null}
|
{t.icon}{t.label}
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
)
|
))}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
}
|
||||||
|
>
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
{/* data source banner */}
|
{/* data source banner */}
|
||||||
@@ -1346,21 +1353,32 @@ export default function OspfPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* KPI strip */}
|
<KpiStatGrid
|
||||||
<div className="grid grid-cols-3 gap-3">
|
aria-label="Сводка OSPF"
|
||||||
{[
|
items={[
|
||||||
{ label: "Роутеров", value: totalRouters },
|
{
|
||||||
{ label: "Интерфейсов", value: totalInterfaces },
|
id: "routers",
|
||||||
{ label: "Зон (Area)", value: totalAreas },
|
label: "Роутеров",
|
||||||
].map(s => (
|
value: totalRouters,
|
||||||
<Frame key={s.label} className="h-full">
|
icon: <RouterIcon className="size-4" />,
|
||||||
<FramePanel className="flex flex-col gap-0.5">
|
iconClassName: "text-muted-foreground",
|
||||||
<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>
|
id: "ifaces",
|
||||||
</Frame>
|
label: "Интерфейсов",
|
||||||
))}
|
value: totalInterfaces,
|
||||||
</div>
|
icon: <NetworkIcon className="size-4" />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "areas",
|
||||||
|
label: "Зон (Area)",
|
||||||
|
value: totalAreas,
|
||||||
|
icon: <LayersIcon className="size-4" />,
|
||||||
|
iconClassName: "text-primary",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
{activeTab === "interfaces" && (
|
{activeTab === "interfaces" && (
|
||||||
<InterfacesTab
|
<InterfacesTab
|
||||||
@@ -1384,7 +1402,6 @@ export default function OspfPage() {
|
|||||||
{activeTab === "bfd" && <BfdTab sessions={displayBfdSessions} />}
|
{activeTab === "bfd" && <BfdTab sessions={displayBfdSessions} />}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ServerRailLayout>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-32
@@ -17,6 +17,8 @@ import {
|
|||||||
} from "@/components/data-grids/probes-speed-probes-data-grid"
|
} from "@/components/data-grids/probes-speed-probes-data-grid"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
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 { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import {
|
import {
|
||||||
@@ -480,19 +482,25 @@ function ScheduleTab({
|
|||||||
setRules,
|
setRules,
|
||||||
serverOptions,
|
serverOptions,
|
||||||
tunnelsForServer,
|
tunnelsForServer,
|
||||||
|
defaultSrc,
|
||||||
}: {
|
}: {
|
||||||
rules: SchedRule[]
|
rules: SchedRule[]
|
||||||
setRules: React.Dispatch<React.SetStateAction<SchedRule[]>>
|
setRules: React.Dispatch<React.SetStateAction<SchedRule[]>>
|
||||||
serverOptions: Server[]
|
serverOptions: Server[]
|
||||||
tunnelsForServer: (serverId: string) => GreTunnel[]
|
tunnelsForServer: (serverId: string) => GreTunnel[]
|
||||||
|
defaultSrc?: string
|
||||||
}) {
|
}) {
|
||||||
const [showAdd, setShowAdd] = useState(false)
|
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 [addTun, setAddTun] = useState("")
|
||||||
const [addType, setAddType] = useState<SchedType>("ping")
|
const [addType, setAddType] = useState<SchedType>("ping")
|
||||||
const [addMin, setAddMin] = useState(10)
|
const [addMin, setAddMin] = useState(10)
|
||||||
const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer])
|
const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (defaultSrc) setAddSrc(defaultSrc)
|
||||||
|
}, [defaultSrc])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const list = tunnelsForServer(addSrc)
|
const list = tunnelsForServer(addSrc)
|
||||||
if (list.length && !list.some(t => t.id === addTun)) {
|
if (list.length && !list.some(t => t.id === addTun)) {
|
||||||
@@ -630,6 +638,20 @@ export default function ProbesPage() {
|
|||||||
return liveServers
|
return liveServers
|
||||||
}, [isLive, 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(() => {
|
useEffect(() => {
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
setRosSrcV4(undefined)
|
setRosSrcV4(undefined)
|
||||||
@@ -887,24 +909,33 @@ export default function ProbesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<ServerRailLayout
|
||||||
<PageHeader
|
items={probeRailItems}
|
||||||
crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]}
|
selectedId={srcId}
|
||||||
actions={
|
onSelect={setSrcId}
|
||||||
running.length > 0
|
showAll={false}
|
||||||
? <Button variant="outline" size="sm" onClick={() => {
|
loading={isLive && liveLoad === "loading" && allServers.length === 0}
|
||||||
liveProbeRunRef.current?.ctrl.abort()
|
header={
|
||||||
setTests(p => p.map(t => (t.status === "running"
|
<PageHeader
|
||||||
? { ...t, status: "done" as const, totalLines: t.lines.length }
|
crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]}
|
||||||
: t)))
|
actions={
|
||||||
}}>
|
<>
|
||||||
<SquareIcon className="size-4" />Остановить все
|
<ServerRailMobileButton />
|
||||||
</Button>
|
{running.length > 0
|
||||||
: undefined
|
? <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 }
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
: t)))
|
||||||
|
}}>
|
||||||
|
<SquareIcon className="size-4" />Остановить все
|
||||||
|
</Button>
|
||||||
|
: null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
|
||||||
{isLive && liveLoad === "error" && (
|
{isLive && liveLoad === "error" && (
|
||||||
@@ -916,7 +947,7 @@ export default function ProbesPage() {
|
|||||||
|
|
||||||
{isLive && (
|
{isLive && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -945,16 +976,6 @@ export default function ProbesPage() {
|
|||||||
{/* main config row */}
|
{/* main config row */}
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<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 */}
|
{/* target — all tools except bandwidth */}
|
||||||
{tool !== "bandwidth" && (
|
{tool !== "bandwidth" && (
|
||||||
<div className="flex-1 min-w-[140px]">
|
<div className="flex-1 min-w-[140px]">
|
||||||
@@ -1169,13 +1190,13 @@ export default function ProbesPage() {
|
|||||||
setRules={setRules}
|
setRules={setRules}
|
||||||
serverOptions={allServers}
|
serverOptions={allServers}
|
||||||
tunnelsForServer={sid => greTunnels.filter(t => t.serverId === sid)}
|
tunnelsForServer={sid => greTunnels.filter(t => t.serverId === sid)}
|
||||||
|
defaultSrc={srcId}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ServerRailLayout>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { cn } from "@/lib/utils"
|
|||||||
import { servers as mockServers, type Server } from "@/lib/data"
|
import { servers as mockServers, type Server } from "@/lib/data"
|
||||||
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon } from "lucide-react"
|
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon } from "lucide-react"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
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 {
|
interface BackendServer {
|
||||||
id: number
|
id: number
|
||||||
@@ -543,6 +545,18 @@ export default function RecursiveRoutesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentServer = servers.find(s => s.id === selectedServerId)
|
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 filteredRows = useMemo(() => {
|
||||||
const q = search.trim().toLowerCase()
|
const q = search.trim().toLowerCase()
|
||||||
if (!q) return rows
|
if (!q) return rows
|
||||||
@@ -574,82 +588,64 @@ export default function RecursiveRoutesPage() {
|
|||||||
}, [filteredRows])
|
}, [filteredRows])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<>
|
||||||
<PageHeader
|
<ServerRailLayout
|
||||||
crumbs={[{ label: "Управление" }, { label: "Рекурсивные маршруты" }]}
|
items={rrRailItems}
|
||||||
actions={
|
selectedId={selectedServerId}
|
||||||
<>
|
onSelect={setSelectedServerId}
|
||||||
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}>
|
showAll={false}
|
||||||
{busy === "from" ? "Синхронизация..." : "Router => DB"}
|
showCount={false}
|
||||||
</Button>
|
loading={isLive && !liveServerListReady}
|
||||||
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}>
|
header={
|
||||||
{busy === "to" ? "Применение..." : "DB => Router"}
|
<PageHeader
|
||||||
</Button>
|
crumbs={[{ label: "Управление" }, { label: "Рекурсивные маршруты" }]}
|
||||||
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}>
|
actions={
|
||||||
<SaveIcon className="size-4" />Сохранить в БД
|
<>
|
||||||
</Button>
|
<ServerRailMobileButton />
|
||||||
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}>
|
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}>
|
||||||
<PlusIcon className="size-4" />Добавить
|
{busy === "from" ? "Синхронизация..." : "Router => DB"}
|
||||||
</Button>
|
</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}>
|
||||||
<div className="border-b bg-muted/20 px-6 py-3 flex items-center gap-3 flex-wrap">
|
<SaveIcon className="size-4" />Сохранить в БД
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
</Button>
|
||||||
<span className="text-muted-foreground">Всего маршрутов</span>
|
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}>
|
||||||
<span className="font-semibold tabular-nums">{rows.length}</span>
|
<PlusIcon className="size-4" />Добавить
|
||||||
</div>
|
</Button>
|
||||||
<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 (
|
banner={
|
||||||
<button key={s.id}
|
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
|
||||||
onClick={() => setSelectedServerId(s.id)}
|
<div className="relative min-w-[200px] max-w-xs flex-1">
|
||||||
className={cn(
|
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
|
<Input className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
|
||||||
active
|
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
? "bg-foreground text-background border-foreground"
|
{search && (
|
||||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
<button onClick={() => setSearch("")}
|
||||||
!s.enabled && !active && "opacity-40",
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||||||
)}>
|
<XIcon className="size-3.5" />
|
||||||
<StatusDot status={s.status} />
|
</button>
|
||||||
<Flag code={s.country} size={12} />
|
)}
|
||||||
<span className="font-mono">{s.name}</span>
|
</div>
|
||||||
<TypeChip type={s.type} />
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
<span className={cn(
|
<span className="text-muted-foreground">Всего маршрутов</span>
|
||||||
"tabular-nums font-semibold",
|
<span className="font-semibold tabular-nums">{rows.length}</span>
|
||||||
active ? "" : count > 0 ? "text-foreground" : "opacity-40",
|
</div>
|
||||||
)}>{count}</span>
|
<p className="text-xs text-muted-foreground ml-auto">
|
||||||
</button>
|
{filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
|
||||||
)
|
</p>
|
||||||
})}
|
{opError && (
|
||||||
</div>
|
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
|
||||||
|
{opError}
|
||||||
<div className="px-6 py-3 flex items-center gap-3 border-b flex-wrap shrink-0">
|
</div>
|
||||||
<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>
|
||||||
<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 ? (
|
{!isLive ? (
|
||||||
<Frame dense className="w-full">
|
<Frame dense className="w-full">
|
||||||
<FramePanel className="p-6 text-sm text-muted-foreground">
|
<FramePanel className="p-6 text-sm text-muted-foreground">
|
||||||
@@ -692,7 +688,7 @@ export default function RecursiveRoutesPage() {
|
|||||||
</button>
|
</button>
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
)}
|
)}
|
||||||
</div>
|
</ServerRailLayout>
|
||||||
|
|
||||||
<RouteSheet
|
<RouteSheet
|
||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
@@ -702,6 +698,6 @@ export default function RecursiveRoutesPage() {
|
|||||||
onClose={() => setSheetOpen(false)}
|
onClose={() => setSheetOpen(false)}
|
||||||
gateways={gatewayOptions}
|
gateways={gatewayOptions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { RouteOptimizerOspfPreviewDataGrid } from "@/components/data-grids/route-optimizer-ospf-preview-data-grid"
|
||||||
import { FormToggle } from "@/components/form-kit"
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
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 { OpsPanel } from "@/components/ops-panel"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -589,32 +589,25 @@ export default function RouteOptimizerPage() {
|
|||||||
label: "Home роутеров",
|
label: "Home роутеров",
|
||||||
value: homeCount,
|
value: homeCount,
|
||||||
sub: `${wanCount} WAN-аплинков`,
|
sub: `${wanCount} WAN-аплинков`,
|
||||||
icon: <MonitorIcon className="size-4 text-muted-foreground" />,
|
icon: <MonitorIcon className="size-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "JumpHost",
|
label: "JumpHost",
|
||||||
value: jh.length,
|
value: jh.length,
|
||||||
sub: jhSub,
|
sub: jhSub,
|
||||||
icon: <ServerIcon className="size-4 text-violet-400" />,
|
icon: <ServerIcon className="size-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Exit Node",
|
label: "Exit Node",
|
||||||
value: ex.length,
|
value: ex.length,
|
||||||
sub: exSub,
|
sub: exSub,
|
||||||
icon: <NetworkIcon className="size-4 text-emerald-500" />,
|
icon: <NetworkIcon className="size-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Переключений",
|
label: "Переключений",
|
||||||
value: totalSwitches,
|
value: totalSwitches,
|
||||||
sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально",
|
sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально",
|
||||||
icon: (
|
icon: <ZapIcon className="size-4" />,
|
||||||
<ZapIcon
|
|
||||||
className={cn(
|
|
||||||
"size-4",
|
|
||||||
totalSwitches > 0 ? "text-amber-500" : "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}, [useLiveData, data, liveJumpHosts, liveExitNodes, totalSwitches])
|
}, [useLiveData, data, liveJumpHosts, liveExitNodes, totalSwitches])
|
||||||
@@ -762,23 +755,24 @@ export default function RouteOptimizerPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats chips */}
|
<KpiStatGrid
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
aria-label="Сводка оптимизатора"
|
||||||
{statsChips.map((s) => (
|
items={statsChips.map((s, i) => ({
|
||||||
<Frame key={s.label} className="h-full">
|
id: `ro-${i}`,
|
||||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
label: s.label,
|
||||||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
value: s.value,
|
||||||
{s.icon}
|
hint: s.sub,
|
||||||
</IconTile>
|
icon: s.icon,
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
iconClassName: s.label === "Переключений" && totalSwitches > 0
|
||||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
? "text-warning"
|
||||||
<p className="text-xl leading-none font-bold tabular-nums">{s.value}</p>
|
: s.label === "Exit Node"
|
||||||
<p className="text-[10px] text-muted-foreground">{s.sub}</p>
|
? "text-success"
|
||||||
</div>
|
: s.label === "JumpHost"
|
||||||
</FramePanel>
|
? "text-primary"
|
||||||
</Frame>
|
: "text-muted-foreground",
|
||||||
))}
|
variant: s.label === "Переключений" && totalSwitches > 0 ? "warning" as const : "default" as const,
|
||||||
</div>
|
}))}
|
||||||
|
/>
|
||||||
|
|
||||||
{error && (
|
{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">
|
<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
@@ -27,8 +27,7 @@ import {
|
|||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { IconTile } from "@/components/reui/icon-tile"
|
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
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-1 overflow-y-auto p-6">
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
{/* Stats */}
|
<KpiStatGrid
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
aria-label="Сводка серверов"
|
||||||
{[
|
items={[
|
||||||
{ 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)]" },
|
id: "all",
|
||||||
{ label: "JH + Exit Node", value: counts["jump-host"] + counts["exit-node"], icon: <NetworkIcon className="size-4" />, iconClass: "text-muted-foreground" },
|
label: "Всего серверов",
|
||||||
{ label: "Home Router", value: counts["home-router"],icon: <HomeIcon className="size-4" />, iconClass: "text-muted-foreground" },
|
value: counts.all,
|
||||||
].map(s => (
|
icon: <ServerIcon className="size-4" />,
|
||||||
<Frame key={s.label} className="h-full">
|
iconClassName: "text-muted-foreground",
|
||||||
<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}
|
id: "online",
|
||||||
</IconTile>
|
label: "Онлайн",
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
value: counts.online,
|
||||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
icon: <CheckCircleIcon className="size-4" />,
|
||||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
iconClassName: "text-success",
|
||||||
</div>
|
},
|
||||||
</FramePanel>
|
{
|
||||||
</Frame>
|
id: "jh-en",
|
||||||
))}
|
label: "JH + Exit Node",
|
||||||
</div>
|
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 */}
|
{/* Table */}
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
|
|||||||
@@ -16,18 +16,12 @@ import {
|
|||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from "@/components/reui/frame"
|
} from "@/components/reui/frame"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
import {
|
import {
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from "@/components/ui/sheet"
|
|
||||||
import {
|
|
||||||
ServerTileRail,
|
|
||||||
type ServerTileItem,
|
type ServerTileItem,
|
||||||
} from "@/components/server-tile-rail"
|
} from "@/components/server-tile-rail"
|
||||||
import {
|
import {
|
||||||
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon, ServerIcon,
|
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@@ -456,7 +450,6 @@ export default function TerminalPage() {
|
|||||||
const [liveServers, setLiveServers] = useState<TermServer[]>([])
|
const [liveServers, setLiveServers] = useState<TermServer[]>([])
|
||||||
const [serversLoading, setServersLoading] = useState(false)
|
const [serversLoading, setServersLoading] = useState(false)
|
||||||
const [refreshKey, setRefreshKey] = useState(0) // force terminal remount on reconnect
|
const [refreshKey, setRefreshKey] = useState(0) // force terminal remount on reconnect
|
||||||
const [railOpen, setRailOpen] = useState(false)
|
|
||||||
|
|
||||||
// Load servers from backend when in live mode
|
// Load servers from backend when in live mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -516,6 +509,7 @@ export default function TerminalPage() {
|
|||||||
return termServers.map((s) => ({
|
return termServers.map((s) => ({
|
||||||
id: s.uid,
|
id: s.uid,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
country: s.country || undefined,
|
country: s.country || undefined,
|
||||||
status: (s.status ?? undefined) as ServerStatus | undefined,
|
status: (s.status ?? undefined) as ServerStatus | undefined,
|
||||||
enabled: s.enabled,
|
enabled: s.enabled,
|
||||||
@@ -524,31 +518,15 @@ export default function TerminalPage() {
|
|||||||
}))
|
}))
|
||||||
}, [termServers])
|
}, [termServers])
|
||||||
|
|
||||||
function handleSelectServer(id: string) {
|
const handleSelectServer = useCallback((id: string) => {
|
||||||
setSelectedUid(id)
|
setSelectedUid(id)
|
||||||
setRefreshKey((k) => k + 1)
|
setRefreshKey((k) => k + 1)
|
||||||
setRailOpen(false)
|
}, [])
|
||||||
}
|
|
||||||
|
|
||||||
const railHeaderRight = isLive
|
const railHeaderRight = isLive && !serversLoading
|
||||||
? serversLoading
|
? <Badge variant="success-light" size="xs">LIVE</Badge>
|
||||||
? <Loader2Icon className="size-3.5 animate-spin text-muted-foreground" />
|
|
||||||
: <Badge variant="success-light" size="xs">LIVE</Badge>
|
|
||||||
: undefined
|
: 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() {
|
function QuickCmds() {
|
||||||
return (
|
return (
|
||||||
<Frame dense spacing="sm" className="min-h-0 shrink-0">
|
<Frame dense spacing="sm" className="min-h-0 shrink-0">
|
||||||
@@ -595,42 +573,39 @@ export default function TerminalPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<ServerRailLayout
|
||||||
<PageHeader
|
items={railItems}
|
||||||
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
|
selectedId={selected?.uid ?? selectedUid}
|
||||||
actions={
|
onSelect={handleSelectServer}
|
||||||
<>
|
showAll={false}
|
||||||
<Button
|
showCount={false}
|
||||||
size="sm"
|
showType={false}
|
||||||
variant="outline"
|
headerRight={railHeaderRight}
|
||||||
className="md:hidden"
|
loading={serversLoading}
|
||||||
onClick={() => setRailOpen(true)}
|
extra={<QuickCmds />}
|
||||||
>
|
contentClassName="overflow-hidden p-3 md:p-4"
|
||||||
<ServerIcon className="size-4" />
|
header={
|
||||||
{selected?.name ?? "Сервер"}
|
<PageHeader
|
||||||
</Button>
|
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
|
||||||
<Button
|
actions={
|
||||||
variant="outline"
|
<>
|
||||||
size="sm"
|
<ServerRailMobileButton />
|
||||||
onClick={() => {
|
<Button
|
||||||
setRefreshKey((k) => k + 1)
|
variant="outline"
|
||||||
if (isLive) setSelectedUid("")
|
size="sm"
|
||||||
}}
|
onClick={() => {
|
||||||
>
|
setRefreshKey((k) => k + 1)
|
||||||
<RefreshCwIcon className="size-4" />
|
if (isLive) setSelectedUid("")
|
||||||
Переподключить
|
}}
|
||||||
</Button>
|
>
|
||||||
</>
|
<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">
|
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<Terminal
|
<Terminal
|
||||||
key={termKey}
|
key={termKey}
|
||||||
@@ -643,30 +618,6 @@ export default function TerminalPage() {
|
|||||||
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
|
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</ServerRailLayout>
|
||||||
</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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+128
-134
@@ -3,7 +3,8 @@
|
|||||||
import { useState, useMemo, useEffect, useCallback } from "react"
|
import { useState, useMemo, useEffect, useCallback } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
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 { Button } from "@/components/ui/button"
|
||||||
import { Sparkline } from "@/components/sparkline"
|
import { Sparkline } from "@/components/sparkline"
|
||||||
import { StatusDot } from "@/components/status-dot"
|
import { StatusDot } from "@/components/status-dot"
|
||||||
@@ -13,7 +14,9 @@ import {
|
|||||||
ArrowUpIcon, ArrowDownIcon, ActivityIcon, UsersIcon, CableIcon, ServerIcon, SearchIcon,
|
ArrowUpIcon, ArrowDownIcon, ActivityIcon, UsersIcon, CableIcon, ServerIcon, SearchIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { fmtGB, fmtRate } from "@/lib/fmt-rate"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { useTrafficLive } from "@/hooks/use-traffic-live"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||||
@@ -32,16 +35,6 @@ function addSeries(a: number[], b: number[]): number[] {
|
|||||||
return a.map((v, i) => v + (b[i] ?? 0))
|
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 ───────────────────────────────────────────────────────────────
|
// ─── data model ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface GreClientTraffic {
|
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(" ")
|
const line = (arr: number[]) => arr.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ")
|
||||||
return (
|
return (
|
||||||
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height, display: "block" }} preserveAspectRatio="none">
|
<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} />
|
<path d={area(rx)} fill="var(--chart-rx)" fillOpacity={0.12} />
|
||||||
<polyline points={line(rx)} fill="none" stroke="hsl(var(--primary))" strokeWidth="1.4" strokeLinejoin="round" />
|
<polyline points={line(rx)} fill="none" stroke="var(--chart-rx)" strokeWidth="1.4" strokeLinejoin="round" />
|
||||||
<path d={area(tx)} fill="#3b82f6" fillOpacity={0.10} />
|
<path d={area(tx)} fill="var(--chart-tx)" fillOpacity={0.10} />
|
||||||
<polyline points={line(tx)} fill="none" stroke="#3b82f6" strokeWidth="1.4" strokeLinejoin="round" />
|
<polyline points={line(tx)} fill="none" stroke="var(--chart-tx)" 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>
|
|
||||||
))}
|
|
||||||
</svg>
|
</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 justify-between mt-2 gap-2">
|
||||||
<div className="flex items-center gap-1 text-[11px]">
|
<div className="flex items-center gap-1 text-[11px]">
|
||||||
<ArrowDownIcon className="size-3 text-emerald-500" />
|
<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>
|
||||||
<div className="flex items-center gap-1 text-[11px]">
|
<div className="flex items-center gap-1 text-[11px]">
|
||||||
<ArrowUpIcon className="size-3 text-blue-500" />
|
<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>
|
</div>
|
||||||
{s.greClients.length > 0 && (
|
{s.greClients.length > 0 && (
|
||||||
<div className="flex items-center gap-0.5 text-[10px] text-muted-foreground">
|
<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 justify-between mt-2 gap-2">
|
||||||
<div className="flex items-center gap-1 text-[11px]">
|
<div className="flex items-center gap-1 text-[11px]">
|
||||||
<ArrowDownIcon className="size-3 text-emerald-500" />
|
<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>
|
||||||
<div className="flex items-center gap-1 text-[11px]">
|
<div className="flex items-center gap-1 text-[11px]">
|
||||||
<ArrowUpIcon className="size-3 text-blue-500" />
|
<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>
|
||||||
</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 justify-between mt-2 gap-2">
|
||||||
<div className="flex items-center gap-1 text-[11px]">
|
<div className="flex items-center gap-1 text-[11px]">
|
||||||
<ArrowDownIcon className="size-3 text-emerald-500" />
|
<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>
|
||||||
<div className="flex items-center gap-1 text-[11px]">
|
<div className="flex items-center gap-1 text-[11px]">
|
||||||
<ArrowUpIcon className="size-3 text-blue-500" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -525,10 +465,10 @@ function GreClientRow({ c, showServer = false }: { c: GreClientTraffic; showServ
|
|||||||
{/* live RX / TX */}
|
{/* live RX / TX */}
|
||||||
<div className="shrink-0 text-right leading-tight">
|
<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">
|
<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>
|
||||||
<div className="flex items-center gap-1 justify-end text-xs font-mono font-medium text-blue-600 dark:text-blue-400">
|
<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>
|
||||||
</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 }) {
|
function OfflinePlaceholder({ text = "Нет данных — объект недоступен" }: { text?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-[220px] text-muted-foreground/40">
|
<div className="flex items-center justify-center h-[220px] text-muted-foreground/40">
|
||||||
@@ -611,18 +538,18 @@ function TotalsRow({ rxTotal, txTotal, rxSeries, txSeries }: {
|
|||||||
return (
|
return (
|
||||||
<div className="mt-4 pt-4 border-t grid grid-cols-2 gap-4">
|
<div className="mt-4 pt-4 border-t grid grid-cols-2 gap-4">
|
||||||
<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">
|
<p className="text-lg font-semibold tabular-nums">
|
||||||
{rxTotal.toFixed(1)} <span className="text-sm font-normal text-muted-foreground">ГБ</span>
|
{rxTotal.toFixed(1)} <span className="text-sm font-normal text-muted-foreground">ГБ</span>
|
||||||
</p>
|
</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>
|
||||||
<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">
|
<p className="text-lg font-semibold tabular-nums">
|
||||||
{txTotal.toFixed(1)} <span className="text-sm font-normal text-muted-foreground">ГБ</span>
|
{txTotal.toFixed(1)} <span className="text-sm font-normal text-muted-foreground">ГБ</span>
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -630,7 +557,18 @@ function TotalsRow({ rxTotal, txTotal, rxSeries, txSeries }: {
|
|||||||
|
|
||||||
// ─── detail panels ────────────────────────────────────────────────────────────
|
// ─── 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<DetailHeader range={range} setRange={setRange}>
|
<DetailHeader range={range} setRange={setRange}>
|
||||||
@@ -640,13 +578,43 @@ function ServerDetail({ sel, range, setRange }: { sel: ServerTraffic; range: Ran
|
|||||||
<Flag code={sel.country} />{sel.site}
|
<Flag code={sel.country} />{sel.site}
|
||||||
</span>
|
</span>
|
||||||
</DetailHeader>
|
</DetailHeader>
|
||||||
<ChartLegend />
|
{sel.status === "offline" ? <OfflinePlaceholder /> : <TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />}
|
||||||
{sel.status === "offline" ? <OfflinePlaceholder /> : <BigChart rx={sel.rxSeries} tx={sel.txSeries} />}
|
<div className="mt-4 pt-4 border-t">
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4 pt-4 border-t">
|
<KpiStatGrid
|
||||||
<StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX сейчас" value={fmtMbps(sel.rxNow)} />
|
aria-label="Скорость выбранного сервера"
|
||||||
<StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX сейчас" value={fmtMbps(sel.txNow)} />
|
items={[
|
||||||
<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}`} />
|
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>
|
</div>
|
||||||
<TotalsRow rxTotal={sel.rxTotal} txTotal={sel.txTotal} rxSeries={sel.rxSeries} txSeries={sel.txSeries} />
|
<TotalsRow rxTotal={sel.rxTotal} txTotal={sel.txTotal} rxSeries={sel.rxSeries} txSeries={sel.txSeries} />
|
||||||
{sel.greClients.length > 0 && (
|
{sel.greClients.length > 0 && (
|
||||||
@@ -680,12 +648,11 @@ function UserDetail({ sel, range, setRange }: { sel: UserTraffic; range: Range;
|
|||||||
<OfflinePlaceholder text="Нет GRE-клиентов у этого пользователя" />
|
<OfflinePlaceholder text="Нет GRE-клиентов у этого пользователя" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ChartLegend />
|
<TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />
|
||||||
<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">
|
<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={<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={fmtMbps(sel.txNow)} />
|
<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={fmtMbps(sel.rxPeak)} />
|
<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}`} />
|
<StatChip icon={<CableIcon className="size-3.5 text-purple-500" />} label="GRE-клиентов" value={`${sel.greClients.length}`} />
|
||||||
</div>
|
</div>
|
||||||
<TotalsRow rxTotal={sel.rxTotal} txTotal={sel.txTotal} rxSeries={sel.rxSeries} txSeries={sel.txSeries} />
|
<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}
|
<Flag code={sel.serverCountry} />{sel.serverSite}
|
||||||
</span>
|
</span>
|
||||||
</DetailHeader>
|
</DetailHeader>
|
||||||
<ChartLegend />
|
{sel.status === "offline" ? <OfflinePlaceholder /> : <TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />}
|
||||||
{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">
|
<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={<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={fmtMbps(sel.txNow)} />
|
<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={fmtMbps(sel.rxPeak)} />
|
<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={fmtMbps(sel.txPeak)} />
|
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик TX" value={fmtRate(sel.txPeak)} />
|
||||||
</div>
|
</div>
|
||||||
<TotalsRow rxTotal={sel.rxTotal} txTotal={sel.txTotal} rxSeries={sel.rxSeries} txSeries={sel.txSeries} />
|
<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">
|
<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 [detailBusy, setDetailBusy] = useState(false)
|
||||||
const [liveDetailServer, setLiveDetailServer] = useState<ServerTraffic | null>(null)
|
const [liveDetailServer, setLiveDetailServer] = useState<ServerTraffic | null>(null)
|
||||||
const effectiveMode: GroupMode = isLive ? "servers" : groupMode
|
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 => {
|
const toLiveServer = (s: LiveTrafficServer): ServerTraffic => {
|
||||||
return {
|
return {
|
||||||
@@ -917,6 +889,7 @@ export default function TrafficPage() {
|
|||||||
}, [sortField, sortDir, q])
|
}, [sortField, sortDir, q])
|
||||||
|
|
||||||
const selServer = useMemo(() => activeServerTraffic.find(s => s.id === selectedId) ?? activeServerTraffic[0], [selectedId, activeServerTraffic])
|
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 selUser = useMemo(() => userTraffic.find(u => u.id === selectedId) ?? userTraffic[0], [selectedId])
|
||||||
const selGre = useMemo(() => greClients.find(c => c.id === selectedId) ?? greClients[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-1 overflow-y-auto p-6">
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
{/* ── summary stat cards ── */}
|
<KpiStatGrid
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
aria-label="Сводка трафика"
|
||||||
{[
|
items={[
|
||||||
{ 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) },
|
id: "rx",
|
||||||
{ icon: <TrendingUpIcon className="size-3.5 text-amber-500" />, label: "Пик RX", value: fmtMbps(peakRx) },
|
label: "RX сейчас",
|
||||||
{ icon: <TrendingUpIcon className="size-3.5 text-amber-500" />, label: "Пик TX", value: fmtMbps(peakTx) },
|
value: fmtRate(totalRx),
|
||||||
].map(({ icon, label, value }) => (
|
icon: <ArrowDownIcon className="size-4" />,
|
||||||
<Frame key={label} className="h-full overflow-hidden">
|
iconClassName: "text-success",
|
||||||
<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}
|
id: "tx",
|
||||||
</IconTile>
|
label: "TX сейчас",
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
value: fmtRate(totalTx),
|
||||||
<p className="text-muted-foreground text-sm font-medium">{label}</p>
|
icon: <ArrowUpIcon className="size-4" />,
|
||||||
<p className="text-2xl leading-none font-bold tabular-nums tracking-tight">{value}</p>
|
iconClassName: "text-info",
|
||||||
</div>
|
},
|
||||||
</FramePanel>
|
{
|
||||||
</Frame>
|
id: "peak-rx",
|
||||||
))}
|
label: "Пик RX",
|
||||||
</div>
|
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">
|
<div className="grid grid-cols-[300px_1fr] gap-5 items-start">
|
||||||
|
|
||||||
@@ -1091,7 +1076,16 @@ export default function TrafficPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 === "users" && <UserDetail sel={selUser} range={range} setRange={setRange} />}
|
||||||
{effectiveMode === "gre" && <GreDetail sel={selGre} range={range} setRange={setRange} />}
|
{effectiveMode === "gre" && <GreDetail sel={selGre} range={range} setRange={setRange} />}
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
|
|||||||
+89
-46
@@ -4,7 +4,7 @@ import { useState, useMemo, useEffect, useRef, useCallback } from "react"
|
|||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
|
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
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 { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
@@ -961,29 +961,58 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── KPI summary ───────────────────────────────────────────────────── */}
|
<KpiStatGrid
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
aria-label="Сводка ресурсов"
|
||||||
{[
|
items={[
|
||||||
{ 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) },
|
id: "servers",
|
||||||
{ icon: <HardDriveIcon className="size-4" />, label: `${avgRam}%`, sub: "средний RAM", color: resPctColor(avgRam) },
|
label: "Серверов всего",
|
||||||
{ icon: <AlertCircleIcon className="size-4" />, label: String(highCpu), sub: "CPU > 85%", color: highCpu > 0 ? "text-red-500" : "text-muted-foreground" },
|
value: rows.length,
|
||||||
{ icon: <AlertCircleIcon className="size-4" />, label: String(highRam), sub: "RAM > 85%", color: highRam > 0 ? "text-red-500" : "text-muted-foreground" },
|
icon: <ServerIcon className="size-4" />,
|
||||||
{ icon: <AlertCircleIcon className="size-4" />, label: String(highHdd), sub: "Диск > 85%", color: highHdd > 0 ? "text-amber-500" : "text-muted-foreground" },
|
iconClassName: "text-muted-foreground",
|
||||||
].map(kpi => (
|
},
|
||||||
<Frame key={kpi.sub} className="h-full">
|
{
|
||||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
id: "cpu",
|
||||||
<IconTile variant="elevated" aria-hidden="true" className={cn("size-10.5", kpi.color)}>
|
label: "Средний CPU",
|
||||||
{kpi.icon}
|
value: `${avgCpu}%`,
|
||||||
</IconTile>
|
icon: <CpuIcon className="size-4" />,
|
||||||
<div className="min-w-0 flex flex-col gap-0.5">
|
iconClassName: avgCpu >= 85 ? "text-destructive" : avgCpu >= 70 ? "text-warning" : "text-success",
|
||||||
<p className={cn("text-xl leading-none font-bold tabular-nums", kpi.color)}>{kpi.label}</p>
|
variant: avgCpu >= 85 ? "destructive" : avgCpu >= 70 ? "warning" : "default",
|
||||||
<p className="text-[11px] text-muted-foreground">{kpi.sub}</p>
|
},
|
||||||
</div>
|
{
|
||||||
</FramePanel>
|
id: "ram",
|
||||||
</Frame>
|
label: "Средний RAM",
|
||||||
))}
|
value: `${avgRam}%`,
|
||||||
</div>
|
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 ─────────────────────────────────────────────────────────── */}
|
{/* ── Table ─────────────────────────────────────────────────────────── */}
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
@@ -2022,28 +2051,42 @@ export default function UptimePage() {
|
|||||||
const maxTx = doneRuns.length ? Math.max(...doneRuns.map(r => r.txAvgMbps)) : null
|
const maxTx = doneRuns.length ? Math.max(...doneRuns.map(r => r.txAvgMbps)) : null
|
||||||
const maxRx = doneRuns.length ? Math.max(...doneRuns.map(r => r.rxAvgMbps)) : null
|
const maxRx = doneRuns.length ? Math.max(...doneRuns.map(r => r.rxAvgMbps)) : null
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 px-6 py-4 border-b bg-muted/10 shrink-0">
|
<div className="px-6 py-4 border-b bg-muted/10 shrink-0">
|
||||||
{[
|
<KpiStatGrid
|
||||||
{ label: "Speed-пробы", value: speedProbes.length, unit: "шт", color: "" },
|
aria-label="Сводка speed-проб"
|
||||||
{ label: "Тестов выполнено", value: doneRuns.length, unit: "run", color: "" },
|
items={[
|
||||||
{ 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)]" },
|
id: "probes",
|
||||||
].map(k => (
|
label: "Speed-пробы",
|
||||||
<Frame key={k.label} className="h-full">
|
value: `${speedProbes.length} шт`,
|
||||||
<FramePanel className="flex flex-col gap-0.5">
|
icon: <ArrowUpDownIcon className="size-4" />,
|
||||||
<p className="text-muted-foreground text-sm font-medium">{k.label}</p>
|
iconClassName: "text-muted-foreground",
|
||||||
<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>}
|
id: "runs",
|
||||||
</div>
|
label: "Тестов выполнено",
|
||||||
{runningCnt > 0 && k.label === "Тестов выполнено" && (
|
value: `${doneRuns.length} run`,
|
||||||
<p className="text-[11px] text-[var(--status-degraded-fg)] flex items-center gap-1 mt-0.5">
|
hint: runningCnt > 0 ? `${runningCnt} выполняется` : undefined,
|
||||||
<RefreshCwIcon className="size-2.5 animate-spin" />{runningCnt} выполняется
|
icon: <PlayIcon className="size-4" />,
|
||||||
</p>
|
iconClassName: runningCnt > 0 ? "text-warning" : "text-muted-foreground",
|
||||||
)}
|
variant: runningCnt > 0 ? "warning" : "default",
|
||||||
</FramePanel>
|
},
|
||||||
</Frame>
|
{
|
||||||
))}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
+34
-24
@@ -4,14 +4,12 @@ import { useMemo, useState } from "react"
|
|||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { vxlanTunnels, servers } from "@/lib/data"
|
import { vxlanTunnels, servers } from "@/lib/data"
|
||||||
import type { VxlanTunnel } from "@/lib/data"
|
import type { VxlanTunnel } from "@/lib/data"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { IconTile } from "@/components/reui/icon-tile"
|
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
|
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import {
|
import {
|
||||||
NetworkIcon, PlusIcon, CodeXmlIcon, LayersIcon,
|
NetworkIcon, PlusIcon, CodeXmlIcon, LayersIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@@ -119,27 +117,39 @@ export default function VxlanPage() {
|
|||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
{/* KPI */}
|
<KpiStatGrid
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
aria-label="Сводка VXLAN"
|
||||||
{[
|
items={[
|
||||||
{ label: "Туннелей", value: vxlanTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
|
{
|
||||||
{ label: "Активных", value: upCount, icon: <LayersIcon className="size-4 text-emerald-500" /> },
|
id: "tunnels",
|
||||||
{ label: "Уникальных VNI", value: vnis, icon: <LayersIcon className="size-4 text-sky-400" /> },
|
label: "Туннелей",
|
||||||
{ label: "Серверов", value: new Set(vxlanTunnels.map((t) => t.serverId)).size, icon: <NetworkIcon className="size-4 text-violet-400" /> },
|
value: vxlanTunnels.length,
|
||||||
].map((s) => (
|
icon: <NetworkIcon className="size-4" />,
|
||||||
<Frame key={s.label} className="h-full">
|
iconClassName: "text-muted-foreground",
|
||||||
<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}
|
id: "up",
|
||||||
</IconTile>
|
label: "Активных",
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
value: upCount,
|
||||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
icon: <LayersIcon className="size-4" />,
|
||||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
iconClassName: "text-success",
|
||||||
</div>
|
},
|
||||||
</FramePanel>
|
{
|
||||||
</Frame>
|
id: "vni",
|
||||||
))}
|
label: "Уникальных VNI",
|
||||||
</div>
|
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 */}
|
{/* 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">
|
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
|
||||||
|
|||||||
@@ -23,12 +23,6 @@ import {
|
|||||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from "@/components/ui/sheet"
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -57,16 +51,16 @@ import { WgCreateSheet, type WgCreateFormState } from "@/components/wireguard/wg
|
|||||||
import { WgImportSheet } from "@/components/wireguard/wg-import-sheet"
|
import { WgImportSheet } from "@/components/wireguard/wg-import-sheet"
|
||||||
import { WgExportSheet } from "@/components/wireguard/wg-export-sheet"
|
import { WgExportSheet } from "@/components/wireguard/wg-export-sheet"
|
||||||
import { WgPeerSheet, type WgPeerFormState } from "@/components/wireguard/wg-peer-sheet"
|
import { WgPeerSheet, type WgPeerFormState } from "@/components/wireguard/wg-peer-sheet"
|
||||||
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
import {
|
import {
|
||||||
ALL_SERVERS_ID,
|
ALL_SERVERS_ID,
|
||||||
ServerTileRail,
|
|
||||||
type ServerTileItem,
|
type ServerTileItem,
|
||||||
} from "@/components/server-tile-rail"
|
} from "@/components/server-tile-rail"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import {
|
import {
|
||||||
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
|
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
|
||||||
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon,
|
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon,
|
||||||
ServerIcon, Trash2Icon, CodeXmlIcon, AlertCircleIcon,
|
Trash2Icon, CodeXmlIcon, AlertCircleIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
type WgWorkspaceTab = "interfaces" | "peers" | "cli"
|
type WgWorkspaceTab = "interfaces" | "peers" | "cli"
|
||||||
@@ -186,7 +180,6 @@ export default function WireGuardPage() {
|
|||||||
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||||
const [workspaceTab, setWorkspaceTab] = useState<WgWorkspaceTab>("interfaces")
|
const [workspaceTab, setWorkspaceTab] = useState<WgWorkspaceTab>("interfaces")
|
||||||
const [statusFilter, setStatusFilter] = useState<WgStatusFilter>("all")
|
const [statusFilter, setStatusFilter] = useState<WgStatusFilter>("all")
|
||||||
const [railOpen, setRailOpen] = useState(false)
|
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [importOpen, setImportOpen] = useState(false)
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
@@ -338,6 +331,8 @@ export default function WireGuardPage() {
|
|||||||
return displayServers.map((s) => ({
|
return displayServers.map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
country: s.country,
|
country: s.country,
|
||||||
status: s.status,
|
status: s.status,
|
||||||
type: s.type,
|
type: s.type,
|
||||||
@@ -347,16 +342,6 @@ export default function WireGuardPage() {
|
|||||||
}))
|
}))
|
||||||
}, [displayServers, displayIfaces])
|
}, [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) {
|
async function handleCreate(form: WgCreateFormState) {
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
toast.info("Создание на роутер доступно только в live-режиме")
|
toast.info("Создание на роутер доступно только в live-режиме")
|
||||||
@@ -543,59 +528,45 @@ export default function WireGuardPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
|
|
||||||
const rail = (
|
return (
|
||||||
<ServerTileRail
|
<>
|
||||||
|
<ServerRailLayout
|
||||||
items={railItems}
|
items={railItems}
|
||||||
selectedId={effectiveServerId}
|
selectedId={effectiveServerId}
|
||||||
onSelect={handleSelectServer}
|
onSelect={setSelectedServerId}
|
||||||
|
showAll
|
||||||
allCount={displayIfaces.length}
|
allCount={displayIfaces.length}
|
||||||
/>
|
loading={isLive && loading && displayServers.length === 0}
|
||||||
)
|
header={
|
||||||
|
<PageHeader
|
||||||
return (
|
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
|
||||||
<div className="flex h-full flex-col">
|
actions={
|
||||||
<PageHeader
|
<>
|
||||||
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
|
<ServerRailMobileButton />
|
||||||
actions={
|
{isLive && (
|
||||||
<>
|
<Button
|
||||||
<Button
|
size="sm"
|
||||||
size="sm"
|
variant="outline"
|
||||||
variant="outline"
|
disabled={loading}
|
||||||
className="md:hidden"
|
onClick={() => void loadLive()}
|
||||||
onClick={() => setRailOpen(true)}
|
>
|
||||||
>
|
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
<ServerIcon className="size-4" />
|
Обновить
|
||||||
{selectedLabel}
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
{isLive && (
|
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
|
||||||
<Button
|
<UploadIcon className="size-4" />
|
||||||
size="sm"
|
Импорт
|
||||||
variant="outline"
|
|
||||||
disabled={loading}
|
|
||||||
onClick={() => void loadLive()}
|
|
||||||
>
|
|
||||||
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
|
||||||
Обновить
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
|
<PlusIcon className="size-4" />
|
||||||
<UploadIcon className="size-4" />
|
Новый интерфейс
|
||||||
Импорт
|
</Button>
|
||||||
</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">
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
aria-label="Сводка WireGuard"
|
aria-label="Сводка WireGuard"
|
||||||
@@ -785,26 +756,7 @@ export default function WireGuardPage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ServerRailLayout>
|
||||||
</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>
|
|
||||||
|
|
||||||
<WgCreateSheet
|
<WgCreateSheet
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
@@ -870,6 +822,6 @@ export default function WireGuardPage() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
|
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
|
||||||
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts"
|
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts",
|
||||||
|
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import certificatesRoutes from "./routes/certificates.js"
|
|||||||
import systemDatabaseRoutes from "./routes/system-database.js"
|
import systemDatabaseRoutes from "./routes/system-database.js"
|
||||||
import eventsRoutes from "./routes/events.js"
|
import eventsRoutes from "./routes/events.js"
|
||||||
import wireguardRoutes from "./routes/wireguard.js"
|
import wireguardRoutes from "./routes/wireguard.js"
|
||||||
|
import firewallRoutes from "./routes/firewall.js"
|
||||||
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
||||||
|
|
||||||
export async function buildApp(opts?: {
|
export async function buildApp(opts?: {
|
||||||
@@ -105,6 +106,7 @@ export async function buildApp(opts?: {
|
|||||||
await app.register(systemDatabaseRoutes, { prefix: "/api" })
|
await app.register(systemDatabaseRoutes, { prefix: "/api" })
|
||||||
await app.register(eventsRoutes, { prefix: "/api" })
|
await app.register(eventsRoutes, { prefix: "/api" })
|
||||||
await app.register(wireguardRoutes, { prefix: "/api" })
|
await app.register(wireguardRoutes, { prefix: "/api" })
|
||||||
|
await app.register(firewallRoutes, { prefix: "/api" })
|
||||||
|
|
||||||
if (opts?.startScheduler !== false) {
|
if (opts?.startScheduler !== false) {
|
||||||
refreshScheduler()
|
refreshScheduler()
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ assert.equal(
|
|||||||
permissionForRequest("GET", "/api/system/database/backup"),
|
permissionForRequest("GET", "/api/system/database/backup"),
|
||||||
"mm:settings:admin",
|
"mm:settings:admin",
|
||||||
)
|
)
|
||||||
|
assert.equal(
|
||||||
|
permissionForRequest("GET", "/api/traffic/servers/1/live"),
|
||||||
|
"mm:traffic:read",
|
||||||
|
)
|
||||||
assert.equal(
|
assert.equal(
|
||||||
permissionForRequest("GET", "/api/unknown-thing"),
|
permissionForRequest("GET", "/api/unknown-thing"),
|
||||||
"mm:dashboard:read",
|
"mm:dashboard:read",
|
||||||
@@ -29,5 +33,13 @@ assert.equal(
|
|||||||
permissionForRequest("POST", "/api/wireguard/interfaces"),
|
permissionForRequest("POST", "/api/wireguard/interfaces"),
|
||||||
"mm:network:write",
|
"mm:network:write",
|
||||||
)
|
)
|
||||||
|
assert.equal(
|
||||||
|
permissionForRequest("GET", "/api/firewall/all"),
|
||||||
|
"mm:network:read",
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
permissionForRequest("POST", "/api/firewall/rules"),
|
||||||
|
"mm:network:write",
|
||||||
|
)
|
||||||
|
|
||||||
console.log("permissions.test.ts: ok")
|
console.log("permissions.test.ts: ok")
|
||||||
|
|||||||
@@ -142,7 +142,8 @@ const RULES: Rule[] = [
|
|||||||
p.startsWith("/api/probes") ||
|
p.startsWith("/api/probes") ||
|
||||||
p.startsWith("/api/internet-path") ||
|
p.startsWith("/api/internet-path") ||
|
||||||
p.startsWith("/api/exec") ||
|
p.startsWith("/api/exec") ||
|
||||||
p.startsWith("/api/wireguard"),
|
p.startsWith("/api/wireguard") ||
|
||||||
|
p.startsWith("/api/firewall"),
|
||||||
permission: "mm:network:read",
|
permission: "mm:network:read",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -154,7 +155,8 @@ const RULES: Rule[] = [
|
|||||||
p.startsWith("/api/probes") ||
|
p.startsWith("/api/probes") ||
|
||||||
p.startsWith("/api/internet-path") ||
|
p.startsWith("/api/internet-path") ||
|
||||||
p.startsWith("/api/exec") ||
|
p.startsWith("/api/exec") ||
|
||||||
p.startsWith("/api/wireguard"),
|
p.startsWith("/api/wireguard") ||
|
||||||
|
p.startsWith("/api/firewall"),
|
||||||
permission: "mm:network:write",
|
permission: "mm:network:write",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { MikrotikClient, MikrotikError, encodeRosId, firewallRestPath } from "../services/mikrotik.js"
|
||||||
|
import { getEnabledServerById } from "../services/wireguard-live.js"
|
||||||
|
import { listFirewallAll } from "../services/firewall-live.js"
|
||||||
|
import type { FirewallFamily, FirewallTable } from "../types/server.js"
|
||||||
|
|
||||||
|
const FamilySchema = z.enum(["ip", "ip6"])
|
||||||
|
const TableSchema = z.enum(["filter", "nat", "mangle", "raw"])
|
||||||
|
|
||||||
|
const RuleKeySchema = z.object({
|
||||||
|
serverId: z.string().min(1),
|
||||||
|
family: FamilySchema,
|
||||||
|
table: TableSchema,
|
||||||
|
rosId: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
const RuleWriteSchema = z.object({
|
||||||
|
serverId: z.string().min(1),
|
||||||
|
family: FamilySchema,
|
||||||
|
table: TableSchema,
|
||||||
|
rosId: z.string().min(1).optional(),
|
||||||
|
chain: z.string().min(1),
|
||||||
|
action: z.string().min(1),
|
||||||
|
protocol: z.string().optional(),
|
||||||
|
srcAddress: z.string().optional(),
|
||||||
|
dstAddress: z.string().optional(),
|
||||||
|
srcAddressList: z.string().optional(),
|
||||||
|
dstAddressList: z.string().optional(),
|
||||||
|
srcPort: z.string().optional(),
|
||||||
|
dstPort: z.string().optional(),
|
||||||
|
inInterface: z.string().optional(),
|
||||||
|
outInterface: z.string().optional(),
|
||||||
|
connectionState: z.string().optional(),
|
||||||
|
comment: z.string().optional(),
|
||||||
|
disabled: z.boolean().optional(),
|
||||||
|
log: z.boolean().optional(),
|
||||||
|
logPrefix: z.string().optional(),
|
||||||
|
tlsHost: z.string().optional(),
|
||||||
|
layer7Proto: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const RulePatchSchema = RuleKeySchema.extend({
|
||||||
|
disabled: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const RuleMoveSchema = RuleKeySchema.extend({
|
||||||
|
destinationRosId: z.string().min(1).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const AddressKeySchema = z.object({
|
||||||
|
serverId: z.string().min(1),
|
||||||
|
family: FamilySchema,
|
||||||
|
rosId: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
const AddressWriteSchema = z.object({
|
||||||
|
serverId: z.string().min(1),
|
||||||
|
family: FamilySchema,
|
||||||
|
rosId: z.string().min(1).optional(),
|
||||||
|
list: z.string().min(1),
|
||||||
|
address: z.string().min(1),
|
||||||
|
comment: z.string().optional(),
|
||||||
|
timeout: z.string().optional(),
|
||||||
|
disabled: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const AddressPatchSchema = AddressKeySchema.extend({
|
||||||
|
disabled: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
function toRosBody(obj: Record<string, string | undefined>): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {}
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
if (v !== undefined && v !== "") out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function ruleToRos(d: z.infer<typeof RuleWriteSchema>): Record<string, string> {
|
||||||
|
return toRosBody({
|
||||||
|
chain: d.chain,
|
||||||
|
action: d.action,
|
||||||
|
protocol: d.protocol && d.protocol !== "all" ? d.protocol : undefined,
|
||||||
|
"src-address": d.srcAddress,
|
||||||
|
"dst-address": d.dstAddress,
|
||||||
|
"src-address-list": d.srcAddressList,
|
||||||
|
"dst-address-list": d.dstAddressList,
|
||||||
|
"src-port": d.srcPort,
|
||||||
|
"dst-port": d.dstPort,
|
||||||
|
"in-interface": d.inInterface,
|
||||||
|
"out-interface": d.outInterface,
|
||||||
|
"connection-state": d.connectionState,
|
||||||
|
comment: d.comment,
|
||||||
|
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
|
||||||
|
log: d.log === true ? "yes" : d.log === false ? "no" : undefined,
|
||||||
|
"log-prefix": d.logPrefix,
|
||||||
|
"tls-host": d.tlsHost,
|
||||||
|
"layer7-protocol": d.layer7Proto,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function addressToRos(d: z.infer<typeof AddressWriteSchema>): Record<string, string> {
|
||||||
|
return toRosBody({
|
||||||
|
list: d.list,
|
||||||
|
address: d.address,
|
||||||
|
comment: d.comment,
|
||||||
|
timeout: d.timeout,
|
||||||
|
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function rosErr(e: unknown): string {
|
||||||
|
if (e instanceof MikrotikError) return e.message
|
||||||
|
if (e instanceof Error) return e.message
|
||||||
|
return String(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireServer(serverId: string) {
|
||||||
|
return getEnabledServerById(serverId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
|
app.get("/firewall/all", async (_req, reply) => {
|
||||||
|
const data = await listFirewallAll()
|
||||||
|
return reply.send(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/firewall/rules", async (req, reply) => {
|
||||||
|
const parsed = RuleWriteSchema.safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
const body = parsed.data
|
||||||
|
const server = requireServer(body.serverId)
|
||||||
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
const path = firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable)
|
||||||
|
try {
|
||||||
|
await client.put(path, ruleToRos(body))
|
||||||
|
return reply.status(201).send({ ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put("/firewall/rules", async (req, reply) => {
|
||||||
|
const parsed = RuleWriteSchema.extend({ rosId: z.string().min(1) }).safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
const body = parsed.data
|
||||||
|
const server = requireServer(body.serverId)
|
||||||
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
const path = `${firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable)}/${encodeRosId(body.rosId)}`
|
||||||
|
try {
|
||||||
|
await client.patch(path, ruleToRos(body))
|
||||||
|
return reply.send({ ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch("/firewall/rules", async (req, reply) => {
|
||||||
|
const parsed = RulePatchSchema.safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
const body = parsed.data
|
||||||
|
const server = requireServer(body.serverId)
|
||||||
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}`
|
||||||
|
try {
|
||||||
|
await client.patch(path, { disabled: body.disabled ? "yes" : "no" })
|
||||||
|
return reply.send({ ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete("/firewall/rules", async (req, reply) => {
|
||||||
|
const parsed = RuleKeySchema.safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
const body = parsed.data
|
||||||
|
const server = requireServer(body.serverId)
|
||||||
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}`
|
||||||
|
try {
|
||||||
|
await client.delete(path)
|
||||||
|
return reply.send({ ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/firewall/rules/move", async (req, reply) => {
|
||||||
|
const parsed = RuleMoveSchema.safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
const body = parsed.data
|
||||||
|
const server = requireServer(body.serverId)
|
||||||
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
const path = `${firewallRestPath(body.family, body.table)}/move`
|
||||||
|
try {
|
||||||
|
await client.post(path, {
|
||||||
|
numbers: body.rosId,
|
||||||
|
...(body.destinationRosId ? { destination: body.destinationRosId } : {}),
|
||||||
|
})
|
||||||
|
return reply.send({ ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/firewall/address-lists", async (req, reply) => {
|
||||||
|
const parsed = AddressWriteSchema.safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
const body = parsed.data
|
||||||
|
const server = requireServer(body.serverId)
|
||||||
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
try {
|
||||||
|
await client.put(firewallRestPath(body.family, "address-list"), addressToRos(body))
|
||||||
|
return reply.status(201).send({ ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put("/firewall/address-lists", async (req, reply) => {
|
||||||
|
const parsed = AddressWriteSchema.extend({ rosId: z.string().min(1) }).safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
const body = parsed.data
|
||||||
|
const server = requireServer(body.serverId)
|
||||||
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
|
||||||
|
try {
|
||||||
|
await client.patch(path, addressToRos(body))
|
||||||
|
return reply.send({ ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch("/firewall/address-lists", async (req, reply) => {
|
||||||
|
const parsed = AddressPatchSchema.safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
const body = parsed.data
|
||||||
|
const server = requireServer(body.serverId)
|
||||||
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
|
||||||
|
try {
|
||||||
|
await client.patch(path, { disabled: body.disabled ? "yes" : "no" })
|
||||||
|
return reply.send({ ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete("/firewall/address-lists", async (req, reply) => {
|
||||||
|
const parsed = AddressKeySchema.safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
const body = parsed.data
|
||||||
|
const server = requireServer(body.serverId)
|
||||||
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
|
||||||
|
try {
|
||||||
|
await client.delete(path)
|
||||||
|
return reply.send({ ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default firewallRoutes
|
||||||
+215
-95
@@ -1,3 +1,4 @@
|
|||||||
|
import { env } from "../config.js"
|
||||||
import { desc, eq } from "drizzle-orm"
|
import { desc, eq } from "drizzle-orm"
|
||||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
import { db } from "../db/index.js"
|
import { db } from "../db/index.js"
|
||||||
@@ -12,6 +13,15 @@ import {
|
|||||||
import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js"
|
import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js"
|
||||||
import { refreshScheduler } from "../services/scheduler.js"
|
import { refreshScheduler } from "../services/scheduler.js"
|
||||||
import { appendEvent } from "../modules/events/service/events-service.js"
|
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||||
|
import { MikrotikClient } from "../services/mikrotik.js"
|
||||||
|
import { getEnabledServerById } from "../services/wireguard-live.js"
|
||||||
|
import {
|
||||||
|
bpsToMbps,
|
||||||
|
buildTrafficFromSamples,
|
||||||
|
isLoopbackName,
|
||||||
|
parseMonitorTraffic,
|
||||||
|
rateBpsFromDelta,
|
||||||
|
} from "../services/traffic-rate.js"
|
||||||
|
|
||||||
type SnapshotRow = typeof serverSnapshots.$inferSelect
|
type SnapshotRow = typeof serverSnapshots.$inferSelect
|
||||||
|
|
||||||
@@ -40,6 +50,9 @@ interface TrafficInterfaceDto {
|
|||||||
txNow: number
|
txNow: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LIVE_TICK_MS = 1500
|
||||||
|
const LIVE_ROS_TIMEOUT_MS = 4000
|
||||||
|
|
||||||
function latestSnapshot(serverId: number): SnapshotRow | undefined {
|
function latestSnapshot(serverId: number): SnapshotRow | undefined {
|
||||||
return db
|
return db
|
||||||
.select()
|
.select()
|
||||||
@@ -61,14 +74,6 @@ function rangeToMinutes(range: string | undefined): number {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSeries(values: number[], target = 60): number[] {
|
|
||||||
if (values.length === 0) return Array(target).fill(0)
|
|
||||||
if (values.length === target) return values
|
|
||||||
if (values.length > target) return values.slice(values.length - target)
|
|
||||||
const head = Array(target - values.length).fill(values[0] ?? 0)
|
|
||||||
return [...head, ...values]
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildServerTraffic(
|
function buildServerTraffic(
|
||||||
s: typeof servers.$inferSelect,
|
s: typeof servers.$inferSelect,
|
||||||
status: TrafficServerDto["status"],
|
status: TrafficServerDto["status"],
|
||||||
@@ -82,88 +87,135 @@ function buildServerTraffic(
|
|||||||
running: boolean
|
running: boolean
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
}>,
|
}>,
|
||||||
|
rangeStartMs: number,
|
||||||
|
rangeEndMs: number,
|
||||||
onlyInterface?: string,
|
onlyInterface?: string,
|
||||||
): TrafficServerDto {
|
): TrafficServerDto {
|
||||||
const filteredRows = onlyInterface
|
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, onlyInterface)
|
||||||
? rows.filter((r) => r.interfaceName === onlyInterface)
|
|
||||||
: rows
|
|
||||||
|
|
||||||
if (filteredRows.length === 0) {
|
|
||||||
return {
|
|
||||||
id: String(s.id),
|
|
||||||
name: s.name || s.host,
|
|
||||||
site: s.site || "—",
|
|
||||||
country: s.country || "UN",
|
|
||||||
status,
|
|
||||||
rxNow: 0,
|
|
||||||
txNow: 0,
|
|
||||||
rxPeak: 0,
|
|
||||||
txPeak: 0,
|
|
||||||
rxTotal: 0,
|
|
||||||
txTotal: 0,
|
|
||||||
sessions: 0,
|
|
||||||
rxSeries: Array(60).fill(0),
|
|
||||||
txSeries: Array(60).fill(0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bySampleTs = new Map<string, { rx: number; tx: number }>()
|
|
||||||
const byIface = new Map<string, typeof filteredRows>()
|
|
||||||
for (const r of filteredRows) {
|
|
||||||
const ts = r.sampledAt
|
|
||||||
const cur = bySampleTs.get(ts) ?? { rx: 0, tx: 0 }
|
|
||||||
cur.rx += Math.max(0, r.rxBps) / 1_000_000
|
|
||||||
cur.tx += Math.max(0, r.txBps) / 1_000_000
|
|
||||||
bySampleTs.set(ts, cur)
|
|
||||||
const arr = byIface.get(r.interfaceName) ?? []
|
|
||||||
arr.push(r)
|
|
||||||
byIface.set(r.interfaceName, arr)
|
|
||||||
}
|
|
||||||
|
|
||||||
const seriesPoints = [...bySampleTs.entries()]
|
|
||||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
||||||
.map(([, v]) => ({ rx: Math.round(v.rx), tx: Math.round(v.tx) }))
|
|
||||||
const rxSeries = toSeries(seriesPoints.map((p) => p.rx))
|
|
||||||
const txSeries = toSeries(seriesPoints.map((p) => p.tx))
|
|
||||||
const rxNow = rxSeries[rxSeries.length - 1] ?? 0
|
|
||||||
const txNow = txSeries[txSeries.length - 1] ?? 0
|
|
||||||
const rxPeak = rxSeries.reduce((m, v) => Math.max(m, v), 0)
|
|
||||||
const txPeak = txSeries.reduce((m, v) => Math.max(m, v), 0)
|
|
||||||
|
|
||||||
let rxBytesDelta = 0
|
|
||||||
let txBytesDelta = 0
|
|
||||||
let sessions = 0
|
|
||||||
for (const arr of byIface.values()) {
|
|
||||||
const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
|
|
||||||
const first = sorted[0]
|
|
||||||
const last = sorted[sorted.length - 1]
|
|
||||||
if (first && last) {
|
|
||||||
const dRx = last.rxBytes - first.rxBytes
|
|
||||||
const dTx = last.txBytes - first.txBytes
|
|
||||||
rxBytesDelta += dRx >= 0 ? dRx : last.rxBytes
|
|
||||||
txBytesDelta += dTx >= 0 ? dTx : last.txBytes
|
|
||||||
if (last.running && !last.disabled) sessions += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(s.id),
|
id: String(s.id),
|
||||||
name: s.name || s.host,
|
name: s.name || s.host,
|
||||||
site: s.site || "—",
|
site: s.site || "—",
|
||||||
country: s.country || "UN",
|
country: s.country || "UN",
|
||||||
status,
|
status,
|
||||||
rxNow,
|
rxNow: built.rxNow,
|
||||||
txNow,
|
txNow: built.txNow,
|
||||||
rxPeak,
|
rxPeak: built.rxPeak,
|
||||||
txPeak,
|
txPeak: built.txPeak,
|
||||||
rxTotal: Number((rxBytesDelta / (1024 ** 3)).toFixed(1)),
|
rxTotal: built.rxTotalGiB,
|
||||||
txTotal: Number((txBytesDelta / (1024 ** 3)).toFixed(1)),
|
txTotal: built.txTotalGiB,
|
||||||
sessions,
|
sessions: built.sessions,
|
||||||
rxSeries,
|
rxSeries: built.rxSeries,
|
||||||
txSeries,
|
txSeries: built.txSeries,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function snapshotStatus(serverId: number): TrafficServerDto["status"] {
|
||||||
|
const snap = latestSnapshot(serverId)
|
||||||
|
return snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
|
||||||
|
}
|
||||||
|
|
||||||
|
function ifaceNowMbps(
|
||||||
|
prev: { rxBytes: number; txBytes: number; sampledAt: string } | undefined,
|
||||||
|
last: { rxBytes: number; txBytes: number; sampledAt: string; rxBps: number; txBps: number },
|
||||||
|
): { rxNow: number; txNow: number } {
|
||||||
|
if (!prev) {
|
||||||
|
return { rxNow: bpsToMbps(last.rxBps), txNow: bpsToMbps(last.txBps) }
|
||||||
|
}
|
||||||
|
const t0 = Date.parse(prev.sampledAt)
|
||||||
|
const t1 = Date.parse(last.sampledAt)
|
||||||
|
const rxBps = rateBpsFromDelta(prev.rxBytes, last.rxBytes, t0, t1)
|
||||||
|
const txBps = rateBpsFromDelta(prev.txBytes, last.txBytes, t0, t1)
|
||||||
|
return {
|
||||||
|
rxNow: bpsToMbps(rxBps ?? last.rxBps),
|
||||||
|
txNow: bpsToMbps(txBps ?? last.txBps),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSse(raw: NodeJS.WritableStream, event: string, data: unknown) {
|
||||||
|
raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (signal.aborted) {
|
||||||
|
reject(new Error("aborted"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
signal.removeEventListener("abort", onAbort)
|
||||||
|
resolve()
|
||||||
|
}, ms)
|
||||||
|
const onAbort = () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
reject(new Error("aborted"))
|
||||||
|
}
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenMonitor(raw: unknown): unknown[] {
|
||||||
|
if (Array.isArray(raw)) return raw
|
||||||
|
if (raw != null) return [raw]
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listRunningIfaceNames(client: MikrotikClient): Promise<string[]> {
|
||||||
|
const ifaces = await client.get<Array<{ name?: string; running?: string; disabled?: string }>>(
|
||||||
|
"/interface",
|
||||||
|
LIVE_ROS_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
return ifaces
|
||||||
|
.filter((i) => (i.running ?? "false") === "true"
|
||||||
|
&& (i.disabled ?? "false") !== "true"
|
||||||
|
&& !isLoopbackName(i.name ?? ""))
|
||||||
|
.map((i) => i.name ?? "")
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function monitorTrafficOnce(
|
||||||
|
client: MikrotikClient,
|
||||||
|
onlyInterface: string | undefined,
|
||||||
|
cache: { names: string[]; joinedFailed: boolean },
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (onlyInterface) {
|
||||||
|
return client.post(
|
||||||
|
"/interface/monitor-traffic",
|
||||||
|
{ interface: onlyInterface, once: "" },
|
||||||
|
LIVE_ROS_TIMEOUT_MS,
|
||||||
|
signal,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (cache.names.length === 0) {
|
||||||
|
cache.names = await listRunningIfaceNames(client)
|
||||||
|
}
|
||||||
|
if (cache.names.length === 0) return []
|
||||||
|
if (!cache.joinedFailed) {
|
||||||
|
try {
|
||||||
|
return await client.post(
|
||||||
|
"/interface/monitor-traffic",
|
||||||
|
{ interface: cache.names.join(","), once: "" },
|
||||||
|
LIVE_ROS_TIMEOUT_MS,
|
||||||
|
signal,
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
cache.joinedFailed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const chunks = await Promise.all(
|
||||||
|
cache.names.map((name) =>
|
||||||
|
client.post(
|
||||||
|
"/interface/monitor-traffic",
|
||||||
|
{ interface: name, once: "" },
|
||||||
|
LIVE_ROS_TIMEOUT_MS,
|
||||||
|
signal,
|
||||||
|
).then(flattenMonitor).catch(() => [] as unknown[]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return chunks.flat()
|
||||||
|
}
|
||||||
|
|
||||||
const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
app.get("/traffic/settings", async (_req, reply) => {
|
app.get("/traffic/settings", async (_req, reply) => {
|
||||||
const settings = getTrafficSettings()
|
const settings = getTrafficSettings()
|
||||||
@@ -243,20 +295,77 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
app.get("/traffic/servers", async (req, reply) => {
|
app.get("/traffic/servers", async (req, reply) => {
|
||||||
const q = req.query as { range?: string }
|
const q = req.query as { range?: string }
|
||||||
const minutes = rangeToMinutes(q.range)
|
const minutes = rangeToMinutes(q.range)
|
||||||
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
|
const rangeEndMs = Date.now()
|
||||||
|
const rangeStartMs = rangeEndMs - minutes * 60_000
|
||||||
|
const sinceIso = new Date(rangeStartMs).toISOString()
|
||||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||||
const data = allServers.map((s): TrafficServerDto => {
|
const data = allServers.map((s): TrafficServerDto => {
|
||||||
const snap = latestSnapshot(s.id)
|
|
||||||
const status: TrafficServerDto["status"] =
|
|
||||||
snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
|
|
||||||
|
|
||||||
const rows = readServerSamplesInRange(s.id, sinceIso)
|
const rows = readServerSamplesInRange(s.id, sinceIso)
|
||||||
return buildServerTraffic(s, status, rows)
|
return buildServerTraffic(s, snapshotStatus(s.id), rows, rangeStartMs, rangeEndMs)
|
||||||
})
|
})
|
||||||
|
|
||||||
return reply.send({ servers: data })
|
return reply.send({ servers: data })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get("/traffic/servers/:id/live", async (req, reply) => {
|
||||||
|
const p = req.params as { id?: string | number }
|
||||||
|
const q = req.query as { iface?: string }
|
||||||
|
const server = getEnabledServerById(p.id ?? "")
|
||||||
|
if (!server || !server.enabled) return reply.status(404).send({ error: "Server not found" })
|
||||||
|
|
||||||
|
const onlyInterface = q.iface && q.iface !== "__all__" ? q.iface : undefined
|
||||||
|
const abort = new AbortController()
|
||||||
|
const onClose = () => abort.abort()
|
||||||
|
req.raw.on("close", onClose)
|
||||||
|
|
||||||
|
reply.hijack()
|
||||||
|
req.raw.setTimeout(0)
|
||||||
|
reply.raw.setTimeout(0)
|
||||||
|
const origin = typeof req.headers.origin === "string" ? req.headers.origin : ""
|
||||||
|
const allowed = env.CORS_ORIGIN
|
||||||
|
const sseHeaders: Record<string, string> = {
|
||||||
|
"Content-Type": "text/event-stream; charset=utf-8",
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
}
|
||||||
|
if (origin && (allowed === "*" || allowed === origin)) {
|
||||||
|
sseHeaders["Access-Control-Allow-Origin"] = origin
|
||||||
|
sseHeaders["Access-Control-Allow-Credentials"] = "true"
|
||||||
|
sseHeaders["Access-Control-Allow-Headers"] = "Authorization, Accept"
|
||||||
|
sseHeaders.Vary = "Origin"
|
||||||
|
}
|
||||||
|
reply.raw.writeHead(200, sseHeaders)
|
||||||
|
reply.raw.write(":\n\n")
|
||||||
|
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
const cache = { names: onlyInterface ? [onlyInterface] : [] as string[], joinedFailed: false }
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (!abort.signal.aborted) {
|
||||||
|
try {
|
||||||
|
const raw = await monitorTrafficOnce(client, onlyInterface, cache, abort.signal)
|
||||||
|
const sample = parseMonitorTraffic(raw, { onlyInterface })
|
||||||
|
writeSse(reply.raw, "sample", sample)
|
||||||
|
} catch (error) {
|
||||||
|
if (abort.signal.aborted) break
|
||||||
|
const msg = error instanceof Error ? error.message : String(error)
|
||||||
|
writeSse(reply.raw, "error", { error: msg })
|
||||||
|
}
|
||||||
|
await sleep(LIVE_TICK_MS, abort.signal)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* abort / disconnect */
|
||||||
|
} finally {
|
||||||
|
req.raw.off("close", onClose)
|
||||||
|
try {
|
||||||
|
reply.raw.end()
|
||||||
|
} catch {
|
||||||
|
/* already closed */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.get("/traffic/servers/:id/interfaces", async (req, reply) => {
|
app.get("/traffic/servers/:id/interfaces", async (req, reply) => {
|
||||||
const p = req.params as { id?: string | number }
|
const p = req.params as { id?: string | number }
|
||||||
const serverId = Number.parseInt(String(p.id ?? ""), 10)
|
const serverId = Number.parseInt(String(p.id ?? ""), 10)
|
||||||
@@ -270,6 +379,7 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
const rows = readServerSamplesInRange(serverId, sinceIso)
|
const rows = readServerSamplesInRange(serverId, sinceIso)
|
||||||
const byIface = new Map<string, typeof rows>()
|
const byIface = new Map<string, typeof rows>()
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
|
if (isLoopbackName(r.interfaceName)) continue
|
||||||
const arr = byIface.get(r.interfaceName) ?? []
|
const arr = byIface.get(r.interfaceName) ?? []
|
||||||
arr.push(r)
|
arr.push(r)
|
||||||
byIface.set(r.interfaceName, arr)
|
byIface.set(r.interfaceName, arr)
|
||||||
@@ -277,12 +387,17 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
const interfaces: TrafficInterfaceDto[] = [...byIface.entries()].map(([name, arr]) => {
|
const interfaces: TrafficInterfaceDto[] = [...byIface.entries()].map(([name, arr]) => {
|
||||||
const sorted = arr.sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
|
const sorted = arr.sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
|
||||||
const last = sorted[sorted.length - 1]
|
const last = sorted[sorted.length - 1]
|
||||||
|
const prev = sorted[sorted.length - 2]
|
||||||
|
if (!last) {
|
||||||
|
return { name, running: false, disabled: true, rxNow: 0, txNow: 0 }
|
||||||
|
}
|
||||||
|
const now = ifaceNowMbps(prev, last)
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
running: Boolean(last?.running),
|
running: Boolean(last.running),
|
||||||
disabled: Boolean(last?.disabled),
|
disabled: Boolean(last.disabled),
|
||||||
rxNow: Math.round((last?.rxBps ?? 0) / 1_000_000),
|
rxNow: now.rxNow,
|
||||||
txNow: Math.round((last?.txBps ?? 0) / 1_000_000),
|
txNow: now.txNow,
|
||||||
}
|
}
|
||||||
}).sort((a, b) => (b.rxNow + b.txNow) - (a.rxNow + a.txNow))
|
}).sort((a, b) => (b.rxNow + b.txNow) - (a.rxNow + a.txNow))
|
||||||
|
|
||||||
@@ -298,15 +413,20 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||||
|
|
||||||
const minutes = rangeToMinutes(q.range)
|
const minutes = rangeToMinutes(q.range)
|
||||||
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
|
const rangeEndMs = Date.now()
|
||||||
|
const rangeStartMs = rangeEndMs - minutes * 60_000
|
||||||
|
const sinceIso = new Date(rangeStartMs).toISOString()
|
||||||
const rows = readServerSamplesInRange(server.id, sinceIso)
|
const rows = readServerSamplesInRange(server.id, sinceIso)
|
||||||
const snap = latestSnapshot(server.id)
|
const data = buildServerTraffic(
|
||||||
const status: TrafficServerDto["status"] =
|
server,
|
||||||
snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
|
snapshotStatus(server.id),
|
||||||
const data = buildServerTraffic(server, status, rows, q.iface && q.iface !== "__all__" ? q.iface : undefined)
|
rows,
|
||||||
|
rangeStartMs,
|
||||||
|
rangeEndMs,
|
||||||
|
q.iface && q.iface !== "__all__" ? q.iface : undefined,
|
||||||
|
)
|
||||||
return reply.send({ server: data })
|
return reply.send({ server: data })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export default trafficRoutes
|
export default trafficRoutes
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { db } from "../db/index.js"
|
||||||
|
import { servers } from "../db/schema.js"
|
||||||
|
import {
|
||||||
|
MikrotikClient,
|
||||||
|
firewallRestPath,
|
||||||
|
} from "./mikrotik.js"
|
||||||
|
import type {
|
||||||
|
FirewallFamily,
|
||||||
|
FirewallTable,
|
||||||
|
RosFirewallAddressList,
|
||||||
|
RosFirewallFilter,
|
||||||
|
} from "../types/server.js"
|
||||||
|
|
||||||
|
type ServerRow = typeof servers.$inferSelect
|
||||||
|
|
||||||
|
export interface FirewallRuleDto {
|
||||||
|
id: string
|
||||||
|
rosId: string
|
||||||
|
serverId: string
|
||||||
|
serverName: string
|
||||||
|
family: FirewallFamily
|
||||||
|
table: FirewallTable
|
||||||
|
chain: string
|
||||||
|
action: string
|
||||||
|
proto: string
|
||||||
|
src: string
|
||||||
|
dst: string
|
||||||
|
port: string
|
||||||
|
iface: string
|
||||||
|
comment: string
|
||||||
|
enabled: boolean
|
||||||
|
hits: number
|
||||||
|
log: boolean
|
||||||
|
logPrefix: string
|
||||||
|
tlsHost?: string
|
||||||
|
layer7Proto?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirewallAddressListDto {
|
||||||
|
id: string
|
||||||
|
rosId: string
|
||||||
|
serverId: string
|
||||||
|
serverName: string
|
||||||
|
family: FirewallFamily
|
||||||
|
list: string
|
||||||
|
address: string
|
||||||
|
comment: string
|
||||||
|
disabled: boolean
|
||||||
|
timeout?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABLES: FirewallTable[] = ["filter", "nat", "mangle", "raw"]
|
||||||
|
const FAMILIES: FirewallFamily[] = ["ip", "ip6"]
|
||||||
|
|
||||||
|
function dash(v: string | undefined): string {
|
||||||
|
const s = v?.trim() ?? ""
|
||||||
|
return s.length > 0 ? s : "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
function rosDisabled(v: string | undefined): boolean {
|
||||||
|
return v === "true" || v === "yes"
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHits(raw: RosFirewallFilter): number {
|
||||||
|
const n = Number.parseInt(raw.packets ?? "0", 10)
|
||||||
|
return Number.isFinite(n) ? n : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ruleUiId(
|
||||||
|
serverId: string | number,
|
||||||
|
family: FirewallFamily,
|
||||||
|
table: FirewallTable,
|
||||||
|
rosId: string,
|
||||||
|
): string {
|
||||||
|
return `${serverId}:${family}:${table}:${rosId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addressUiId(
|
||||||
|
serverId: string | number,
|
||||||
|
family: FirewallFamily,
|
||||||
|
rosId: string,
|
||||||
|
): string {
|
||||||
|
return `${serverId}:${family}:address-list:${rosId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapFirewallRule(
|
||||||
|
server: ServerRow,
|
||||||
|
family: FirewallFamily,
|
||||||
|
table: FirewallTable,
|
||||||
|
raw: RosFirewallFilter,
|
||||||
|
idx: number,
|
||||||
|
): FirewallRuleDto {
|
||||||
|
const rosId = raw[".id"] || `*${idx}`
|
||||||
|
const src = raw["src-address"] || raw["src-address-list"]
|
||||||
|
const dst = raw["dst-address"] || raw["dst-address-list"]
|
||||||
|
const port = raw["dst-port"] || raw["src-port"]
|
||||||
|
const iface = raw["in-interface"] || raw["out-interface"]
|
||||||
|
return {
|
||||||
|
id: ruleUiId(server.id, family, table, rosId),
|
||||||
|
rosId,
|
||||||
|
serverId: String(server.id),
|
||||||
|
serverName: server.name || server.host,
|
||||||
|
family,
|
||||||
|
table,
|
||||||
|
chain: raw.chain || "",
|
||||||
|
action: raw.action || "",
|
||||||
|
proto: raw.protocol || "all",
|
||||||
|
src: dash(src),
|
||||||
|
dst: dash(dst),
|
||||||
|
port: dash(port),
|
||||||
|
iface: dash(iface),
|
||||||
|
comment: raw.comment ?? "",
|
||||||
|
enabled: !rosDisabled(raw.disabled),
|
||||||
|
hits: parseHits(raw),
|
||||||
|
log: raw.log === "true" || raw.log === "yes",
|
||||||
|
logPrefix: raw["log-prefix"] ?? "",
|
||||||
|
tlsHost: raw["tls-host"],
|
||||||
|
layer7Proto: raw["layer7-protocol"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapAddressList(
|
||||||
|
server: ServerRow,
|
||||||
|
family: FirewallFamily,
|
||||||
|
raw: RosFirewallAddressList,
|
||||||
|
idx: number,
|
||||||
|
): FirewallAddressListDto {
|
||||||
|
const rosId = raw[".id"] || `*${idx}`
|
||||||
|
return {
|
||||||
|
id: addressUiId(server.id, family, rosId),
|
||||||
|
rosId,
|
||||||
|
serverId: String(server.id),
|
||||||
|
serverName: server.name || server.host,
|
||||||
|
family,
|
||||||
|
list: raw.list || "",
|
||||||
|
address: raw.address || "",
|
||||||
|
comment: raw.comment ?? "",
|
||||||
|
disabled: rosDisabled(raw.disabled),
|
||||||
|
timeout: raw.timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeGet<T>(fn: () => Promise<T[]>, fallback: T[] = []): Promise<T[]> {
|
||||||
|
try {
|
||||||
|
const rows = await fn()
|
||||||
|
return Array.isArray(rows) ? rows : fallback
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchServerFirewall(server: ServerRow): Promise<{
|
||||||
|
rules: FirewallRuleDto[]
|
||||||
|
addressLists: FirewallAddressListDto[]
|
||||||
|
}> {
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
const ruleJobs = FAMILIES.flatMap((family) =>
|
||||||
|
TABLES.map(async (table) => {
|
||||||
|
const raw = await safeGet(() => client.getFirewallRules(family, table))
|
||||||
|
return raw.map((row, idx) => mapFirewallRule(server, family, table, row, idx))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const listJobs = FAMILIES.map(async (family) => {
|
||||||
|
const raw = await safeGet(() => client.getFirewallAddressList(family))
|
||||||
|
return raw.map((row, idx) => mapAddressList(server, family, row, idx))
|
||||||
|
})
|
||||||
|
const [ruleChunks, listChunks] = await Promise.all([
|
||||||
|
Promise.all(ruleJobs),
|
||||||
|
Promise.all(listJobs),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
rules: ruleChunks.flat(),
|
||||||
|
addressLists: listChunks.flat(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFirewallAll(): Promise<{
|
||||||
|
rules: FirewallRuleDto[]
|
||||||
|
addressLists: FirewallAddressListDto[]
|
||||||
|
}> {
|
||||||
|
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||||
|
const perServer = await Promise.all(
|
||||||
|
allServers.map(async (server) => {
|
||||||
|
try {
|
||||||
|
return await fetchServerFirewall(server)
|
||||||
|
} catch {
|
||||||
|
return { rules: [] as FirewallRuleDto[], addressLists: [] as FirewallAddressListDto[] }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
rules: perServer.flatMap((r) => r.rules),
|
||||||
|
addressLists: perServer.flatMap((r) => r.addressLists),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { firewallRestPath, FAMILIES, TABLES }
|
||||||
@@ -7,7 +7,8 @@ import type {
|
|||||||
RosBgpSession,
|
RosBgpSession,
|
||||||
RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance,
|
RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance,
|
||||||
RosBfdSession,
|
RosBfdSession,
|
||||||
RosIpRoute, RosFirewallFilter, RosLogEntry, RosPingResult,
|
RosIpRoute, RosFirewallFilter, RosFirewallAddressList, RosLogEntry, RosPingResult,
|
||||||
|
FirewallFamily, FirewallTable,
|
||||||
} from "../types/server.js"
|
} from "../types/server.js"
|
||||||
|
|
||||||
// ── connection params ─────────────────────────────────────────────────────────
|
// ── connection params ─────────────────────────────────────────────────────────
|
||||||
@@ -338,6 +339,19 @@ function matchesUploadedFile(entryName: string, requested: string): boolean {
|
|||||||
|| entryName.endsWith(`/${base}`)
|
|| entryName.endsWith(`/${base}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function firewallRestPath(
|
||||||
|
family: FirewallFamily,
|
||||||
|
table: FirewallTable | "address-list",
|
||||||
|
): string {
|
||||||
|
const root = family === "ip6" ? "/ipv6/firewall" : "/ip/firewall"
|
||||||
|
return `${root}/${table}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeRosId(rosId: string): string {
|
||||||
|
const id = rosId.startsWith("*") ? rosId : `*${rosId.replace(/^\*/, "")}`
|
||||||
|
return encodeURIComponent(id)
|
||||||
|
}
|
||||||
|
|
||||||
// ── MikrotikClient ─────────────────────────────────────────────────────────────
|
// ── MikrotikClient ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class MikrotikClient {
|
export class MikrotikClient {
|
||||||
@@ -461,7 +475,15 @@ export class MikrotikClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getFirewallFilters(): Promise<RosFirewallFilter[]> {
|
async getFirewallFilters(): Promise<RosFirewallFilter[]> {
|
||||||
return this.get<RosFirewallFilter[]>("/ip/firewall/filter")
|
return this.getFirewallRules("ip", "filter")
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFirewallRules(family: FirewallFamily, table: FirewallTable): Promise<RosFirewallFilter[]> {
|
||||||
|
return this.get<RosFirewallFilter[]>(firewallRestPath(family, table))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFirewallAddressList(family: FirewallFamily): Promise<RosFirewallAddressList[]> {
|
||||||
|
return this.get<RosFirewallAddressList[]>(firewallRestPath(family, "address-list"))
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLogs(limit = 50): Promise<RosLogEntry[]> {
|
async getLogs(limit = 50): Promise<RosLogEntry[]> {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { and, asc, eq, gte, lt } from "drizzle-orm"
|
import { and, asc, desc, eq, gte, lt } from "drizzle-orm"
|
||||||
import { db } from "../db/index.js"
|
import { db } from "../db/index.js"
|
||||||
import { servers, trafficSamples, trafficSettings } from "../db/schema.js"
|
import { servers, trafficSamples, trafficSettings } from "../db/schema.js"
|
||||||
import type { TrafficRunSnapshot } from "../types/scheduler-run-snapshot.js"
|
import type { TrafficRunSnapshot } from "../types/scheduler-run-snapshot.js"
|
||||||
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
|
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
|
||||||
import { MikrotikClient } from "./mikrotik.js"
|
import { MikrotikClient } from "./mikrotik.js"
|
||||||
|
import { bpsToMbps, rateBpsFromDelta, shouldIncludeIface } from "./traffic-rate.js"
|
||||||
|
|
||||||
interface RosIfaceTraffic {
|
interface RosIfaceTraffic {
|
||||||
name?: string
|
name?: string
|
||||||
@@ -50,6 +51,31 @@ function cleanupOldSamples(retentionDays: number) {
|
|||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readPreviousWave(serverId: number): Map<string, { rxBytes: number; txBytes: number; sampledAt: string }> {
|
||||||
|
const last = db
|
||||||
|
.select({ sampledAt: trafficSamples.sampledAt })
|
||||||
|
.from(trafficSamples)
|
||||||
|
.where(eq(trafficSamples.serverId, serverId))
|
||||||
|
.orderBy(desc(trafficSamples.sampledAt))
|
||||||
|
.limit(1)
|
||||||
|
.all()[0]
|
||||||
|
if (!last) return new Map()
|
||||||
|
const rows = db
|
||||||
|
.select({
|
||||||
|
interfaceName: trafficSamples.interfaceName,
|
||||||
|
rxBytes: trafficSamples.rxBytes,
|
||||||
|
txBytes: trafficSamples.txBytes,
|
||||||
|
sampledAt: trafficSamples.sampledAt,
|
||||||
|
})
|
||||||
|
.from(trafficSamples)
|
||||||
|
.where(and(
|
||||||
|
eq(trafficSamples.serverId, serverId),
|
||||||
|
eq(trafficSamples.sampledAt, last.sampledAt),
|
||||||
|
))
|
||||||
|
.all()
|
||||||
|
return new Map(rows.map((r) => [r.interfaceName, r]))
|
||||||
|
}
|
||||||
|
|
||||||
export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
||||||
const sampledAt = new Date().toISOString()
|
const sampledAt = new Date().toISOString()
|
||||||
if (collecting) {
|
if (collecting) {
|
||||||
@@ -80,26 +106,42 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
|||||||
try {
|
try {
|
||||||
const client = MikrotikClient.fromServer(srv)
|
const client = MikrotikClient.fromServer(srv)
|
||||||
const ifaces = await client.get<RosIfaceTraffic[]>("/interface")
|
const ifaces = await client.get<RosIfaceTraffic[]>("/interface")
|
||||||
let sumRx = 0
|
const prevWave = readPreviousWave(srv.id)
|
||||||
let sumTx = 0
|
const nowMs = Date.parse(now)
|
||||||
for (const i of ifaces) {
|
let sumRxMbps = 0
|
||||||
sumRx += toNum(i["rx-bits-per-second"]) / 1_000_000
|
let sumTxMbps = 0
|
||||||
sumTx += toNum(i["tx-bits-per-second"]) / 1_000_000
|
const rows = ifaces.map((i) => {
|
||||||
}
|
const interfaceName = i.name ?? "unknown"
|
||||||
if (ifaces.length > 0) {
|
const rxBytes = toNum(i["rx-byte"])
|
||||||
db.insert(trafficSamples).values(
|
const txBytes = toNum(i["tx-byte"])
|
||||||
ifaces.map((i) => ({
|
const running = (i.running ?? "false") === "true"
|
||||||
serverId: srv.id,
|
const disabled = (i.disabled ?? "false") === "true"
|
||||||
interfaceName: i.name ?? "unknown",
|
const prev = prevWave.get(interfaceName)
|
||||||
sampledAt: now,
|
const prevMs = prev ? Date.parse(prev.sampledAt) : NaN
|
||||||
rxBytes: toNum(i["rx-byte"]),
|
const rxBps = prev && Number.isFinite(prevMs)
|
||||||
txBytes: toNum(i["tx-byte"]),
|
? (rateBpsFromDelta(prev.rxBytes, rxBytes, prevMs, nowMs) ?? 0)
|
||||||
rxBps: toNum(i["rx-bits-per-second"]),
|
: 0
|
||||||
txBps: toNum(i["tx-bits-per-second"]),
|
const txBps = prev && Number.isFinite(prevMs)
|
||||||
running: (i.running ?? "false") === "true",
|
? (rateBpsFromDelta(prev.txBytes, txBytes, prevMs, nowMs) ?? 0)
|
||||||
disabled: (i.disabled ?? "false") === "true",
|
: 0
|
||||||
})),
|
if (shouldIncludeIface(interfaceName, running, disabled)) {
|
||||||
).run()
|
sumRxMbps += bpsToMbps(rxBps)
|
||||||
|
sumTxMbps += bpsToMbps(txBps)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
serverId: srv.id,
|
||||||
|
interfaceName,
|
||||||
|
sampledAt: now,
|
||||||
|
rxBytes,
|
||||||
|
txBytes,
|
||||||
|
rxBps,
|
||||||
|
txBps,
|
||||||
|
running,
|
||||||
|
disabled,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (rows.length > 0) {
|
||||||
|
db.insert(trafficSamples).values(rows).run()
|
||||||
}
|
}
|
||||||
snapshot.servers.push({
|
snapshot.servers.push({
|
||||||
serverId: srv.id,
|
serverId: srv.id,
|
||||||
@@ -107,8 +149,8 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
|||||||
host: srv.host,
|
host: srv.host,
|
||||||
ok: true,
|
ok: true,
|
||||||
interfaces: ifaces.length,
|
interfaces: ifaces.length,
|
||||||
sumRxMbps: Math.round(sumRx),
|
sumRxMbps: Math.round(sumRxMbps * 1000) / 1000,
|
||||||
sumTxMbps: Math.round(sumTx),
|
sumTxMbps: Math.round(sumTxMbps * 1000) / 1000,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
snapshot.servers.push({
|
snapshot.servers.push({
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {
|
||||||
|
bpsToMbps,
|
||||||
|
bucketAvg,
|
||||||
|
buildTrafficFromSamples,
|
||||||
|
isLoopbackName,
|
||||||
|
parseMonitorTraffic,
|
||||||
|
rateBpsFromDelta,
|
||||||
|
shouldIncludeIface,
|
||||||
|
type TrafficSampleLike,
|
||||||
|
} from "./traffic-rate.js"
|
||||||
|
|
||||||
|
assert.equal(isLoopbackName("lo"), true)
|
||||||
|
assert.equal(isLoopbackName("loopback"), true)
|
||||||
|
assert.equal(isLoopbackName("ether1"), false)
|
||||||
|
assert.equal(shouldIncludeIface("lo", true, false), false)
|
||||||
|
assert.equal(shouldIncludeIface("ether1", true, false), true)
|
||||||
|
assert.equal(shouldIncludeIface("ether1", false, false), false)
|
||||||
|
assert.equal(shouldIncludeIface("ether1", true, true), false)
|
||||||
|
assert.equal(shouldIncludeIface("lo", true, false, "lo"), true)
|
||||||
|
|
||||||
|
assert.equal(rateBpsFromDelta(1000, 2000, 0, 1000), 8000)
|
||||||
|
assert.equal(rateBpsFromDelta(1000, 500, 0, 1000), null)
|
||||||
|
assert.equal(rateBpsFromDelta(1000, 2000, 1000, 1000), null)
|
||||||
|
assert.equal(bpsToMbps(1_500_000), 1.5)
|
||||||
|
assert.equal(bpsToMbps(400_000), 0.4)
|
||||||
|
|
||||||
|
const buckets = bucketAvg(
|
||||||
|
[
|
||||||
|
{ t: 0, v: 10 },
|
||||||
|
{ t: 1000, v: 20 },
|
||||||
|
],
|
||||||
|
0,
|
||||||
|
1000,
|
||||||
|
4,
|
||||||
|
)
|
||||||
|
assert.equal(buckets.length, 4)
|
||||||
|
assert.equal(buckets[0], 10)
|
||||||
|
assert.equal(buckets[3], 20)
|
||||||
|
assert.equal(buckets[1], 0)
|
||||||
|
assert.equal(buckets[2], 0)
|
||||||
|
|
||||||
|
const t0 = "2026-09-06T10:00:00.000Z"
|
||||||
|
const t1 = "2026-09-06T10:00:30.000Z"
|
||||||
|
const t2 = "2026-09-06T10:01:00.000Z"
|
||||||
|
const start = Date.parse(t0)
|
||||||
|
const end = Date.parse(t2)
|
||||||
|
|
||||||
|
const samples: TrafficSampleLike[] = [
|
||||||
|
{ interfaceName: "ether1", sampledAt: t0, rxBytes: 1_000_000, txBytes: 500_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
{ interfaceName: "ether1", sampledAt: t1, rxBytes: 1_000_000 + 3_750_000, txBytes: 500_000 + 1_875_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
{ interfaceName: "ether1", sampledAt: t2, rxBytes: 100, txBytes: 50, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
{ interfaceName: "lo", sampledAt: t0, rxBytes: 0, txBytes: 0, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
{ interfaceName: "lo", sampledAt: t1, rxBytes: 9_000_000, txBytes: 9_000_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
const built = buildTrafficFromSamples(samples, start, end)
|
||||||
|
assert.ok(built.rxPeak > 0, "peak RX from delta")
|
||||||
|
assert.equal(built.rxNow, 1, "last valid delta after reset skip")
|
||||||
|
assert.ok(built.rxSeries.some((v) => v > 0), "bucket series not flat")
|
||||||
|
assert.ok(built.rxPeak <= 1.1, "lo excluded from peak")
|
||||||
|
|
||||||
|
const live = parseMonitorTraffic([
|
||||||
|
{ name: "ether1", "rx-bits-per-second": "2000000", "tx-bits-per-second": "500000" },
|
||||||
|
{ name: "lo", "rx-bits-per-second": "8000000", "tx-bits-per-second": "8000000" },
|
||||||
|
])
|
||||||
|
assert.equal(live.rxMbps, 2)
|
||||||
|
assert.equal(live.txMbps, 0.5)
|
||||||
|
|
||||||
|
const onceOnly = parseMonitorTraffic(
|
||||||
|
{ name: "ether1", "rx-bits-per-second": "1000000", "tx-bits-per-second": "0" },
|
||||||
|
{ onlyInterface: "ether1" },
|
||||||
|
)
|
||||||
|
assert.equal(onceOnly.rxMbps, 1)
|
||||||
|
|
||||||
|
console.log("traffic-rate tests ok")
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
/** Чистые формулы трафика: дельты счётчиков, корзины series, monitor-traffic. */
|
||||||
|
|
||||||
|
export const SERIES_POINTS = 60
|
||||||
|
|
||||||
|
export interface TrafficSampleLike {
|
||||||
|
interfaceName: string
|
||||||
|
sampledAt: string
|
||||||
|
rxBytes: number
|
||||||
|
txBytes: number
|
||||||
|
rxBps: number
|
||||||
|
txBps: number
|
||||||
|
running: boolean
|
||||||
|
disabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RatePoint {
|
||||||
|
t: number
|
||||||
|
rxMbps: number
|
||||||
|
txMbps: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuiltTrafficSeries {
|
||||||
|
rxNow: number
|
||||||
|
txNow: number
|
||||||
|
rxPeak: number
|
||||||
|
txPeak: number
|
||||||
|
rxTotalGiB: number
|
||||||
|
txTotalGiB: number
|
||||||
|
sessions: number
|
||||||
|
rxSeries: number[]
|
||||||
|
txSeries: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitorLiveSample {
|
||||||
|
rxMbps: number
|
||||||
|
txMbps: number
|
||||||
|
at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLoopbackName(name: string): boolean {
|
||||||
|
return /^(lo|loopback)(\d+)?$/i.test(name.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldIncludeIface(
|
||||||
|
name: string,
|
||||||
|
running: boolean,
|
||||||
|
disabled: boolean,
|
||||||
|
onlyInterface?: string,
|
||||||
|
): boolean {
|
||||||
|
if (onlyInterface) return name === onlyInterface
|
||||||
|
if (isLoopbackName(name)) return false
|
||||||
|
return running && !disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bpsToMbps(bps: number): number {
|
||||||
|
if (!Number.isFinite(bps) || bps <= 0) return 0
|
||||||
|
return Math.round((bps / 1_000_000) * 1000) / 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
/** bits/s из соседних счётчиков. null = нельзя (Δt≤0 или сброс). */
|
||||||
|
export function rateBpsFromDelta(
|
||||||
|
prevBytes: number,
|
||||||
|
nextBytes: number,
|
||||||
|
prevAtMs: number,
|
||||||
|
nextAtMs: number,
|
||||||
|
): number | null {
|
||||||
|
const dtSec = (nextAtMs - prevAtMs) / 1000
|
||||||
|
if (!(dtSec > 0) || !Number.isFinite(dtSec)) return null
|
||||||
|
if (nextBytes < prevBytes) return null
|
||||||
|
return Math.round(((nextBytes - prevBytes) * 8) / dtSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bucketAvg(
|
||||||
|
points: Array<{ t: number; v: number }>,
|
||||||
|
rangeStartMs: number,
|
||||||
|
rangeEndMs: number,
|
||||||
|
target = SERIES_POINTS,
|
||||||
|
): number[] {
|
||||||
|
const buckets = Array.from({ length: target }, () => 0)
|
||||||
|
const counts = Array.from({ length: target }, () => 0)
|
||||||
|
const span = rangeEndMs - rangeStartMs
|
||||||
|
if (span <= 0 || points.length === 0) return buckets
|
||||||
|
for (const p of points) {
|
||||||
|
const ratio = (p.t - rangeStartMs) / span
|
||||||
|
const i = Math.min(target - 1, Math.max(0, Math.floor(ratio * target)))
|
||||||
|
buckets[i] += p.v
|
||||||
|
counts[i] += 1
|
||||||
|
}
|
||||||
|
return buckets.map((sum, i) => (counts[i] > 0 ? sum / counts[i] : 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIsoMs(iso: string): number {
|
||||||
|
const t = Date.parse(iso)
|
||||||
|
return Number.isFinite(t) ? t : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTrafficFromSamples(
|
||||||
|
rows: TrafficSampleLike[],
|
||||||
|
rangeStartMs: number,
|
||||||
|
rangeEndMs: number,
|
||||||
|
onlyInterface?: string,
|
||||||
|
): BuiltTrafficSeries {
|
||||||
|
const empty: BuiltTrafficSeries = {
|
||||||
|
rxNow: 0,
|
||||||
|
txNow: 0,
|
||||||
|
rxPeak: 0,
|
||||||
|
txPeak: 0,
|
||||||
|
rxTotalGiB: 0,
|
||||||
|
txTotalGiB: 0,
|
||||||
|
sessions: 0,
|
||||||
|
rxSeries: Array.from({ length: SERIES_POINTS }, () => 0),
|
||||||
|
txSeries: Array.from({ length: SERIES_POINTS }, () => 0),
|
||||||
|
}
|
||||||
|
if (rows.length === 0) return empty
|
||||||
|
|
||||||
|
const byIface = new Map<string, TrafficSampleLike[]>()
|
||||||
|
for (const r of rows) {
|
||||||
|
const arr = byIface.get(r.interfaceName) ?? []
|
||||||
|
arr.push(r)
|
||||||
|
byIface.set(r.interfaceName, arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rxPoints: Array<{ t: number; v: number }> = []
|
||||||
|
const txPoints: Array<{ t: number; v: number }> = []
|
||||||
|
const byTs = new Map<number, { rx: number; tx: number }>()
|
||||||
|
|
||||||
|
let rxBytesDelta = 0
|
||||||
|
let txBytesDelta = 0
|
||||||
|
let sessions = 0
|
||||||
|
|
||||||
|
for (const [name, arr] of byIface) {
|
||||||
|
if (onlyInterface) {
|
||||||
|
if (name !== onlyInterface) continue
|
||||||
|
} else if (isLoopbackName(name)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
|
||||||
|
const last = sorted[sorted.length - 1]
|
||||||
|
if (!last) continue
|
||||||
|
if (!onlyInterface && (!last.running || last.disabled)) continue
|
||||||
|
|
||||||
|
if (last.running && !last.disabled) sessions += 1
|
||||||
|
|
||||||
|
const first = sorted[0]
|
||||||
|
if (first) {
|
||||||
|
const dRx = last.rxBytes - first.rxBytes
|
||||||
|
const dTx = last.txBytes - first.txBytes
|
||||||
|
rxBytesDelta += dRx >= 0 ? dRx : last.rxBytes
|
||||||
|
txBytesDelta += dTx >= 0 ? dTx : last.txBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i < sorted.length; i++) {
|
||||||
|
const prev = sorted[i - 1]
|
||||||
|
const cur = sorted[i]
|
||||||
|
if (!prev || !cur) continue
|
||||||
|
if (!onlyInterface && (!cur.running || cur.disabled)) continue
|
||||||
|
const t0 = parseIsoMs(prev.sampledAt)
|
||||||
|
const t1 = parseIsoMs(cur.sampledAt)
|
||||||
|
const rxBps = rateBpsFromDelta(prev.rxBytes, cur.rxBytes, t0, t1)
|
||||||
|
const txBps = rateBpsFromDelta(prev.txBytes, cur.txBytes, t0, t1)
|
||||||
|
if (rxBps == null && txBps == null) continue
|
||||||
|
const rxMbps = bpsToMbps(rxBps ?? 0)
|
||||||
|
const txMbps = bpsToMbps(txBps ?? 0)
|
||||||
|
const acc = byTs.get(t1) ?? { rx: 0, tx: 0 }
|
||||||
|
acc.rx += rxMbps
|
||||||
|
acc.tx += txMbps
|
||||||
|
byTs.set(t1, acc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [t, v] of byTs) {
|
||||||
|
rxPoints.push({ t, v: v.rx })
|
||||||
|
txPoints.push({ t, v: v.tx })
|
||||||
|
}
|
||||||
|
|
||||||
|
const rxSeries = bucketAvg(rxPoints, rangeStartMs, rangeEndMs)
|
||||||
|
const txSeries = bucketAvg(txPoints, rangeStartMs, rangeEndMs)
|
||||||
|
const lastTs = [...byTs.keys()].sort((a, b) => a - b).at(-1)
|
||||||
|
const last = lastTs != null ? byTs.get(lastTs) : undefined
|
||||||
|
const rxPeak = rxPoints.reduce((m, p) => Math.max(m, p.v), 0)
|
||||||
|
const txPeak = txPoints.reduce((m, p) => Math.max(m, p.v), 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
rxNow: last?.rx ?? 0,
|
||||||
|
txNow: last?.tx ?? 0,
|
||||||
|
rxPeak,
|
||||||
|
txPeak,
|
||||||
|
rxTotalGiB: Number((rxBytesDelta / (1024 ** 3)).toFixed(1)),
|
||||||
|
txTotalGiB: Number((txBytesDelta / (1024 ** 3)).toFixed(1)),
|
||||||
|
sessions,
|
||||||
|
rxSeries,
|
||||||
|
txSeries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMonitorTraffic(
|
||||||
|
raw: unknown,
|
||||||
|
opts?: { onlyInterface?: string },
|
||||||
|
): MonitorLiveSample {
|
||||||
|
const items = Array.isArray(raw) ? raw : raw != null ? [raw] : []
|
||||||
|
let rxBps = 0
|
||||||
|
let txBps = 0
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item || typeof item !== "object") continue
|
||||||
|
const rec = item as Record<string, unknown>
|
||||||
|
const name = String(rec.name ?? rec.interface ?? "")
|
||||||
|
if (opts?.onlyInterface) {
|
||||||
|
if (name && name !== opts.onlyInterface) continue
|
||||||
|
} else if (isLoopbackName(name)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rxBps += Number.parseFloat(String(rec["rx-bits-per-second"] ?? 0)) || 0
|
||||||
|
txBps += Number.parseFloat(String(rec["tx-bits-per-second"] ?? 0)) || 0
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
rxMbps: bpsToMbps(rxBps),
|
||||||
|
txMbps: bpsToMbps(txBps),
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -345,8 +345,24 @@ export interface RosFirewallFilter {
|
|||||||
"dynamic"?: string
|
"dynamic"?: string
|
||||||
"bytes"?: string
|
"bytes"?: string
|
||||||
"packets"?: string
|
"packets"?: string
|
||||||
|
"log"?: string
|
||||||
|
"log-prefix"?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RosFirewallAddressList {
|
||||||
|
".id": string
|
||||||
|
list: string
|
||||||
|
address: string
|
||||||
|
comment?: string
|
||||||
|
disabled?: string
|
||||||
|
timeout?: string
|
||||||
|
dynamic?: string
|
||||||
|
"creation-time"?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FirewallFamily = "ip" | "ip6"
|
||||||
|
export type FirewallTable = "filter" | "nat" | "mangle" | "raw"
|
||||||
|
|
||||||
export interface RosLogEntry {
|
export interface RosLogEntry {
|
||||||
".id": string
|
".id": string
|
||||||
"time": string
|
"time": string
|
||||||
|
|||||||
@@ -23,8 +23,11 @@ import {
|
|||||||
DATA_GRID_CELL_PAD,
|
DATA_GRID_CELL_PAD,
|
||||||
DATA_GRID_CELL_PAD_FIRST,
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
DATA_GRID_CELL_PAD_LAST,
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
DATA_GRID_CONTAINER_CLASS,
|
||||||
} from "@/components/data-grids/shared/data-grid-layout"
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||||
|
import { DataGridContainer, DataGridTableDndRowHandle, DataGridTableDndRows } from "@/components/reui/data-grid"
|
||||||
|
import type { DragEndEvent } from "@dnd-kit/core"
|
||||||
import { EmptyState } from "@/components/empty-state"
|
import { EmptyState } from "@/components/empty-state"
|
||||||
import {
|
import {
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
@@ -89,16 +92,41 @@ interface FirewallRulesDataGridProps {
|
|||||||
rules: FirewallRule[]
|
rules: FirewallRule[]
|
||||||
onToggle: (id: string) => void
|
onToggle: (id: string) => void
|
||||||
onEdit: (rule: FirewallRule) => void
|
onEdit: (rule: FirewallRule) => void
|
||||||
|
onDelete?: (rule: FirewallRule) => void
|
||||||
|
onReorder?: (activeId: string, overId: string) => void
|
||||||
|
showServer?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGridProps) {
|
function FirewallRulesDataGrid({
|
||||||
|
rules,
|
||||||
|
onToggle,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onReorder,
|
||||||
|
showServer = false,
|
||||||
|
}: FirewallRulesDataGridProps) {
|
||||||
const indexedRules = useMemo(
|
const indexedRules = useMemo(
|
||||||
() => rules.map((rule, index) => ({ ...rule, _index: index + 1 })),
|
() => rules.map((rule, index) => ({ ...rule, _index: index + 1 })),
|
||||||
[rules],
|
[rules],
|
||||||
)
|
)
|
||||||
|
const reorderable = Boolean(onReorder)
|
||||||
|
const indexPad = reorderable ? DATA_GRID_CELL_PAD : DATA_GRID_CELL_PAD_FIRST
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<FirewallRule & { _index: number }>[]>(
|
const columns = useMemo<ColumnDef<FirewallRule & { _index: number }>[]>(
|
||||||
() => [
|
() => [
|
||||||
|
...(reorderable
|
||||||
|
? [{
|
||||||
|
id: "drag",
|
||||||
|
header: () => <span className="sr-only">Порядок</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: () => <DataGridTableDndRowHandle />,
|
||||||
|
size: 40,
|
||||||
|
meta: {
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
},
|
||||||
|
} satisfies ColumnDef<FirewallRule & { _index: number }>]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
id: "index",
|
id: "index",
|
||||||
accessorKey: "_index",
|
accessorKey: "_index",
|
||||||
@@ -114,10 +142,23 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri
|
|||||||
),
|
),
|
||||||
size: 48,
|
size: 48,
|
||||||
meta: {
|
meta: {
|
||||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
headerClassName: indexPad,
|
||||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
cellClassName: indexPad,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
...(showServer
|
||||||
|
? [{
|
||||||
|
id: "server",
|
||||||
|
accessorFn: (row: FirewallRule & { _index: number }) => row.serverName ?? row.serverId ?? "",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground truncate">
|
||||||
|
{row.original.serverName || row.original.serverId || "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: "Сервер", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
} satisfies ColumnDef<FirewallRule & { _index: number }>]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
id: "chain",
|
id: "chain",
|
||||||
accessorKey: "chain",
|
accessorKey: "chain",
|
||||||
@@ -261,7 +302,7 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri
|
|||||||
{r.enabled ? "Отключить" : "Включить"}
|
{r.enabled ? "Отключить" : "Включить"}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem variant="destructive">
|
<DropdownMenuItem variant="destructive" onClick={() => onDelete?.(r)}>
|
||||||
<Trash2Icon className="size-4" />
|
<Trash2Icon className="size-4" />
|
||||||
Удалить правило
|
Удалить правило
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -274,17 +315,24 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri
|
|||||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[onEdit, onToggle],
|
[onEdit, onToggle, onDelete, showServer, reorderable, indexPad],
|
||||||
)
|
)
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: indexedRules,
|
data: indexedRules,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
...(reorderable ? {} : { getSortedRowModel: getSortedRowModel() }),
|
||||||
|
enableSorting: !reorderable,
|
||||||
getRowId: (row) => row.id,
|
getRowId: (row) => row.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
|
const { active, over } = event
|
||||||
|
if (!over || active.id === over.id || !onReorder) return
|
||||||
|
onReorder(String(active.id), String(over.id))
|
||||||
|
}
|
||||||
|
|
||||||
if (rules.length === 0) {
|
if (rules.length === 0) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -304,7 +352,16 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri
|
|||||||
headerRow: "border-b border-border",
|
headerRow: "border-b border-border",
|
||||||
bodyRow: cn("group/row", "[&:has([data-rule-disabled=true])]:opacity-40"),
|
bodyRow: cn("group/row", "[&:has([data-rule-disabled=true])]:opacity-40"),
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{reorderable ? (
|
||||||
|
<DataGridContainer border={false} className={DATA_GRID_CONTAINER_CLASS}>
|
||||||
|
<DataGridTableDndRows
|
||||||
|
dataIds={indexedRules.map((r) => r.id)}
|
||||||
|
handleDragEnd={handleDragEnd}
|
||||||
|
/>
|
||||||
|
</DataGridContainer>
|
||||||
|
) : undefined}
|
||||||
|
</DataGridShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ function DataPageToolbarFrame({
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DataPageToolbarProps<T extends string = string> {
|
interface DataPageToolbarProps<T extends string = string> {
|
||||||
|
leading?: ReactNode
|
||||||
search?: string
|
search?: string
|
||||||
onSearchChange?: (value: string) => void
|
onSearchChange?: (value: string) => void
|
||||||
searchPlaceholder?: string
|
searchPlaceholder?: string
|
||||||
@@ -48,6 +49,7 @@ interface DataPageToolbarProps<T extends string = string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DataPageToolbar<T extends string = string>({
|
function DataPageToolbar<T extends string = string>({
|
||||||
|
leading,
|
||||||
search,
|
search,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
searchPlaceholder = "Поиск…",
|
searchPlaceholder = "Поиск…",
|
||||||
@@ -61,6 +63,7 @@ function DataPageToolbar<T extends string = string>({
|
|||||||
}: DataPageToolbarProps<T>) {
|
}: DataPageToolbarProps<T>) {
|
||||||
return (
|
return (
|
||||||
<DataPageToolbarFrame className={className}>
|
<DataPageToolbarFrame className={className}>
|
||||||
|
{leading}
|
||||||
{segmented && (
|
{segmented && (
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={segmented.value}
|
value={segmented.value}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
|
||||||
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { ChartContainer, ChartTooltip, type ChartConfig } from "@/components/ui/chart"
|
||||||
|
import { fmtRate, formatRangeAgo, TRAFFIC_RANGE_MINUTES } from "@/lib/fmt-rate"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* История RX/TX — adapt ReUI PRO chart-23 (multi-line + tooltip).
|
||||||
|
* @see https://reui.io/preview/base/chart-23
|
||||||
|
* @see https://reui.io/blocks
|
||||||
|
*/
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
rx: { label: "RX", color: "var(--chart-rx)" },
|
||||||
|
tx: { label: "TX", color: "var(--chart-tx)" },
|
||||||
|
} satisfies ChartConfig
|
||||||
|
|
||||||
|
function ChartLegendItem({ label, color }: { label: string; color: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="size-2 rounded-full" style={{ backgroundColor: color }} />
|
||||||
|
<span className="text-muted-foreground text-xs font-medium">{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomTooltip({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
active?: boolean
|
||||||
|
payload?: {
|
||||||
|
dataKey: string
|
||||||
|
color: string
|
||||||
|
value: number
|
||||||
|
}[]
|
||||||
|
}) {
|
||||||
|
if (!active || !payload?.length) return null
|
||||||
|
return (
|
||||||
|
<div className="min-w-[120px] flex flex-col gap-1.5 rounded-lg bg-popover p-3 text-popover-foreground shadow-lg ring-1 ring-foreground/10">
|
||||||
|
{payload.map((item) => (
|
||||||
|
<div key={item.dataKey}>
|
||||||
|
<div className="text-[10px] font-medium tracking-wider uppercase opacity-70">
|
||||||
|
{item.dataKey === "rx" ? "RX" : "TX"}:
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold tabular-nums">
|
||||||
|
{fmtRate(item.value)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toChartData(rx: number[], tx: number[], rangeMinutes: number) {
|
||||||
|
const n = Math.max(rx.length, tx.length, 1)
|
||||||
|
const denom = Math.max(n - 1, 1)
|
||||||
|
return Array.from({ length: n }, (_, i) => {
|
||||||
|
const minutesAgo = Math.round((1 - i / denom) * rangeMinutes)
|
||||||
|
return {
|
||||||
|
time: formatRangeAgo(minutesAgo),
|
||||||
|
rx: rx[i] ?? 0,
|
||||||
|
tx: tx[i] ?? 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrafficRxTxChart({
|
||||||
|
rx,
|
||||||
|
tx,
|
||||||
|
range = "1h",
|
||||||
|
}: {
|
||||||
|
rx: number[]
|
||||||
|
tx: number[]
|
||||||
|
range?: string
|
||||||
|
}) {
|
||||||
|
const rangeMinutes = TRAFFIC_RANGE_MINUTES[range] ?? 60
|
||||||
|
const data = toChartData(rx, tx, rangeMinutes)
|
||||||
|
const tickEvery = Math.max(1, Math.ceil(data.length / 6))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame className="w-full">
|
||||||
|
<FramePanel className="flex flex-col gap-6">
|
||||||
|
<ChartContainer config={chartConfig} className="-ms-4 aspect-auto h-[220px] w-full">
|
||||||
|
<LineChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="4 8"
|
||||||
|
vertical={false}
|
||||||
|
stroke="var(--border)"
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="time"
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickMargin={10}
|
||||||
|
interval={tickEvery - 1}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickFormatter={(v: number) => fmtRate(Number(v))}
|
||||||
|
tickMargin={8}
|
||||||
|
width={72}
|
||||||
|
/>
|
||||||
|
<ChartTooltip content={<CustomTooltip />} />
|
||||||
|
<Line
|
||||||
|
dataKey="rx"
|
||||||
|
type="monotone"
|
||||||
|
stroke="var(--chart-rx)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="tx"
|
||||||
|
type="monotone"
|
||||||
|
stroke="var(--chart-tx)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
<div className="mb-1 flex items-center justify-center gap-6">
|
||||||
|
<ChartLegendItem label="RX (входящий)" color="var(--chart-rx)" />
|
||||||
|
<ChartLegendItem label="TX (исходящий)" color="var(--chart-tx)" />
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react"
|
||||||
|
import { PanelLeftCloseIcon, ServerIcon } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet"
|
||||||
|
import {
|
||||||
|
ALL_SERVERS_ID,
|
||||||
|
ServerTileRail,
|
||||||
|
hostnameOf,
|
||||||
|
type ServerTileItem,
|
||||||
|
} from "@/components/server-tile-rail"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
/** Preview: https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/list-9 */
|
||||||
|
|
||||||
|
const STORAGE_KEY = "mm:server-rail-collapsed"
|
||||||
|
const DESKTOP_MQ = "(min-width: 768px)"
|
||||||
|
|
||||||
|
function readCollapsed(): boolean {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(STORAGE_KEY) === "1"
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCollapsed(value: boolean) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, value ? "1" : "0")
|
||||||
|
} catch {
|
||||||
|
// quota / private mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerRailContextValue {
|
||||||
|
openRail: () => void
|
||||||
|
expandRail: () => void
|
||||||
|
collapsed: boolean
|
||||||
|
selectedLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ServerRailContext = createContext<ServerRailContextValue | null>(null)
|
||||||
|
|
||||||
|
function useServerRail() {
|
||||||
|
const ctx = useContext(ServerRailContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("ServerRailMobileButton must be used inside ServerRailLayout")
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerRailMobileButton() {
|
||||||
|
const { openRail, expandRail, collapsed, selectedLabel } = useServerRail()
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className={collapsed ? undefined : "md:hidden"}
|
||||||
|
aria-label={collapsed ? "Показать список серверов" : "Серверы"}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.matchMedia(DESKTOP_MQ).matches) {
|
||||||
|
expandRail()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
openRail()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ServerIcon data-icon="inline-start" />
|
||||||
|
{selectedLabel}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerRailLayout({
|
||||||
|
items,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
showAll = true,
|
||||||
|
allCount = 0,
|
||||||
|
showCount = true,
|
||||||
|
showType = true,
|
||||||
|
headerRight,
|
||||||
|
loading = false,
|
||||||
|
extra,
|
||||||
|
header,
|
||||||
|
banner,
|
||||||
|
contentClassName,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
items: ServerTileItem[]
|
||||||
|
selectedId: string
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
showAll?: boolean
|
||||||
|
allCount?: number
|
||||||
|
showCount?: boolean
|
||||||
|
showType?: boolean
|
||||||
|
headerRight?: ReactNode
|
||||||
|
loading?: boolean
|
||||||
|
extra?: ReactNode
|
||||||
|
header?: ReactNode
|
||||||
|
banner?: ReactNode
|
||||||
|
contentClassName?: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
const [railOpen, setRailOpen] = useState(false)
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const [hydrated, setHydrated] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCollapsed(readCollapsed())
|
||||||
|
setHydrated(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hydrated) return
|
||||||
|
writeCollapsed(collapsed)
|
||||||
|
}, [collapsed, hydrated])
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
onSelect(id)
|
||||||
|
setRailOpen(false)
|
||||||
|
},
|
||||||
|
[onSelect],
|
||||||
|
)
|
||||||
|
|
||||||
|
const expandRail = useCallback(() => {
|
||||||
|
setCollapsed(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const selectedLabel = useMemo(() => {
|
||||||
|
if (showAll && selectedId === ALL_SERVERS_ID) return "Все серверы"
|
||||||
|
const item = items.find((entry) => entry.id === selectedId)
|
||||||
|
return item ? hostnameOf(item) : "Сервер"
|
||||||
|
}, [items, selectedId, showAll])
|
||||||
|
|
||||||
|
const ctx = useMemo<ServerRailContextValue>(
|
||||||
|
() => ({
|
||||||
|
openRail: () => setRailOpen(true),
|
||||||
|
expandRail,
|
||||||
|
collapsed,
|
||||||
|
selectedLabel,
|
||||||
|
}),
|
||||||
|
[collapsed, expandRail, selectedLabel],
|
||||||
|
)
|
||||||
|
|
||||||
|
const railHeaderRight = (
|
||||||
|
<>
|
||||||
|
{headerRight}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="hidden md:inline-flex"
|
||||||
|
aria-label="Свернуть список серверов"
|
||||||
|
onClick={() => setCollapsed(true)}
|
||||||
|
>
|
||||||
|
<PanelLeftCloseIcon />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const rail = (
|
||||||
|
<ServerTileRail
|
||||||
|
items={items}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
showAll={showAll}
|
||||||
|
allCount={allCount}
|
||||||
|
showCount={showCount}
|
||||||
|
showType={showType}
|
||||||
|
headerRight={railHeaderRight}
|
||||||
|
loading={loading}
|
||||||
|
className="min-h-0 flex-1"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ServerRailContext.Provider value={ctx}>
|
||||||
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
|
{header}
|
||||||
|
{banner}
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
{!collapsed ? (
|
||||||
|
<aside className="hidden min-h-0 w-60 shrink-0 flex-col gap-3 p-3 pr-0 md:flex">
|
||||||
|
{rail}
|
||||||
|
{extra}
|
||||||
|
</aside>
|
||||||
|
) : null}
|
||||||
|
<div className={cn("min-w-0 flex-1 overflow-y-auto p-4 md:p-6", contentClassName)}>
|
||||||
|
{children}
|
||||||
|
</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={items}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
showAll={showAll}
|
||||||
|
allCount={allCount}
|
||||||
|
showCount={showCount}
|
||||||
|
showType={showType}
|
||||||
|
showHeader={false}
|
||||||
|
headerRight={headerRight}
|
||||||
|
loading={loading}
|
||||||
|
className="min-h-0 flex-1"
|
||||||
|
/>
|
||||||
|
{extra}
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
</ServerRailContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ServerRailLayout, ServerRailMobileButton }
|
||||||
+224
-75
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState, type ReactNode } from "react"
|
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react"
|
||||||
import type { ServerStatus, ServerType } from "@/lib/data"
|
import type { ServerStatus, ServerType } from "@/lib/data"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { StatusDot } from "@/components/status-dot"
|
import { StatusDot } from "@/components/status-dot"
|
||||||
@@ -12,7 +12,15 @@ import {
|
|||||||
FramePanel,
|
FramePanel,
|
||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from "@/components/reui/frame"
|
} from "@/components/reui/frame"
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemActions,
|
||||||
|
ItemContent,
|
||||||
|
ItemMedia,
|
||||||
|
ItemTitle,
|
||||||
|
} from "@/components/ui/item"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
import {
|
import {
|
||||||
InputGroup,
|
InputGroup,
|
||||||
InputGroupAddon,
|
InputGroupAddon,
|
||||||
@@ -21,7 +29,7 @@ import {
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { LayersIcon, SearchIcon, ServerIcon } from "lucide-react"
|
import { LayersIcon, SearchIcon, ServerIcon } from "lucide-react"
|
||||||
|
|
||||||
/** Preview: https://reui.io/preview/base/list-9 · https://reui.io/docs/components/base/icon-tile · https://reui.io/preview/base/components/c-input-group-1 */
|
/** Preview: https://reui.io/preview/base/list-9 · https://reui.io/docs/components/base/icon-tile · https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/badge */
|
||||||
export const ALL_SERVERS_ID = "all"
|
export const ALL_SERVERS_ID = "all"
|
||||||
|
|
||||||
export interface ServerTileItem {
|
export interface ServerTileItem {
|
||||||
@@ -34,6 +42,9 @@ export interface ServerTileItem {
|
|||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
selectable?: boolean
|
selectable?: boolean
|
||||||
title?: string
|
title?: string
|
||||||
|
site?: string
|
||||||
|
host?: string
|
||||||
|
meta?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function typeBadgeVariant(
|
function typeBadgeVariant(
|
||||||
@@ -58,12 +69,22 @@ function ServerTypeBadge({ type }: { type: ServerType }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hostnameOf(item: ServerTileItem): string {
|
||||||
|
return item.name || item.host || ""
|
||||||
|
}
|
||||||
|
|
||||||
function matchesQuery(item: ServerTileItem, q: string): boolean {
|
function matchesQuery(item: ServerTileItem, q: string): boolean {
|
||||||
if (!q) return true
|
if (!q) return true
|
||||||
const hay = [item.name, item.title ?? ""].join(" ").toLowerCase()
|
const hay = [item.name, item.title ?? "", item.host ?? "", item.site ?? "", item.country ?? ""]
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase()
|
||||||
return hay.includes(q)
|
return hay.includes(q)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSelectable(item: ServerTileItem): boolean {
|
||||||
|
return item.selectable !== false
|
||||||
|
}
|
||||||
|
|
||||||
function ServerTileRail({
|
function ServerTileRail({
|
||||||
items,
|
items,
|
||||||
selectedId,
|
selectedId,
|
||||||
@@ -74,6 +95,7 @@ function ServerTileRail({
|
|||||||
showCount = true,
|
showCount = true,
|
||||||
showType = true,
|
showType = true,
|
||||||
headerRight,
|
headerRight,
|
||||||
|
loading = false,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
items: ServerTileItem[]
|
items: ServerTileItem[]
|
||||||
@@ -85,18 +107,106 @@ function ServerTileRail({
|
|||||||
showCount?: boolean
|
showCount?: boolean
|
||||||
showType?: boolean
|
showType?: boolean
|
||||||
headerRight?: ReactNode
|
headerRight?: ReactNode
|
||||||
|
loading?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const q = query.trim().toLowerCase()
|
const q = query.trim().toLowerCase()
|
||||||
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(
|
||||||
() => items.filter((item) => matchesQuery(item, q)),
|
() => items.filter((item) => matchesQuery(item, q)),
|
||||||
[items, q],
|
[items, q],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const uniqueSites = useMemo(() => {
|
||||||
|
const sites = new Set(
|
||||||
|
filtered.map((item) => item.site?.trim()).filter((site): site is string => Boolean(site)),
|
||||||
|
)
|
||||||
|
return sites.size
|
||||||
|
}, [filtered])
|
||||||
|
|
||||||
|
const groupBySite = uniqueSites >= 2 && !q
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
if (!groupBySite) {
|
||||||
|
return [{ site: "", items: filtered }]
|
||||||
|
}
|
||||||
|
const bySite = new Map<string, ServerTileItem[]>()
|
||||||
|
for (const item of filtered) {
|
||||||
|
const site = item.site?.trim() || "—"
|
||||||
|
const list = bySite.get(site)
|
||||||
|
if (list) list.push(item)
|
||||||
|
else bySite.set(site, [item])
|
||||||
|
}
|
||||||
|
return [...bySite.entries()].map(([site, siteItems]) => ({ site, items: siteItems }))
|
||||||
|
}, [filtered, groupBySite])
|
||||||
|
|
||||||
const showAllTile = showAll && !q
|
const showAllTile = showAll && !q
|
||||||
|
|
||||||
|
const resolvedId = useMemo(() => {
|
||||||
|
if (showAll && selectedId === ALL_SERVERS_ID) return ALL_SERVERS_ID
|
||||||
|
if (items.some((item) => item.id === selectedId)) return selectedId
|
||||||
|
if (showAll) return ALL_SERVERS_ID
|
||||||
|
return items.find(isSelectable)?.id ?? items[0]?.id ?? ""
|
||||||
|
}, [items, selectedId, showAll])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (resolvedId && resolvedId !== selectedId) onSelect(resolvedId)
|
||||||
|
}, [onSelect, resolvedId, selectedId])
|
||||||
|
|
||||||
|
const selectableIds = useMemo(() => {
|
||||||
|
const ids: string[] = []
|
||||||
|
if (showAllTile) ids.push(ALL_SERVERS_ID)
|
||||||
|
for (const item of filtered) {
|
||||||
|
if (isSelectable(item)) ids.push(item.id)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}, [filtered, showAllTile])
|
||||||
|
|
||||||
|
function moveSelection(delta: number) {
|
||||||
|
if (selectableIds.length === 0) return
|
||||||
|
const idx = selectableIds.indexOf(resolvedId)
|
||||||
|
const nextIdx =
|
||||||
|
idx < 0
|
||||||
|
? delta > 0 ? 0 : selectableIds.length - 1
|
||||||
|
: Math.min(selectableIds.length - 1, Math.max(0, idx + delta))
|
||||||
|
const nextId = selectableIds[nextIdx]
|
||||||
|
if (nextId) onSelect(nextId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleListKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||||
|
if (loading) return
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault()
|
||||||
|
moveSelection(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault()
|
||||||
|
moveSelection(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "Home") {
|
||||||
|
event.preventDefault()
|
||||||
|
if (selectableIds[0]) onSelect(selectableIds[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "End") {
|
||||||
|
event.preventDefault()
|
||||||
|
const last = selectableIds[selectableIds.length - 1]
|
||||||
|
if (last) onSelect(last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allItem: ServerTileItem = {
|
||||||
|
id: ALL_SERVERS_ID,
|
||||||
|
name: "Все серверы",
|
||||||
|
title: "Все серверы",
|
||||||
|
count: allCount,
|
||||||
|
meta: String(allCount),
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame dense spacing="sm" className={cn("flex h-full min-h-0 w-full flex-col", className)}>
|
<Frame dense spacing="sm" className={cn("flex h-full min-h-0 w-full flex-col", className)}>
|
||||||
<FramePanel className="flex min-h-0 flex-1 flex-col gap-0 p-0">
|
<FramePanel className="flex min-h-0 flex-1 flex-col gap-0 p-0">
|
||||||
@@ -106,10 +216,16 @@ function ServerTileRail({
|
|||||||
<FrameTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<FrameTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Серверы
|
Серверы
|
||||||
</FrameTitle>
|
</FrameTitle>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{loading ? <Spinner className="size-3.5 text-muted-foreground" /> : null}
|
||||||
|
{headerRight}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : headerRight || loading ? (
|
||||||
|
<div className="flex justify-end gap-1.5">
|
||||||
|
{loading ? <Spinner className="size-3.5 text-muted-foreground" /> : null}
|
||||||
{headerRight}
|
{headerRight}
|
||||||
</div>
|
</div>
|
||||||
) : headerRight ? (
|
|
||||||
<div className="flex justify-end">{headerRight}</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<InputGroupAddon>
|
<InputGroupAddon>
|
||||||
@@ -120,18 +236,27 @@ function ServerTileRail({
|
|||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Поиск…"
|
placeholder="Поиск…"
|
||||||
aria-label="Поиск сервера"
|
aria-label="Поиск сервера"
|
||||||
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
</FrameHeader>
|
</FrameHeader>
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
<div className="flex flex-col gap-0.5 p-1.5" role="listbox" aria-label="Серверы">
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
className="flex flex-col gap-0.5 p-1.5"
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Серверы"
|
||||||
|
aria-activedescendant={resolvedId ? `server-tile-${resolvedId}` : undefined}
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={handleListKeyDown}
|
||||||
|
>
|
||||||
{showAllTile ? (
|
{showAllTile ? (
|
||||||
<ServerTileButton
|
<ServerTileButton
|
||||||
selected={selectedId === ALL_SERVERS_ID}
|
item={allItem}
|
||||||
|
selected={resolvedId === ALL_SERVERS_ID}
|
||||||
onSelect={() => onSelect(ALL_SERVERS_ID)}
|
onSelect={() => onSelect(ALL_SERVERS_ID)}
|
||||||
title="Все серверы"
|
showCount={showCount}
|
||||||
name="Все"
|
showType={false}
|
||||||
count={showCount ? allCount : undefined}
|
|
||||||
icon={
|
icon={
|
||||||
<IconTile variant="elevated" size="sm" aria-hidden="true">
|
<IconTile variant="elevated" size="sm" aria-hidden="true">
|
||||||
<LayersIcon className="text-muted-foreground" />
|
<LayersIcon className="text-muted-foreground" />
|
||||||
@@ -139,32 +264,42 @@ function ServerTileRail({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{filtered.map((item) => (
|
{groups.map((group) => (
|
||||||
<ServerTileButton
|
<div key={group.site || "__flat__"} className="flex flex-col gap-0.5">
|
||||||
key={item.id}
|
{groupBySite && group.site ? (
|
||||||
selected={selectedId === item.id}
|
<p className="sticky top-0 z-[1] bg-background/95 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-muted-foreground backdrop-blur-sm">
|
||||||
onSelect={() => onSelect(item.id)}
|
{group.site}
|
||||||
title={item.title ?? item.name}
|
</p>
|
||||||
name={item.name}
|
) : null}
|
||||||
count={showCount ? item.count : undefined}
|
{group.items.map((item) => (
|
||||||
enabled={item.enabled}
|
<ServerTileButton
|
||||||
selectable={item.selectable}
|
key={item.id}
|
||||||
status={item.status}
|
item={item}
|
||||||
type={showType ? item.type : undefined}
|
selected={resolvedId === item.id}
|
||||||
icon={
|
onSelect={() => onSelect(item.id)}
|
||||||
<IconTile variant="elevated" size="sm" aria-hidden="true">
|
showCount={showCount}
|
||||||
{item.country ? (
|
showType={showType}
|
||||||
<Flag code={item.country} size={16} />
|
showSite={!groupBySite}
|
||||||
) : (
|
icon={
|
||||||
<ServerIcon className="text-muted-foreground" />
|
<IconTile variant="elevated" size="sm" aria-hidden="true">
|
||||||
)}
|
{item.country ? (
|
||||||
</IconTile>
|
<Flag code={item.country} size={16} />
|
||||||
}
|
) : (
|
||||||
/>
|
<ServerIcon className="text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</IconTile>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{filtered.length === 0 && !showAllTile ? (
|
{filtered.length === 0 && !showAllTile ? (
|
||||||
<p className="px-2 py-3 text-center text-xs text-muted-foreground">
|
<p className="px-2 py-3 text-center text-xs text-muted-foreground">
|
||||||
{items.length === 0 ? "Нет доступных серверов" : "Ничего не найдено"}
|
{loading
|
||||||
|
? "Загрузка серверов…"
|
||||||
|
: items.length === 0
|
||||||
|
? "Нет доступных серверов"
|
||||||
|
: "Ничего не найдено"}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -175,62 +310,76 @@ function ServerTileRail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ServerTileButton({
|
function ServerTileButton({
|
||||||
|
item,
|
||||||
selected,
|
selected,
|
||||||
onSelect,
|
onSelect,
|
||||||
title,
|
showCount,
|
||||||
name,
|
showType,
|
||||||
count,
|
showSite = true,
|
||||||
enabled = true,
|
|
||||||
selectable = true,
|
|
||||||
status,
|
|
||||||
type,
|
|
||||||
icon,
|
icon,
|
||||||
}: {
|
}: {
|
||||||
|
item: ServerTileItem
|
||||||
selected: boolean
|
selected: boolean
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
title: string
|
showCount: boolean
|
||||||
name: string
|
showType: boolean
|
||||||
count?: number
|
showSite?: boolean
|
||||||
enabled?: boolean
|
|
||||||
selectable?: boolean
|
|
||||||
status?: ServerStatus
|
|
||||||
type?: ServerType
|
|
||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
}) {
|
}) {
|
||||||
|
const isAll = item.id === ALL_SERVERS_ID
|
||||||
|
const selectable = isSelectable(item)
|
||||||
|
const hostname = isAll ? "Все серверы" : hostnameOf(item)
|
||||||
|
const meta = item.meta ?? (showCount && item.count != null ? String(item.count) : undefined)
|
||||||
|
const showStatus = Boolean(item.status && item.status !== "online")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Item
|
||||||
type="button"
|
id={`server-tile-${item.id}`}
|
||||||
role="option"
|
size="xs"
|
||||||
aria-selected={selected}
|
variant={selected ? "muted" : "default"}
|
||||||
title={title}
|
render={
|
||||||
disabled={!selectable}
|
<button
|
||||||
onClick={onSelect}
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={selected}
|
||||||
|
title={item.title ?? hostname}
|
||||||
|
disabled={!selectable}
|
||||||
|
onClick={onSelect}
|
||||||
|
/>
|
||||||
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-11 w-full items-center gap-2 rounded-md px-2 text-left transition-colors",
|
"h-11 min-h-11 flex-nowrap rounded-md py-0",
|
||||||
selected
|
selected && "ring-1 ring-border",
|
||||||
? "bg-muted ring-1 ring-border"
|
item.enabled === false && !selected && "opacity-40",
|
||||||
: "hover:bg-muted/60",
|
|
||||||
!enabled && !selected && "opacity-40",
|
|
||||||
!selectable && "cursor-not-allowed opacity-40 hover:bg-transparent",
|
!selectable && "cursor-not-allowed opacity-40 hover:bg-transparent",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{icon}
|
<ItemMedia>{icon}</ItemMedia>
|
||||||
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
<ItemContent className="min-w-0">
|
||||||
{status ? <StatusDot status={status} /> : null}
|
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
|
||||||
<span className="min-w-0 truncate font-mono text-[11px] font-medium">{name}</span>
|
{showStatus ? <StatusDot status={item.status!} /> : null}
|
||||||
{type ? <ServerTypeBadge type={type} /> : null}
|
<span className="min-w-0 truncate">{hostname}</span>
|
||||||
</span>
|
{!isAll && showSite && item.site ? (
|
||||||
{count != null ? (
|
<Badge variant="outline" size="xs" className="shrink-0 font-mono uppercase">
|
||||||
<Badge
|
{item.site}
|
||||||
variant={selected ? "secondary" : "outline"}
|
</Badge>
|
||||||
size="xs"
|
) : null}
|
||||||
className="tabular-nums"
|
{!isAll && showType && item.type ? <ServerTypeBadge type={item.type} /> : null}
|
||||||
>
|
</ItemTitle>
|
||||||
{count}
|
</ItemContent>
|
||||||
</Badge>
|
{meta ? (
|
||||||
|
<ItemActions>
|
||||||
|
<Badge
|
||||||
|
variant={selected ? "secondary" : "outline"}
|
||||||
|
size="xs"
|
||||||
|
className="tabular-nums"
|
||||||
|
>
|
||||||
|
{meta}
|
||||||
|
</Badge>
|
||||||
|
</ItemActions>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</Item>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ServerTileRail, ServerTypeBadge }
|
export { ServerTileRail, ServerTypeBadge, hostnameOf }
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import * as RechartsPrimitive from "recharts"
|
||||||
|
import type { TooltipValueType } from "recharts"
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: "", dark: ".dark" } as const
|
||||||
|
|
||||||
|
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
|
||||||
|
type TooltipNameType = number | string
|
||||||
|
|
||||||
|
export type ChartConfig = Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
label?: React.ReactNode
|
||||||
|
icon?: React.ComponentType
|
||||||
|
} & (
|
||||||
|
| { color?: string; theme?: never }
|
||||||
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
|
)
|
||||||
|
>
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useChart must be used within a <ChartContainer />")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartContainer({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
config,
|
||||||
|
initialDimension = INITIAL_DIMENSION,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
config: ChartConfig
|
||||||
|
children: React.ComponentProps<
|
||||||
|
typeof RechartsPrimitive.ResponsiveContainer
|
||||||
|
>["children"]
|
||||||
|
initialDimension?: {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
const uniqueId = React.useId()
|
||||||
|
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-slot="chart"
|
||||||
|
data-chart={chartId}
|
||||||
|
className={cn(
|
||||||
|
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer
|
||||||
|
initialDimension={initialDimension}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(
|
||||||
|
([, config]) => config.theme ?? config.color
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color =
|
||||||
|
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
|
||||||
|
itemConfig.color
|
||||||
|
return color ? ` --color-${key}: ${color};` : null
|
||||||
|
})
|
||||||
|
.join("\n")}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||||
|
|
||||||
|
function ChartTooltipContent({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = "dot",
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
hideLabel?: boolean
|
||||||
|
hideIndicator?: boolean
|
||||||
|
indicator?: "line" | "dot" | "dashed"
|
||||||
|
nameKey?: string
|
||||||
|
labelKey?: string
|
||||||
|
} & Omit<
|
||||||
|
RechartsPrimitive.DefaultTooltipContentProps<
|
||||||
|
TooltipValueType,
|
||||||
|
TooltipNameType
|
||||||
|
>,
|
||||||
|
"accessibilityLayer"
|
||||||
|
>) {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload
|
||||||
|
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === "string"
|
||||||
|
? (config[label]?.label ?? label)
|
||||||
|
: itemConfig?.label
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return (
|
||||||
|
<div className={cn("font-medium", labelClassName)}>
|
||||||
|
{labelFormatter(value, payload)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||||
|
}, [
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
payload,
|
||||||
|
hideLabel,
|
||||||
|
labelClassName,
|
||||||
|
config,
|
||||||
|
labelKey,
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload
|
||||||
|
.filter((item) => item.type !== "none")
|
||||||
|
.map((item, index) => {
|
||||||
|
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const indicatorColor = color ?? item.payload?.fill ?? item.color
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||||
|
indicator === "dot" && "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||||
|
{
|
||||||
|
"h-2.5 w-2.5": indicator === "dot",
|
||||||
|
"w-1": indicator === "line",
|
||||||
|
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||||
|
indicator === "dashed",
|
||||||
|
"my-0.5": nestLabel && indicator === "dashed",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--color-bg": indicatorColor,
|
||||||
|
"--color-border": indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 justify-between leading-none",
|
||||||
|
nestLabel ? "items-end" : "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label ?? item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value != null && (
|
||||||
|
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||||
|
{typeof item.value === "number"
|
||||||
|
? item.value.toLocaleString()
|
||||||
|
: String(item.value)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend
|
||||||
|
|
||||||
|
function ChartLegendContent({
|
||||||
|
className,
|
||||||
|
hideIcon = false,
|
||||||
|
payload,
|
||||||
|
verticalAlign = "bottom",
|
||||||
|
nameKey,
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
hideIcon?: boolean
|
||||||
|
nameKey?: string
|
||||||
|
} & RechartsPrimitive.DefaultLegendContentProps) {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-4",
|
||||||
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload
|
||||||
|
.filter((item) => item.type !== "none")
|
||||||
|
.map((item, index) => {
|
||||||
|
const key = `${nameKey ?? item.dataKey ?? "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPayloadConfigFromPayload(
|
||||||
|
config: ChartConfig,
|
||||||
|
payload: unknown,
|
||||||
|
key: string
|
||||||
|
) {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
"payload" in payload &&
|
||||||
|
typeof payload.payload === "object" &&
|
||||||
|
payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
let configLabelKey: string = key
|
||||||
|
|
||||||
|
if (
|
||||||
|
key in payload &&
|
||||||
|
typeof payload[key as keyof typeof payload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[
|
||||||
|
key as keyof typeof payloadPayload
|
||||||
|
] as string
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config ? config[configLabelKey] : config[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle,
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupInput,
|
||||||
|
} from "@/components/ui/input-group"
|
||||||
|
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const Combobox = ComboboxPrimitive.Root
|
||||||
|
|
||||||
|
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
||||||
|
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Trigger
|
||||||
|
data-slot="combobox-trigger"
|
||||||
|
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
|
</ComboboxPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Clear
|
||||||
|
data-slot="combobox-clear"
|
||||||
|
render={<InputGroupButton variant="ghost" size="icon-xs" />}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<XIcon className="pointer-events-none" />
|
||||||
|
</ComboboxPrimitive.Clear>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxInput({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
disabled = false,
|
||||||
|
showTrigger = true,
|
||||||
|
showClear = false,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Input.Props & {
|
||||||
|
showTrigger?: boolean
|
||||||
|
showClear?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<InputGroup className={cn("w-auto", className)}>
|
||||||
|
<ComboboxPrimitive.Input
|
||||||
|
render={<InputGroupInput disabled={disabled} />}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon align="inline-end">
|
||||||
|
{showTrigger && (
|
||||||
|
<InputGroupButton
|
||||||
|
size="icon-xs"
|
||||||
|
variant="ghost"
|
||||||
|
render={<ComboboxTrigger />}
|
||||||
|
data-slot="input-group-button"
|
||||||
|
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showClear && <ComboboxClear disabled={disabled} />}
|
||||||
|
</InputGroupAddon>
|
||||||
|
{children}
|
||||||
|
</InputGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxContent({
|
||||||
|
className,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 6,
|
||||||
|
align = "start",
|
||||||
|
alignOffset = 0,
|
||||||
|
anchor,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
ComboboxPrimitive.Positioner.Props,
|
||||||
|
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Portal>
|
||||||
|
<ComboboxPrimitive.Positioner
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
anchor={anchor}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<ComboboxPrimitive.Popup
|
||||||
|
data-slot="combobox-content"
|
||||||
|
data-chips={!!anchor}
|
||||||
|
className={cn(
|
||||||
|
"group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ComboboxPrimitive.Positioner>
|
||||||
|
</ComboboxPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.List
|
||||||
|
data-slot="combobox-list"
|
||||||
|
className={cn(
|
||||||
|
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Item
|
||||||
|
data-slot="combobox-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ComboboxPrimitive.ItemIndicator
|
||||||
|
render={
|
||||||
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</ComboboxPrimitive.ItemIndicator>
|
||||||
|
</ComboboxPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Group
|
||||||
|
data-slot="combobox-group"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.GroupLabel.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.GroupLabel
|
||||||
|
data-slot="combobox-label"
|
||||||
|
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Empty
|
||||||
|
data-slot="combobox-empty"
|
||||||
|
className={cn(
|
||||||
|
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Separator
|
||||||
|
data-slot="combobox-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxChips({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
|
||||||
|
ComboboxPrimitive.Chips.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Chips
|
||||||
|
data-slot="combobox-chips"
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxChip({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showRemove = true,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Chip.Props & {
|
||||||
|
showRemove?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Chip
|
||||||
|
data-slot="combobox-chip"
|
||||||
|
className={cn(
|
||||||
|
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showRemove && (
|
||||||
|
<ComboboxPrimitive.ChipRemove
|
||||||
|
render={<Button variant="ghost" size="icon-xs" />}
|
||||||
|
className="-ml-1 opacity-50 hover:opacity-100"
|
||||||
|
data-slot="combobox-chip-remove"
|
||||||
|
>
|
||||||
|
<XIcon className="pointer-events-none" />
|
||||||
|
</ComboboxPrimitive.ChipRemove>
|
||||||
|
)}
|
||||||
|
</ComboboxPrimitive.Chip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxChipsInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Input.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Input
|
||||||
|
data-slot="combobox-chip-input"
|
||||||
|
className={cn("min-w-16 flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useComboboxAnchor() {
|
||||||
|
return React.useRef<HTMLDivElement | null>(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Combobox,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxContent,
|
||||||
|
ComboboxList,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxGroup,
|
||||||
|
ComboboxLabel,
|
||||||
|
ComboboxCollection,
|
||||||
|
ComboboxEmpty,
|
||||||
|
ComboboxSeparator,
|
||||||
|
ComboboxChips,
|
||||||
|
ComboboxChip,
|
||||||
|
ComboboxChipsInput,
|
||||||
|
ComboboxTrigger,
|
||||||
|
ComboboxValue,
|
||||||
|
useComboboxAnchor,
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="list"
|
||||||
|
data-slot="item-group"
|
||||||
|
className={cn(
|
||||||
|
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="item-separator"
|
||||||
|
orientation="horizontal"
|
||||||
|
className={cn("my-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemVariants = cva(
|
||||||
|
"group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-transparent",
|
||||||
|
outline: "border-border",
|
||||||
|
muted: "border-transparent bg-muted/50",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "gap-2.5 px-3 py-2.5",
|
||||||
|
sm: "gap-2.5 px-3 py-2.5",
|
||||||
|
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Item({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "div",
|
||||||
|
props: mergeProps<"div">(
|
||||||
|
{
|
||||||
|
className: cn(itemVariants({ variant, size, className })),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "item",
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemMediaVariants = cva(
|
||||||
|
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
icon: "[&_svg:not([class*='size-'])]:size-4",
|
||||||
|
image:
|
||||||
|
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function ItemMedia({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-media"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(itemMediaVariants({ variant, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-content"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-title"
|
||||||
|
className={cn(
|
||||||
|
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="item-description"
|
||||||
|
className={cn(
|
||||||
|
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-actions"
|
||||||
|
className={cn("flex items-center gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-header"
|
||||||
|
className={cn(
|
||||||
|
"flex basis-full items-center justify-between gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex basis-full items-center justify-between gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Item,
|
||||||
|
ItemMedia,
|
||||||
|
ItemContent,
|
||||||
|
ItemActions,
|
||||||
|
ItemGroup,
|
||||||
|
ItemSeparator,
|
||||||
|
ItemTitle,
|
||||||
|
ItemDescription,
|
||||||
|
ItemHeader,
|
||||||
|
ItemFooter,
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ Project lock: **`surface: frame`**. Ops / list / dashboard / detail / settings
|
|||||||
| Shell | https://reui.io/preview/base/app-shell-12 |
|
| Shell | https://reui.io/preview/base/app-shell-12 |
|
||||||
| KPI | https://reui.io/preview/base/stats-12 · https://reui.io/docs/components/base/icon-tile |
|
| KPI | https://reui.io/preview/base/stats-12 · https://reui.io/docs/components/base/icon-tile |
|
||||||
| Lists | https://reui.io/preview/base/data-grid-filtering-2 |
|
| Lists | https://reui.io/preview/base/data-grid-filtering-2 |
|
||||||
|
| Server rail | https://reui.io/preview/base/list-9 · https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile |
|
||||||
| Settings | https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3 |
|
| Settings | https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3 |
|
||||||
| Forms / Sheet | https://reui.io/preview/base/form-7 · https://reui.io/preview/base/sheet-8 |
|
| Forms / Sheet | https://reui.io/preview/base/form-7 · https://reui.io/preview/base/sheet-8 |
|
||||||
| Alert / Badge | https://reui.io/docs/components/base/alert · https://reui.io/docs/components/base/badge |
|
| Alert / Badge | https://reui.io/docs/components/base/alert · https://reui.io/docs/components/base/badge |
|
||||||
@@ -29,9 +30,11 @@ Project lock: **`surface: frame`**. Ops / list / dashboard / detail / settings
|
|||||||
|
|
||||||
| Component | Role |
|
| Component | Role |
|
||||||
|-----------|------|
|
|-----------|------|
|
||||||
| `KpiStatGrid` | Hybrid KPI (IconTile elevated `size-10.5`) |
|
KPI: только `KpiStatGrid` (hybrid IconTile elevated `size-10.5`, эталон `/wireguard`). Не hand-roll Frame KPI.
|
||||||
| `CodeExportSheet` | Экспорт кода (.rsc / .conf) — Sheet + Frame + ScrollArea |
|
| `CodeExportSheet` | Экспорт кода (.rsc / .conf) — Sheet + Frame + ScrollArea |
|
||||||
|
|
||||||
|
Переключение сервера (ops-контекст): **`ServerTileRail` + `ServerRailLayout`** (`w-60` = `--sidebar-width`). Desktop rail сворачивается (persist `mm:server-rail-collapsed`); mobile — Sheet. Не Combobox. Страницы: OSPF, Filters, GRE, Recursive Routes, WireGuard, Terminal, Certificates, Probes, **Firewall** (live `/api/firewall`). Combobox — generic primitive, не доменный селектор хостов.
|
||||||
|
|
||||||
Слои:
|
Слои:
|
||||||
|
|
||||||
| Слой | Путь |
|
| Слой | Путь |
|
||||||
@@ -68,6 +71,7 @@ Project lock: **`surface: frame`**. Ops / list / dashboard / detail / settings
|
|||||||
- Дубль Copy в header + footer export sheet
|
- Дубль Copy в header + footer export sheet
|
||||||
- Mixing Card и Frame на одном ops-экране
|
- Mixing Card и Frame на одном ops-экране
|
||||||
- Radix-варианты docs — только Base UI (`base-nova`)
|
- Radix-варианты docs — только Base UI (`base-nova`)
|
||||||
|
- Combobox / toolbar-фильтр как переключатель сервера (SoT — `ServerTileRail`)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { resolveApiUrl, withAuthHeaders } from "@/shared/api/http-client"
|
||||||
|
|
||||||
|
export interface TrafficLiveSample {
|
||||||
|
rxMbps: number
|
||||||
|
txMbps: number
|
||||||
|
at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSseBlock(block: string): { event: string; data: string } {
|
||||||
|
let event = "message"
|
||||||
|
const dataLines: string[] = []
|
||||||
|
for (const line of block.split("\n")) {
|
||||||
|
if (line.startsWith("event:")) event = line.slice(6).trim()
|
||||||
|
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim())
|
||||||
|
}
|
||||||
|
return { event, data: dataLines.join("\n") }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTrafficLive(opts: {
|
||||||
|
enabled: boolean
|
||||||
|
backendUrl: string
|
||||||
|
serverId: string
|
||||||
|
iface: string
|
||||||
|
}): { sample: TrafficLiveSample | null; error: string | null } {
|
||||||
|
const [sample, setSample] = useState<TrafficLiveSample | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!opts.enabled || !opts.serverId) {
|
||||||
|
setSample(null)
|
||||||
|
setError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ac = new AbortController()
|
||||||
|
setSample(null)
|
||||||
|
setError(null)
|
||||||
|
const ifaceQ =
|
||||||
|
opts.iface && opts.iface !== "__all__"
|
||||||
|
? `?iface=${encodeURIComponent(opts.iface)}`
|
||||||
|
: ""
|
||||||
|
const path = `/api/traffic/servers/${encodeURIComponent(opts.serverId)}/live${ifaceQ}`
|
||||||
|
const url = resolveApiUrl(opts.backendUrl, path)
|
||||||
|
|
||||||
|
let buf = ""
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: withAuthHeaders({ Accept: "text/event-stream" }),
|
||||||
|
signal: ac.signal,
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
setError(`live HTTP ${res.status}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
while (!ac.signal.aborted) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
buf += decoder.decode(value, { stream: true })
|
||||||
|
const parts = buf.split("\n\n")
|
||||||
|
buf = parts.pop() ?? ""
|
||||||
|
for (const raw of parts) {
|
||||||
|
if (!raw.trim() || raw.trim().startsWith(":")) continue
|
||||||
|
const ev = parseSseBlock(raw)
|
||||||
|
if (ev.event === "sample" && ev.data) {
|
||||||
|
const parsed = JSON.parse(ev.data) as TrafficLiveSample
|
||||||
|
setSample(parsed)
|
||||||
|
setError(null)
|
||||||
|
} else if (ev.event === "error" && ev.data) {
|
||||||
|
const parsed = JSON.parse(ev.data) as { error?: string }
|
||||||
|
setError(parsed.error ?? "live error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (ac.signal.aborted) return
|
||||||
|
setError(e instanceof Error ? e.message : "live error")
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => ac.abort()
|
||||||
|
}, [opts.enabled, opts.backendUrl, opts.serverId, opts.iface])
|
||||||
|
|
||||||
|
return { sample, error }
|
||||||
|
}
|
||||||
+30
-10
@@ -146,6 +146,8 @@ export type FirewallAction =
|
|||||||
| "fasttrack-connection" | "nfqueue" | "passthrough" | "return"
|
| "fasttrack-connection" | "nfqueue" | "passthrough" | "return"
|
||||||
| "add-src-to-address-list" | "add-dst-to-address-list"
|
| "add-src-to-address-list" | "add-dst-to-address-list"
|
||||||
|
|
||||||
|
export type FirewallTable = "filter" | "nat" | "mangle" | "raw"
|
||||||
|
|
||||||
export interface FirewallRule {
|
export interface FirewallRule {
|
||||||
id: string
|
id: string
|
||||||
chain: string
|
chain: string
|
||||||
@@ -158,16 +160,32 @@ export interface FirewallRule {
|
|||||||
comment: string
|
comment: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
hits: number
|
hits: number
|
||||||
// RouterOS 7.x extras
|
|
||||||
family?: "ip" | "ip6" | "any"
|
family?: "ip" | "ip6" | "any"
|
||||||
tlsHost?: string // tls-host matcher (SNI)
|
table?: FirewallTable
|
||||||
connRateLimit?: string // connection-rate e.g. "100/s"
|
serverId?: string
|
||||||
layer7Proto?: string // /ip firewall layer7-protocol
|
serverName?: string
|
||||||
|
rosId?: string
|
||||||
|
tlsHost?: string
|
||||||
|
connRateLimit?: string
|
||||||
|
layer7Proto?: string
|
||||||
nfqueueId?: number
|
nfqueueId?: number
|
||||||
log?: boolean
|
log?: boolean
|
||||||
logPrefix?: string
|
logPrefix?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FirewallAddressListEntry {
|
||||||
|
id: string
|
||||||
|
list: string
|
||||||
|
address: string
|
||||||
|
comment: string
|
||||||
|
disabled: boolean
|
||||||
|
timeout?: string
|
||||||
|
family?: "ip" | "ip6"
|
||||||
|
serverId?: string
|
||||||
|
serverName?: string
|
||||||
|
rosId?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Backup {
|
export interface Backup {
|
||||||
id: string
|
id: string
|
||||||
server: string
|
server: string
|
||||||
@@ -438,12 +456,14 @@ export const asns: Asn[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export const firewallRules: FirewallRule[] = [
|
export const firewallRules: FirewallRule[] = [
|
||||||
{ id: "fw1", chain: "forward", action: "accept", proto: "tcp", src: "youtube-bypass", dst: "0.0.0.0/0", port: "443", iface: "wan-bgp", comment: "YouTube bypass traffic", enabled: true, hits: 188421 },
|
{ id: "fw1", table: "filter", family: "ip", serverId: "srv1", serverName: "mt-msk-core-01", rosId: "*1", chain: "forward", action: "accept", proto: "tcp", src: "youtube-bypass", dst: "0.0.0.0/0", port: "443", iface: "wan-bgp", comment: "YouTube bypass traffic", enabled: true, hits: 188421 },
|
||||||
{ id: "fw2", chain: "forward", action: "mark-routing", proto: "tcp", src: "10.10.0.0/24", dst: "cdn-bypass", port: "—", iface: "lan", comment: "CDN routing mark", enabled: true, hits: 4128821 },
|
{ id: "fw2", table: "mangle", family: "ip", serverId: "srv1", serverName: "mt-msk-core-01", rosId: "*1", chain: "forward", action: "mark-routing", proto: "tcp", src: "10.10.0.0/24", dst: "cdn-bypass", port: "—", iface: "lan", comment: "CDN routing mark", enabled: true, hits: 4128821 },
|
||||||
{ id: "fw3", chain: "srcnat", action: "masquerade", proto: "all", src: "10.10.0.0/16", dst: "0.0.0.0/0", port: "—", iface: "wan-msk", comment: "Main NAT rule", enabled: true, hits: 12889241 },
|
{ id: "fw3", table: "nat", family: "ip", serverId: "srv1", serverName: "mt-msk-core-01", rosId: "*1", chain: "srcnat", action: "masquerade", proto: "all", src: "10.10.0.0/16", dst: "0.0.0.0/0", port: "—", iface: "wan-msk", comment: "Main NAT rule", enabled: true, hits: 12889241 },
|
||||||
{ id: "fw4", chain: "forward", action: "drop", proto: "tcp", src: "0.0.0.0/0", dst: "social-block-school", port: "—", iface: "lan-school", comment: "School social block", enabled: true, hits: 4218 },
|
{ id: "fw4", table: "filter", family: "ip", serverId: "srv1", serverName: "mt-msk-core-01", rosId: "*2", chain: "forward", action: "drop", proto: "tcp", src: "0.0.0.0/0", dst: "social-block-school", port: "—", iface: "lan-school", comment: "School social block", enabled: true, hits: 4218 },
|
||||||
{ id: "fw5", chain: "input", action: "accept", proto: "tcp", src: "10.10.0.0/16", dst: "—", port: "22,443,8291",iface: "—", comment: "Management access", enabled: true, hits: 18412 },
|
{ id: "fw5", table: "filter", family: "ip", serverId: "srv1", serverName: "mt-msk-core-01", rosId: "*3", chain: "input", action: "accept", proto: "tcp", src: "10.10.0.0/16", dst: "—", port: "22,443,8291",iface: "—", comment: "Management access", enabled: true, hits: 18412 },
|
||||||
{ id: "fw6", chain: "input", action: "drop", proto: "tcp", src: "0.0.0.0/0", dst: "—", port: "22", iface: "wan-msk", comment: "Block SSH from WAN", enabled: true, hits: 882412 },
|
{ id: "fw6", table: "filter", family: "ip", serverId: "srv1", serverName: "mt-msk-core-01", rosId: "*4", chain: "input", action: "drop", proto: "tcp", src: "0.0.0.0/0", dst: "—", port: "22", iface: "wan-msk", comment: "Block SSH from WAN", enabled: true, hits: 882412 },
|
||||||
|
{ id: "fw7", table: "filter", family: "ip6", serverId: "srv1", serverName: "mt-msk-core-01", rosId: "*1", chain: "input", action: "accept", proto: "icmpv6", src: "fe80::/10", dst: "—", port: "—", iface: "—", comment: "IPv6 link-local ICMPv6", enabled: true, hits: 1204 },
|
||||||
|
{ id: "fw8", table: "filter", family: "ip6", serverId: "srv1", serverName: "mt-msk-core-01", rosId: "*2", chain: "forward", action: "accept", proto: "tcp", src: "2a01:4f8::/32", dst: "::/0", port: "443", iface: "wan-bgp", comment: "IPv6 HTTPS forward", enabled: true, hits: 88421 },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const backups: Backup[] = [
|
export const backups: Backup[] = [
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/** Формат скорости: Кбит/с при < 1 Мбит/с, иначе Мбит/с / Гбит/с. */
|
||||||
|
export function fmtRate(v: number): string {
|
||||||
|
if (!Number.isFinite(v) || v <= 0) return "0 Кбит/с"
|
||||||
|
if (v >= 1000) return `${(v / 1000).toFixed(2)} Гбит/с`
|
||||||
|
if (v < 1) return `${Math.round(v * 1000)} Кбит/с`
|
||||||
|
if (v < 10) return `${v.toFixed(2)} Мбит/с`
|
||||||
|
if (v < 100) return `${v.toFixed(1)} Мбит/с`
|
||||||
|
return `${Math.round(v)} Мбит/с`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtGB(v: number): string {
|
||||||
|
if (v >= 1000) return `${(v / 1000).toFixed(2)} ТБ`
|
||||||
|
return `${v.toFixed(1)} ГБ`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TRAFFIC_RANGE_MINUTES: Record<string, number> = {
|
||||||
|
"5m": 5,
|
||||||
|
"15m": 15,
|
||||||
|
"1h": 60,
|
||||||
|
"4h": 240,
|
||||||
|
"24h": 1440,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRangeAgo(minutesAgo: number): string {
|
||||||
|
if (minutesAgo <= 0) return "сейчас"
|
||||||
|
if (minutesAgo < 60) return `−${minutesAgo}м`
|
||||||
|
return `−${Math.round(minutesAgo / 60)}ч`
|
||||||
|
}
|
||||||
Generated
+385
-16
@@ -24,10 +24,12 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"cn": "^0.2.5",
|
||||||
"lucide-react": "^1.11.0",
|
"lucide-react": "^1.11.0",
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"recharts": "^3.8.0",
|
||||||
"shadcn": "^4.5.0",
|
"shadcn": "^4.5.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
@@ -3694,6 +3696,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||||
|
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
|
||||||
|
"integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
@@ -3719,6 +3757,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -4219,6 +4269,69 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -4284,6 +4397,12 @@
|
|||||||
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
|
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/validate-npm-package-name": {
|
"node_modules/@types/validate-npm-package-name": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
||||||
@@ -5777,6 +5896,18 @@
|
|||||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cn": {
|
||||||
|
"version": "0.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/cn/-/cn-0.2.5.tgz",
|
||||||
|
"integrity": "sha512-OCjZtMeQfXbI4Es1+EIjkd77gvWzaE689gD8KhfexlqjClC06qR1MQBR+Z35ZMSPNEBWyHiItW1Soy0UvwNv9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"cn": "bin/cn.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/code-block-writer": {
|
"node_modules/code-block-writer": {
|
||||||
"version": "13.0.3",
|
"version": "13.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
|
||||||
@@ -5958,6 +6089,127 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
@@ -6055,6 +6307,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/decompress-response": {
|
"node_modules/decompress-response": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
@@ -6684,6 +6942,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks",
|
||||||
|
"tests/types",
|
||||||
|
"tests/browser-compat"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
@@ -7182,6 +7452,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/eventsource": {
|
"node_modules/eventsource": {
|
||||||
"version": "3.0.7",
|
"version": "3.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||||
@@ -8350,6 +8626,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -8403,6 +8689,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||||
@@ -11031,9 +11326,31 @@
|
|||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||||
|
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-remove-scroll": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
@@ -11142,6 +11459,51 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
||||||
|
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect-metadata": {
|
"node_modules/reflect-metadata": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||||
@@ -13487,6 +13849,28 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/web-streams-polyfill": {
|
"node_modules/web-streams-polyfill": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||||
@@ -13875,21 +14259,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^4.4.1"
|
"zod": "^4.4.1"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
|
||||||
"version": "16.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
|
|
||||||
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,12 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"cn": "^0.2.5",
|
||||||
"lucide-react": "^1.11.0",
|
"lucide-react": "^1.11.0",
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"recharts": "^3.8.0",
|
||||||
"shadcn": "^4.5.0",
|
"shadcn": "^4.5.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user