Compare commits
17
Commits
15ad53af1f
...
v1.16.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2820683cba | ||
|
|
37167f78e3 | ||
|
|
cf68b59b3f | ||
|
|
5e512407e5 | ||
|
|
13889005f8 | ||
|
|
f0dc5acfd3 | ||
|
|
63bed28251 | ||
|
|
95dcd3df58 | ||
|
|
1e9312acbd | ||
|
|
5884bd8873 | ||
|
|
fc161506e7 | ||
|
|
b3e50a1f5f | ||
|
|
fe32c9313a | ||
|
|
6123660346 | ||
|
|
b680f882cc | ||
|
|
5e0c16e808 | ||
|
|
66509b26bd |
No files matched your search
+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"
|
||||||
@@ -17,13 +17,14 @@ import {
|
|||||||
import {
|
import {
|
||||||
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
|
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
|
||||||
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
|
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
|
||||||
CodeXmlIcon, CopyIcon, CheckIcon, ActivityIcon, ServerIcon,
|
CodeXmlIcon, ActivityIcon, ServerIcon,
|
||||||
TerminalIcon, AlertCircleIcon,
|
TerminalIcon, AlertCircleIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import {
|
import {
|
||||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||||
SheetDescription, SheetFooter, SheetClose,
|
SheetDescription, SheetFooter, SheetClose,
|
||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
|
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -104,57 +105,23 @@ function generateContainerRsc(c: RouterContainer): string {
|
|||||||
function ExportSheet({ open, container, onClose }: {
|
function ExportSheet({ open, container, onClose }: {
|
||||||
open: boolean; container: RouterContainer | null; onClose: () => void
|
open: boolean; container: RouterContainer | null; onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container])
|
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container])
|
||||||
|
|
||||||
function handleCopy() {
|
|
||||||
navigator.clipboard.writeText(code).then(() => {
|
|
||||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
<CodeExportSheet
|
||||||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
|
open={open}
|
||||||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
onClose={onClose}
|
||||||
<div className="flex items-start justify-between gap-4">
|
title="Экспорт Container"
|
||||||
<div>
|
description="RouterOS 7.4+ · /container · /interface/veth"
|
||||||
<SheetTitle>Экспорт Container</SheetTitle>
|
formats={[
|
||||||
<SheetDescription>RouterOS 7.4+ · /container · /interface/veth</SheetDescription>
|
{
|
||||||
</div>
|
id: "rsc",
|
||||||
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
|
label: "MikroTik .rsc",
|
||||||
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
|
filename: `${container?.name ?? "container"}.rsc`,
|
||||||
</Button>
|
code,
|
||||||
</div>
|
},
|
||||||
</SheetHeader>
|
]}
|
||||||
<div className="flex-1 overflow-y-auto">
|
/>
|
||||||
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
|
|
||||||
{code.split("\n").map((line, i) => {
|
|
||||||
const isComment = line.startsWith("#")
|
|
||||||
const isCmd = /^\//.test(line.trimStart())
|
|
||||||
const isParam = /^\s+[a-z]/.test(line)
|
|
||||||
return (
|
|
||||||
<span key={i} className={
|
|
||||||
isComment ? "text-muted-foreground"
|
|
||||||
: isCmd ? "text-sky-400"
|
|
||||||
: isParam ? "text-violet-300"
|
|
||||||
: "text-foreground"
|
|
||||||
}>
|
|
||||||
{line}{"\n"}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
|
|
||||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
|
||||||
<Button className="flex-1" onClick={handleCopy}>
|
|
||||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
|
||||||
{copied ? "Скопировано" : "Копировать .rsc"}
|
|
||||||
</Button>
|
|
||||||
</SheetFooter>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,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">
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import Link from "next/link"
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { FormToggle } from "@/components/form-kit"
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Badge } from "@/components/ui/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 {
|
||||||
@@ -55,6 +54,7 @@ import {
|
|||||||
type SchedulerJobGridRow,
|
type SchedulerJobGridRow,
|
||||||
} from "@/components/data-grids/data-collection-scheduler-data-grid"
|
} from "@/components/data-grids/data-collection-scheduler-data-grid"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
|
import { NetflowSettingsPanel } from "@/components/traffic/netflow-settings-panel"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
AlertCircleIcon,
|
AlertCircleIcon,
|
||||||
@@ -104,7 +104,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "traffic") {
|
if (snap.job === "traffic") {
|
||||||
const t = snap as TrafficRunSnapshot
|
const t = snap as TrafficRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
{t.skipped ? (
|
{t.skipped ? (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор трафика ещё выполнялся.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор трафика ещё выполнялся.</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -126,7 +126,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "uptime_resources") {
|
if (snap.job === "uptime_resources") {
|
||||||
const u = snap as ResourcesRunSnapshot
|
const u = snap as ResourcesRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
{u.skipped ? (
|
{u.skipped ? (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ресурсов уже выполняется.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ресурсов уже выполняется.</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -148,7 +148,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "servers_rest_ping") {
|
if (snap.job === "servers_rest_ping") {
|
||||||
const s = snap as ServersRestPingRunSnapshot
|
const s = snap as ServersRestPingRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
{s.skipped ? (
|
{s.skipped ? (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка API ещё выполнялась.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка API ещё выполнялась.</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -171,7 +171,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "uptime_ping") {
|
if (snap.job === "uptime_ping") {
|
||||||
const p = snap as PingRunSnapshot
|
const p = snap as PingRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
{p.skipped ? (
|
{p.skipped ? (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ping уже выполняется.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ping уже выполняется.</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -196,7 +196,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "uptime_speed") {
|
if (snap.job === "uptime_speed") {
|
||||||
const s = snap as SpeedScheduledRunSnapshot
|
const s = snap as SpeedScheduledRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Прогоны speed на <span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span> — по очереди для каждой включённой пробы
|
Прогоны speed на <span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span> — по очереди для каждой включённой пробы
|
||||||
</p>
|
</p>
|
||||||
@@ -209,7 +209,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "gre_bgp") {
|
if (snap.job === "gre_bgp") {
|
||||||
const g = snap as GreBgpSnapshotRunSnapshot
|
const g = snap as GreBgpSnapshotRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
{g.skipped ? (
|
{g.skipped ? (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -235,7 +235,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
{g.errors?.length ? (
|
{g.errors?.length ? (
|
||||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
|
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
|
||||||
{g.errors.map((e, i) => (
|
{g.errors.map((e, i) => (
|
||||||
<p key={i} className="break-words">
|
<p key={i} className="break-words">
|
||||||
{e}
|
{e}
|
||||||
@@ -249,7 +249,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "internet_path") {
|
if (snap.job === "internet_path") {
|
||||||
const p = snap as InternetPathRunSnapshot
|
const p = snap as InternetPathRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Снимок internet-path на{" "}
|
Снимок internet-path на{" "}
|
||||||
<span className="font-mono tabular-nums">{new Date(p.sampledAt).toLocaleString("ru-RU")}</span>
|
<span className="font-mono tabular-nums">{new Date(p.sampledAt).toLocaleString("ru-RU")}</span>
|
||||||
@@ -276,7 +276,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "certificates_renew") {
|
if (snap.job === "certificates_renew") {
|
||||||
const c = snap as CertificatesRenewRunSnapshot
|
const c = snap as CertificatesRenewRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
{c.skipped ? (
|
{c.skipped ? (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -299,7 +299,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
{c.errors.length ? (
|
{c.errors.length ? (
|
||||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
|
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
|
||||||
{c.errors.map((e, i) => (
|
{c.errors.map((e, i) => (
|
||||||
<p key={i} className="break-words">{e}</p>
|
<p key={i} className="break-words">{e}</p>
|
||||||
))}
|
))}
|
||||||
@@ -311,7 +311,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "backups") {
|
if (snap.job === "backups") {
|
||||||
const b = snap as BackupsRunSnapshot
|
const b = snap as BackupsRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
|
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-muted-foreground">Слот расписания</dt>
|
<dt className="text-muted-foreground">Слот расписания</dt>
|
||||||
@@ -331,7 +331,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
{b.errors?.length ? (
|
{b.errors?.length ? (
|
||||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
|
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
|
||||||
{b.errors.map((e, i) => (
|
{b.errors.map((e, i) => (
|
||||||
<p key={i} className="break-words">{e}</p>
|
<p key={i} className="break-words">{e}</p>
|
||||||
))}
|
))}
|
||||||
@@ -343,7 +343,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "alert_engine") {
|
if (snap.job === "alert_engine") {
|
||||||
const a = snap as AlertEngineRunSnapshot
|
const a = snap as AlertEngineRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Снимок на{" "}
|
Снимок на{" "}
|
||||||
<span className="font-mono tabular-nums">{new Date(a.sampledAt).toLocaleString("ru-RU")}</span>
|
<span className="font-mono tabular-nums">{new Date(a.sampledAt).toLocaleString("ru-RU")}</span>
|
||||||
@@ -370,7 +370,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
{a.errors?.length ? (
|
{a.errors?.length ? (
|
||||||
<Alert variant="destructive" className="py-2">
|
<Alert variant="destructive" className="py-2">
|
||||||
<AlertCircleIcon />
|
<AlertCircleIcon />
|
||||||
<AlertDescription className="space-y-1 text-xs">
|
<AlertDescription className="flex flex-col gap-1 text-xs">
|
||||||
{a.errors.map((e, i) => (
|
{a.errors.map((e, i) => (
|
||||||
<p key={i} className="break-words">
|
<p key={i} className="break-words">
|
||||||
{e}
|
{e}
|
||||||
@@ -380,7 +380,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
{a.ruleDiag && a.ruleDiag.length > 0 ? (
|
{a.ruleDiag && a.ruleDiag.length > 0 ? (
|
||||||
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 space-y-2">
|
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 flex flex-col gap-2">
|
||||||
<p className="text-[11px] font-medium text-muted-foreground">По правилам (почему не ушло в Telegram)</p>
|
<p className="text-[11px] font-medium text-muted-foreground">По правилам (почему не ушло в Telegram)</p>
|
||||||
<DataPageCard className="border-0 shadow-none bg-transparent">
|
<DataPageCard className="border-0 shadow-none bg-transparent">
|
||||||
<AlertEngineRuleDiagGrid ruleDiag={a.ruleDiag} />
|
<AlertEngineRuleDiagGrid ruleDiag={a.ruleDiag} />
|
||||||
@@ -398,39 +398,39 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
|||||||
const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—"
|
const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—"
|
||||||
const snapshot = useMemo(() => parseSchedulerRunSnapshot(r.resultJson ?? null), [r.resultJson])
|
const snapshot = useMemo(() => parseSchedulerRunSnapshot(r.resultJson ?? null), [r.resultJson])
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 text-sm">
|
<div className="flex flex-col gap-4 text-sm">
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
|
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col gap-1">
|
||||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">ID записи</dt>
|
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">ID записи</dt>
|
||||||
<dd className="font-mono text-xs break-all bg-muted/60 rounded-md px-2 py-1.5 border border-border">{r.id}</dd>
|
<dd className="font-mono text-xs break-all bg-muted/60 rounded-md px-2 py-1.5 border border-border">{r.id}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col gap-1">
|
||||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Ключ задачи</dt>
|
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Ключ задачи</dt>
|
||||||
<dd className="font-mono text-xs">{r.jobKey}</dd>
|
<dd className="font-mono text-xs">{r.jobKey}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2 space-y-1">
|
<div className="sm:col-span-2 flex flex-col gap-1">
|
||||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Название и назначение</dt>
|
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Название и назначение</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<span className="font-medium">{jobTitle}</span>
|
<span className="font-medium">{jobTitle}</span>
|
||||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">{jobDesc}</p>
|
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">{jobDesc}</p>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col gap-1">
|
||||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Старт</dt>
|
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Старт</dt>
|
||||||
<dd className="tabular-nums text-xs">{new Date(r.startedAt).toLocaleString("ru-RU")}</dd>
|
<dd className="tabular-nums text-xs">{new Date(r.startedAt).toLocaleString("ru-RU")}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col gap-1">
|
||||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Завершение</dt>
|
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Завершение</dt>
|
||||||
<dd className="tabular-nums text-xs">{new Date(r.finishedAt).toLocaleString("ru-RU")}</dd>
|
<dd className="tabular-nums text-xs">{new Date(r.finishedAt).toLocaleString("ru-RU")}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col gap-1">
|
||||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Длительность</dt>
|
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Длительность</dt>
|
||||||
<dd className="tabular-nums">
|
<dd className="tabular-nums">
|
||||||
<span className="font-mono">{r.durationMs}</span> мс
|
<span className="font-mono">{r.durationMs}</span> мс
|
||||||
<span className="text-muted-foreground text-xs ml-2">({fmtMs(r.durationMs)})</span>
|
<span className="text-muted-foreground text-xs ml-2">({fmtMs(r.durationMs)})</span>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col gap-1">
|
||||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результат</dt>
|
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результат</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -447,7 +447,7 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
{r.error ? (
|
{r.error ? (
|
||||||
<div className="space-y-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<p className="text-xs font-medium text-destructive">Текст ошибки</p>
|
<p className="text-xs font-medium text-destructive">Текст ошибки</p>
|
||||||
<pre
|
<pre
|
||||||
className="text-xs font-mono whitespace-pre-wrap break-words rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 max-h-48 overflow-y-auto"
|
className="text-xs font-mono whitespace-pre-wrap break-words rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 max-h-48 overflow-y-auto"
|
||||||
@@ -461,7 +461,7 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="flex flex-col gap-2">
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результаты измерений</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результаты измерений</p>
|
||||||
{snapshot ? (
|
{snapshot ? (
|
||||||
<SnapshotTables snap={snapshot} />
|
<SnapshotTables snap={snapshot} />
|
||||||
@@ -967,13 +967,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 +982,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 +1064,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">
|
||||||
@@ -1090,7 +1100,7 @@ export default function DataCollectionPage() {
|
|||||||
</div>
|
</div>
|
||||||
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
|
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-3 px-5 py-4">
|
<div className="flex flex-col gap-3 px-5 py-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
|
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
|
||||||
@@ -1142,7 +1152,7 @@ export default function DataCollectionPage() {
|
|||||||
{schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null}
|
{schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null}
|
||||||
Сохранить настройки планировщика
|
Сохранить настройки планировщика
|
||||||
</Button>
|
</Button>
|
||||||
<div className="text-xs text-muted-foreground space-y-0.5 pt-1 border-t border-border/60">
|
<div className="text-xs text-muted-foreground flex flex-col gap-0.5 pt-1 border-t border-border/60">
|
||||||
<p>
|
<p>
|
||||||
Трафик — последний сбор:{" "}
|
Трафик — последний сбор:{" "}
|
||||||
{trafficCollector?.lastCollectedAt
|
{trafficCollector?.lastCollectedAt
|
||||||
@@ -1201,6 +1211,8 @@ export default function DataCollectionPage() {
|
|||||||
</div>
|
</div>
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
|
|
||||||
|
{isLive ? <NetflowSettingsPanel backendUrl={backendUrl} enabled={isLive} /> : null}
|
||||||
|
|
||||||
<OpsPanel
|
<OpsPanel
|
||||||
title="Журнал прогонов"
|
title="Журнал прогонов"
|
||||||
description="SQLite `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
|
description="SQLite `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
|
||||||
|
|||||||
+153
-226
@@ -35,6 +35,9 @@ import {
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
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 { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
import { type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
|
|
||||||
// ── helpers ────────────────────────────────────────────────────────────────────
|
// ── helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -754,13 +757,11 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
|
|||||||
tunnelsList: GreTunnel[]
|
tunnelsList: GreTunnel[]
|
||||||
recRoutesByServer: Record<string, RecursiveRouteLite[]>
|
recRoutesByServer: Record<string, RecursiveRouteLite[]>
|
||||||
}) {
|
}) {
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
|
|
||||||
const singleRuleset = useMemo(
|
const singleRuleset = useMemo(
|
||||||
() => rulesets.filter(r => r.serverId === serverId),
|
() => rulesets.filter(r => r.serverId === serverId),
|
||||||
[rulesets, serverId],
|
[rulesets, serverId],
|
||||||
)
|
)
|
||||||
const server = serversList.find(s => s.id === serverId)
|
const server = serversList.find(s => s.id === serverId)
|
||||||
const totalRules = singleRuleset.reduce((s, r) => s + r.rules.length, 0)
|
const totalRules = singleRuleset.reduce((s, r) => s + r.rules.length, 0)
|
||||||
|
|
||||||
const config = useMemo(
|
const config = useMemo(
|
||||||
@@ -771,93 +772,25 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
|
|||||||
[open, singleRuleset, serversList, tunnelsList, recRoutesByServer],
|
[open, singleRuleset, serversList, tunnelsList, recRoutesByServer],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleCopy = () => {
|
|
||||||
navigator.clipboard.writeText(config).catch(() => {})
|
|
||||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
|
||||||
}
|
|
||||||
if (!open) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<CodeExportSheet
|
||||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
|
open={open}
|
||||||
<div className="relative z-10 w-full max-w-2xl mx-4 bg-card rounded-xl border shadow-2xl flex flex-col max-h-[85vh]">
|
onClose={onClose}
|
||||||
|
title="RouterOS config"
|
||||||
{/* header */}
|
description={
|
||||||
<div className="flex items-center justify-between px-5 py-3.5 border-b shrink-0">
|
server
|
||||||
<div className="flex items-center gap-2.5">
|
? `${server.name} · ${totalRules} правил`
|
||||||
<FileCodeIcon className="size-4 text-muted-foreground" />
|
: "Экспорт правил фильтрации"
|
||||||
<span className="text-sm font-semibold">RouterOS config</span>
|
}
|
||||||
{server && (
|
formats={[
|
||||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
|
{
|
||||||
<Flag code={server.country} size={11} />
|
id: "rsc",
|
||||||
<span className="font-mono">{server.name}</span>
|
label: "RouterOS",
|
||||||
<span>· {totalRules} правил</span>
|
filename: `filters-${server?.name ?? "server"}.rsc`,
|
||||||
</span>
|
code: config,
|
||||||
)}
|
},
|
||||||
</div>
|
]}
|
||||||
<Button variant="ghost" size="icon-sm" onClick={onClose}><XIcon className="size-4" /></Button>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* code */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 min-h-0">
|
|
||||||
<pre className="text-xs font-mono bg-[#0d1117] rounded-lg p-4 leading-[1.6] whitespace-pre overflow-x-auto">
|
|
||||||
{config.split("\n").map((line, i) => {
|
|
||||||
const trimmed = line.trimStart()
|
|
||||||
const cls =
|
|
||||||
// section dividers
|
|
||||||
trimmed.startsWith("# ═") || trimmed.startsWith("# ─")
|
|
||||||
? "text-[#444c56]" :
|
|
||||||
// inline comments inside rule body
|
|
||||||
trimmed.startsWith("# ")
|
|
||||||
? "text-[#8b949e]" :
|
|
||||||
// RouterOS scripting keywords
|
|
||||||
trimmed.startsWith(":local") || trimmed.startsWith(":log") || trimmed.startsWith(":foreach")
|
|
||||||
? "text-[#d2a8ff]" :
|
|
||||||
// :if identity branch / closing brace
|
|
||||||
trimmed.startsWith(":if") || (trimmed === "}" && line.length < 3)
|
|
||||||
? "text-[#ff7b72] font-semibold" :
|
|
||||||
// filter rule add command
|
|
||||||
trimmed.startsWith("/routing filter rule")
|
|
||||||
? "text-[#79c0ff]" :
|
|
||||||
// rule body: if/else if branches
|
|
||||||
trimmed.startsWith("if (") || trimmed.startsWith("} else if")
|
|
||||||
? "text-[#ff7b72]" :
|
|
||||||
// rule body: blackhole action
|
|
||||||
trimmed.startsWith("set type blackhole")
|
|
||||||
? "text-[#ff7b72] font-semibold" :
|
|
||||||
// rule body: set actions
|
|
||||||
trimmed.startsWith("set gw") || trimmed.startsWith("set gateway") || trimmed.startsWith("set out-interface")
|
|
||||||
? "text-[#a5d6ff]" :
|
|
||||||
// rule body: accept / rule close
|
|
||||||
trimmed.startsWith("accept") || trimmed === `}"` || trimmed.startsWith(`rule="`)
|
|
||||||
? "text-[#79c0ff]" :
|
|
||||||
// named params
|
|
||||||
trimmed.match(/^(chain|comment|bgp-communities)=/)
|
|
||||||
? "text-[#a5d6ff]" :
|
|
||||||
"text-[#c9d1d9]"
|
|
||||||
return <span key={i} className={cn("block", cls)}>{line || " "}</span>
|
|
||||||
})}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* footer */}
|
|
||||||
<div className="px-5 py-4 border-t bg-muted/30 shrink-0 space-y-3">
|
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs text-muted-foreground">
|
|
||||||
<p>1. Скопируйте скрипт в буфер обмена</p>
|
|
||||||
<p>3. Вставьте в терминал и нажмите Enter</p>
|
|
||||||
<p>2. Подключитесь к любому MikroTik (SSH / Winbox)</p>
|
|
||||||
<p>4. Скрипт сам определит свои правила по identity</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={onClose} className="flex-1">Закрыть</Button>
|
|
||||||
<Button onClick={handleCopy} className="flex-1">
|
|
||||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
|
||||||
{copied ? "Скопировано!" : "Скопировать"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1470,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],
|
||||||
@@ -1635,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 (моки) */}
|
||||||
@@ -1886,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}`}
|
||||||
@@ -1927,6 +1854,6 @@ export default function FiltersPage() {
|
|||||||
recRoutesByServer={recRoutesByServer}
|
recRoutesByServer={recRoutesByServer}
|
||||||
ensureRecursiveFor={ensureRecursiveRoutes}
|
ensureRecursiveFor={ensureRecursiveRoutes}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+921
-209
File diff suppressed because it is too large.
Load diff
+155
-149
@@ -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,14 +26,16 @@ 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,
|
||||||
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
|
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
|
||||||
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon,
|
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon,
|
||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||||
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
|
|
||||||
// ─── label maps ─────────────────────────────────────────────────────────────
|
// ─── label maps ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -240,11 +242,11 @@ 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)
|
||||||
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
|
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
|
|
||||||
const [tForm, setTForm] = useState(defaultTunnelForm)
|
const [tForm, setTForm] = useState(defaultTunnelForm)
|
||||||
const [pForm, setPForm] = useState(defaultPoolForm)
|
const [pForm, setPForm] = useState(defaultPoolForm)
|
||||||
@@ -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,60 +375,68 @@ 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 },
|
||||||
]
|
]
|
||||||
|
|
||||||
function handleCopy(code: string) {
|
const greExportCode = useMemo(
|
||||||
navigator.clipboard.writeText(code).then(() => {
|
() => (codePreviewTunnel ? generateRosCommands(codePreviewTunnel, serverById) : ""),
|
||||||
setCopied(true)
|
[codePreviewTunnel, serverById],
|
||||||
toast.success("Команды скопированы")
|
)
|
||||||
setTimeout(() => setCopied(false), 2000)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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 */}
|
||||||
@@ -424,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">
|
||||||
@@ -548,85 +589,50 @@ export default function GrePage() {
|
|||||||
</div>
|
</div>
|
||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ServerRailLayout>
|
||||||
|
|
||||||
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
|
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
|
||||||
<Sheet open={!!codePreviewTunnel} onOpenChange={(open) => { if (!open) setCodePreviewTunnel(null) }}>
|
<CodeExportSheet
|
||||||
<SheetContent side="right" className="w-full sm:max-w-2xl flex flex-col gap-0 p-0">
|
open={!!codePreviewTunnel}
|
||||||
{codePreviewTunnel && (() => {
|
onClose={() => setCodePreviewTunnel(null)}
|
||||||
const code = generateRosCommands(codePreviewTunnel, serverById)
|
title={codePreviewTunnel?.name ?? "GRE"}
|
||||||
return (
|
description="Команды RouterOS 7.20+ для создания туннеля"
|
||||||
<>
|
formats={[
|
||||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
{
|
||||||
<div className="flex items-start justify-between gap-4">
|
id: "rsc",
|
||||||
<div>
|
label: "RouterOS",
|
||||||
<SheetTitle className="font-mono">{codePreviewTunnel.name}</SheetTitle>
|
filename: `${codePreviewTunnel?.name ?? "gre"}.rsc`,
|
||||||
<SheetDescription>Команды RouterOS 7.20+ для создания туннеля</SheetDescription>
|
code: greExportCode,
|
||||||
</div>
|
},
|
||||||
<Button
|
]}
|
||||||
variant="outline" size="sm"
|
beforeCode={
|
||||||
className="shrink-0 gap-1.5"
|
codePreviewTunnel ? (
|
||||||
onClick={() => handleCopy(code)}
|
<div className="flex flex-wrap gap-3 text-xs shrink-0">
|
||||||
>
|
<span className="flex items-center gap-1.5">
|
||||||
{copied
|
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} />
|
||||||
? <><CheckIcon className="size-3.5 text-emerald-500" /> Скопировано</>
|
{STATUS_MAP[codePreviewTunnel.status].label}
|
||||||
: <><CopyIcon className="size-3.5" /> Копировать</>}
|
</span>
|
||||||
</Button>
|
<span className="text-muted-foreground">·</span>
|
||||||
</div>
|
<span>{serverById[codePreviewTunnel.serverId]?.name}</span>
|
||||||
</SheetHeader>
|
<span className="text-muted-foreground">·</span>
|
||||||
|
<span className="font-mono">
|
||||||
<div className="flex-1 overflow-y-auto">
|
{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress}
|
||||||
{/* meta strip */}
|
{" → "}
|
||||||
<div className="flex flex-wrap gap-3 px-6 py-3 border-b bg-muted/30 text-xs">
|
{codePreviewTunnel.remoteAddress}
|
||||||
<span className="flex items-center gap-1.5">
|
</span>
|
||||||
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} />
|
{codePreviewTunnel.ipsec ? (
|
||||||
{STATUS_MAP[codePreviewTunnel.status].label}
|
<>
|
||||||
</span>
|
<span className="text-muted-foreground">·</span>
|
||||||
<span className="text-muted-foreground">·</span>
|
<span className="flex items-center gap-1 text-success">
|
||||||
<span>{serverById[codePreviewTunnel.serverId]?.name}</span>
|
<LockIcon className="size-3" />
|
||||||
<span className="text-muted-foreground">·</span>
|
IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}
|
||||||
<span className="font-mono">{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress} → {codePreviewTunnel.remoteAddress}</span>
|
</span>
|
||||||
{codePreviewTunnel.ipsec && (
|
</>
|
||||||
<>
|
) : null}
|
||||||
<span className="text-muted-foreground">·</span>
|
</div>
|
||||||
<span className="flex items-center gap-1 text-emerald-400"><LockIcon className="size-3" /> IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}</span>
|
) : null
|
||||||
</>
|
}
|
||||||
)}
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* code block */}
|
|
||||||
<pre className="px-6 py-5 text-xs font-mono leading-relaxed text-foreground/90 whitespace-pre overflow-x-auto select-all">
|
|
||||||
{code.split("\n").map((line, i) => {
|
|
||||||
const isComment = line.startsWith("#")
|
|
||||||
const isSection = isComment && line.includes("──")
|
|
||||||
const isKey = /^\s+[a-z]/.test(line)
|
|
||||||
return (
|
|
||||||
<span key={i} className={
|
|
||||||
isSection ? "text-muted-foreground/60"
|
|
||||||
: isComment ? "text-muted-foreground"
|
|
||||||
: isKey ? "text-sky-400/90"
|
|
||||||
: "text-foreground"
|
|
||||||
}>
|
|
||||||
{line}
|
|
||||||
{"\n"}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
|
||||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
|
||||||
<Button className="flex-1 gap-1.5" onClick={() => handleCopy(code)}>
|
|
||||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
|
||||||
{copied ? "Скопировано" : "Копировать команды"}
|
|
||||||
</Button>
|
|
||||||
</SheetFooter>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
|
|
||||||
{/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */}
|
{/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */}
|
||||||
<Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}>
|
<Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}>
|
||||||
@@ -839,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>
|
||||||
|
|||||||
+51
-821
File diff suppressed because it is too large.
Load diff
+107
-107
@@ -4,9 +4,22 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react"
|
|||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { servers as mockServers } from "@/lib/data"
|
import { servers as mockServers } from "@/lib/data"
|
||||||
import { Flag } from "@/components/flag"
|
import type { ServerStatus } from "@/lib/data"
|
||||||
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 { Badge } from "@/components/reui/badge"
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameFooter,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from "@/components/reui/frame"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
import {
|
||||||
|
type ServerTileItem,
|
||||||
|
} from "@/components/server-tile-rail"
|
||||||
import {
|
import {
|
||||||
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
|
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@@ -492,6 +505,64 @@ export default function TerminalPage() {
|
|||||||
|
|
||||||
const termKey = `${selectedUid}-${refreshKey}-${isLive ? "live" : "mock"}`
|
const termKey = `${selectedUid}-${refreshKey}-${isLive ? "live" : "mock"}`
|
||||||
|
|
||||||
|
const railItems = useMemo<ServerTileItem[]>(() => {
|
||||||
|
return termServers.map((s) => ({
|
||||||
|
id: s.uid,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
country: s.country || undefined,
|
||||||
|
status: (s.status ?? undefined) as ServerStatus | undefined,
|
||||||
|
enabled: s.enabled,
|
||||||
|
selectable: s.enabled && s.status !== "offline",
|
||||||
|
title: [s.name, s.host].filter(Boolean).join(" · "),
|
||||||
|
}))
|
||||||
|
}, [termServers])
|
||||||
|
|
||||||
|
const handleSelectServer = useCallback((id: string) => {
|
||||||
|
setSelectedUid(id)
|
||||||
|
setRefreshKey((k) => k + 1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const railHeaderRight = isLive && !serversLoading
|
||||||
|
? <Badge variant="success-light" size="xs">LIVE</Badge>
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
function QuickCmds() {
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className="min-h-0 shrink-0">
|
||||||
|
<FramePanel className="flex max-h-56 flex-col gap-0 p-0">
|
||||||
|
<FrameHeader className="border-b px-3 py-2">
|
||||||
|
<FrameTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Быстрые команды
|
||||||
|
</FrameTitle>
|
||||||
|
</FrameHeader>
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<div className="flex flex-col gap-0.5 p-1.5">
|
||||||
|
{QUICK_CMDS.map(({ cmd, label }) => (
|
||||||
|
<button
|
||||||
|
key={cmd}
|
||||||
|
type="button"
|
||||||
|
className="truncate rounded-md px-2 py-1.5 text-left font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
onClick={() => injectCommand(cmd)}
|
||||||
|
title={cmd}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
<FrameFooter className="border-t text-[10px] text-muted-foreground/70">
|
||||||
|
<p>↑↓ — история команд</p>
|
||||||
|
<p>Ctrl+L — очистить экран</p>
|
||||||
|
{isLive
|
||||||
|
? <p className="text-info">Команды выполняются на роутере</p>
|
||||||
|
: <p>Режим: mock-данные</p>}
|
||||||
|
</FrameFooter>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function injectCommand(cmd: string) {
|
function injectCommand(cmd: string) {
|
||||||
const el = document.querySelector<HTMLInputElement>(".terminal-input-active")
|
const el = document.querySelector<HTMLInputElement>(".terminal-input-active")
|
||||||
if (!el) return
|
if (!el) return
|
||||||
@@ -502,108 +573,39 @@ export default function TerminalPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<ServerRailLayout
|
||||||
<PageHeader
|
items={railItems}
|
||||||
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
|
selectedId={selected?.uid ?? selectedUid}
|
||||||
actions={
|
onSelect={handleSelectServer}
|
||||||
<Button variant="outline" size="sm" onClick={() => {
|
showAll={false}
|
||||||
setRefreshKey(k => k + 1)
|
showCount={false}
|
||||||
if (isLive) setSelectedUid("")
|
showType={false}
|
||||||
}}>
|
headerRight={railHeaderRight}
|
||||||
<RefreshCwIcon className="size-4" />Переподключить
|
loading={serversLoading}
|
||||||
</Button>
|
extra={<QuickCmds />}
|
||||||
}
|
contentClassName="overflow-hidden p-3 md:p-4"
|
||||||
/>
|
header={
|
||||||
|
<PageHeader
|
||||||
<div className="flex-1 overflow-hidden p-6">
|
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
|
||||||
<div className="grid grid-cols-[220px_1fr] gap-5 h-full">
|
actions={
|
||||||
|
<>
|
||||||
{/* ── sidebar ── */}
|
<ServerRailMobileButton />
|
||||||
<div className="flex flex-col gap-4 overflow-y-auto min-h-0">
|
<Button
|
||||||
|
variant="outline"
|
||||||
{/* server picker */}
|
size="sm"
|
||||||
<div>
|
onClick={() => {
|
||||||
<div className="flex items-center gap-2 mb-2">
|
setRefreshKey((k) => k + 1)
|
||||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Узел</p>
|
if (isLive) setSelectedUid("")
|
||||||
{isLive && serversLoading && (
|
}}
|
||||||
<Loader2Icon className="size-3 animate-spin text-muted-foreground" />
|
>
|
||||||
)}
|
<RefreshCwIcon className="size-4" />
|
||||||
{isLive && !serversLoading && (
|
Переподключить
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5">
|
</Button>
|
||||||
<span className="size-1 rounded-full bg-emerald-400" />LIVE
|
</>
|
||||||
</span>
|
}
|
||||||
)}
|
/>
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
{isLive && !serversLoading && liveServers.length === 0 && (
|
|
||||||
<p className="text-xs text-muted-foreground px-2.5">
|
|
||||||
Нет доступных серверов
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
{termServers.map(s => {
|
|
||||||
const isOffline = s.status === "offline"
|
|
||||||
const isSelected = s.uid === selectedUid
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={s.uid}
|
|
||||||
disabled={isOffline || !s.enabled}
|
|
||||||
onClick={() => { setSelectedUid(s.uid); setRefreshKey(k => k + 1) }}
|
|
||||||
className={cn(
|
|
||||||
"w-full text-left rounded-md px-2.5 py-2 text-xs transition-colors",
|
|
||||||
"flex items-center gap-2",
|
|
||||||
isSelected
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "hover:bg-muted",
|
|
||||||
(isOffline || !s.enabled) && "opacity-40 cursor-not-allowed",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className={cn(
|
|
||||||
"inline-block size-1.5 rounded-full shrink-0",
|
|
||||||
s.status === "online" ? "bg-emerald-500" :
|
|
||||||
s.status === "degraded" ? "bg-amber-400" :
|
|
||||||
s.status === null ? "bg-sky-400" : "bg-red-500",
|
|
||||||
)} />
|
|
||||||
{s.country && <Flag code={s.country} />}
|
|
||||||
<span className="truncate font-mono">{s.name}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* quick commands */}
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
|
||||||
Быстрые команды
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{QUICK_CMDS.map(({ cmd, label }) => (
|
|
||||||
<button
|
|
||||||
key={cmd}
|
|
||||||
className="w-full text-left rounded-md px-2.5 py-1.5 text-[11px] font-mono text-muted-foreground hover:bg-muted hover:text-foreground transition-colors truncate block"
|
|
||||||
onClick={() => injectCommand(cmd)}
|
|
||||||
title={cmd}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* hints */}
|
|
||||||
<div className="mt-auto text-[10px] text-muted-foreground/50 space-y-0.5 px-0.5">
|
|
||||||
<p>↑↓ — история команд</p>
|
|
||||||
<p>Ctrl+L — очистить экран</p>
|
|
||||||
{isLive
|
|
||||||
? <p className="text-sky-400/60">Команды выполняются на роутере</p>
|
|
||||||
: <p>Режим: mock-данные</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── terminal ── */}
|
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<Terminal
|
<Terminal
|
||||||
key={termKey}
|
key={termKey}
|
||||||
@@ -612,12 +614,10 @@ export default function TerminalPage() {
|
|||||||
backendUrl={backendUrl}
|
backendUrl={backendUrl}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center bg-[#0d1117] rounded-lg border border-[#30363d] text-[#8b949e] text-sm font-mono">
|
<div className="flex h-full items-center justify-center rounded-lg border border-[#30363d] bg-[#0d1117] font-mono text-sm text-[#8b949e]">
|
||||||
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
|
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</ServerRailLayout>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+624
-313
File diff suppressed because it is too large.
Load diff
+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>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
|
import { UsersDataGrid } from "@/components/data-grids/users-data-grid"
|
||||||
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
|
import { UserSheet } from "@/components/users/user-sheet"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import { servers as mockServers } from "@/lib/data"
|
||||||
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import {
|
||||||
|
ALL_SECTIONS,
|
||||||
|
INIT_USERS,
|
||||||
|
bindingDiffKey,
|
||||||
|
userInitials,
|
||||||
|
type AppUser,
|
||||||
|
type AppUserForm,
|
||||||
|
type UserServerOption,
|
||||||
|
} from "@/lib/users"
|
||||||
|
import { listServers } from "@/shared/api/servers"
|
||||||
|
import {
|
||||||
|
createAppUser,
|
||||||
|
createUserBinding,
|
||||||
|
deleteAppUser,
|
||||||
|
deleteUserBinding,
|
||||||
|
listAppUsers,
|
||||||
|
updateAppUser,
|
||||||
|
} from "@/shared/api/users"
|
||||||
|
import { ApiClientError } from "@/shared/api/http-client"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import {
|
||||||
|
CableIcon,
|
||||||
|
PlusIcon,
|
||||||
|
TrashIcon,
|
||||||
|
UserCheckIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
function toServerOptionsFromMock(): UserServerOption[] {
|
||||||
|
return mockServers.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
status: s.status,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
const { mode, backendUrl } = useDataSource()
|
||||||
|
const isLive = mode === "live"
|
||||||
|
|
||||||
|
const [users, setUsers] = useState<AppUser[]>(INIT_USERS)
|
||||||
|
const [serverOptions, setServerOptions] = useState<UserServerOption[]>(toServerOptionsFromMock)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
|
const [editUser, setEditUser] = useState<AppUser | null>(null)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<AppUser | null>(null)
|
||||||
|
|
||||||
|
const loadLive = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [list, srvs] = await Promise.all([
|
||||||
|
listAppUsers(backendUrl),
|
||||||
|
listServers(backendUrl),
|
||||||
|
])
|
||||||
|
setUsers(list)
|
||||||
|
setServerOptions(srvs.map((s) => ({
|
||||||
|
id: String(s.id),
|
||||||
|
name: s.name || s.host,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
status: s.status,
|
||||||
|
})))
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Не удалось загрузить пользователей")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [backendUrl])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLive) {
|
||||||
|
setUsers(INIT_USERS)
|
||||||
|
setServerOptions(toServerOptionsFromMock())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void loadLive()
|
||||||
|
}, [isLive, loadLive])
|
||||||
|
|
||||||
|
const boundCount = users.reduce((n, u) => n + u.bindings.length, 0)
|
||||||
|
const activeCount = users.filter((u) => u.active).length
|
||||||
|
|
||||||
|
const applyBindingsDiff = async (userId: string, next: AppUserForm["bindings"], prev: AppUser["bindings"]) => {
|
||||||
|
const nextKeys = new Set(next.map(bindingDiffKey))
|
||||||
|
const prevKeys = new Map(prev.map((b) => [bindingDiffKey(b), b] as const))
|
||||||
|
for (const b of prev) {
|
||||||
|
if (!nextKeys.has(bindingDiffKey(b))) {
|
||||||
|
await deleteUserBinding(backendUrl, userId, b.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const b of next) {
|
||||||
|
if (!prevKeys.has(bindingDiffKey(b))) {
|
||||||
|
await createUserBinding(backendUrl, userId, {
|
||||||
|
serverId: Number(b.serverId),
|
||||||
|
interfaceName: b.interfaceName,
|
||||||
|
interfaceType: b.interfaceType,
|
||||||
|
peerPublicKey: b.peerPublicKey,
|
||||||
|
peerName: b.peerName,
|
||||||
|
comment: b.comment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async (form: AppUserForm) => {
|
||||||
|
if (!isLive) {
|
||||||
|
if (!editUser) {
|
||||||
|
const id = `u${Date.now()}`
|
||||||
|
const created: AppUser = {
|
||||||
|
id,
|
||||||
|
...form,
|
||||||
|
last: "только что",
|
||||||
|
avatar: userInitials(form.name),
|
||||||
|
bindings: form.bindings.map((b, i) => ({ ...b, id: `b${id}-${i}`, userId: id })),
|
||||||
|
}
|
||||||
|
setUsers((prev) => [...prev, created])
|
||||||
|
} else {
|
||||||
|
setUsers((prev) => prev.map((u) => u.id === editUser.id ? { ...u, ...form, avatar: userInitials(form.name) } : u))
|
||||||
|
}
|
||||||
|
setSheetOpen(false)
|
||||||
|
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
if (!editUser) {
|
||||||
|
const created = await createAppUser(backendUrl, {
|
||||||
|
name: form.name,
|
||||||
|
login: form.login,
|
||||||
|
email: form.email,
|
||||||
|
role: form.role,
|
||||||
|
active: form.active,
|
||||||
|
avatar: userInitials(form.name),
|
||||||
|
sections: form.sections,
|
||||||
|
servers: form.servers,
|
||||||
|
})
|
||||||
|
await applyBindingsDiff(created.id, form.bindings, [])
|
||||||
|
await loadLive()
|
||||||
|
} else {
|
||||||
|
await updateAppUser(backendUrl, editUser.id, {
|
||||||
|
name: form.name,
|
||||||
|
login: form.login,
|
||||||
|
email: form.email,
|
||||||
|
role: form.role,
|
||||||
|
active: form.active,
|
||||||
|
avatar: userInitials(form.name),
|
||||||
|
sections: form.sections,
|
||||||
|
servers: form.servers,
|
||||||
|
})
|
||||||
|
await applyBindingsDiff(editUser.id, form.bindings, editUser.bindings)
|
||||||
|
await loadLive()
|
||||||
|
}
|
||||||
|
setSheetOpen(false)
|
||||||
|
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof ApiClientError ? err.message : err instanceof Error ? err.message : "Ошибка сохранения"
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
if (!isLive) {
|
||||||
|
setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id))
|
||||||
|
setDeleteTarget(null)
|
||||||
|
toast.success("Пользователь удалён")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deleteAppUser(backendUrl, deleteTarget.id)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
await loadLive()
|
||||||
|
toast.success("Пользователь удалён")
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Не удалось удалить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ label: "Управление" }, { label: "Пользователи" }]}
|
||||||
|
actions={
|
||||||
|
<Button size="sm" onClick={() => { setEditUser(null); setSheetOpen(true) }}>
|
||||||
|
<PlusIcon className="size-4" />Пригласить
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-5">
|
||||||
|
{/* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/docs/components/base/icon-tile */}
|
||||||
|
<KpiStatGrid
|
||||||
|
aria-label="Сводка пользователей"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: "all",
|
||||||
|
label: "Пользователи",
|
||||||
|
value: users.length,
|
||||||
|
icon: <UsersIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "active",
|
||||||
|
label: "Активные",
|
||||||
|
value: activeCount,
|
||||||
|
icon: <UserCheckIcon className="size-4" />,
|
||||||
|
iconClassName: "text-success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ifaces",
|
||||||
|
label: "Привязанные ifaces",
|
||||||
|
value: boundCount,
|
||||||
|
icon: <CableIcon className="size-4" />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/docs/components/base/data-grid · https://reui.io/docs/components/base/frame */}
|
||||||
|
<DataPageCard>
|
||||||
|
<UsersDataGrid
|
||||||
|
users={users}
|
||||||
|
serversCount={serverOptions.length}
|
||||||
|
allSectionsCount={ALL_SECTIONS.length}
|
||||||
|
isLoading={loading}
|
||||||
|
onEdit={(u) => { setEditUser(u); setSheetOpen(true) }}
|
||||||
|
onDelete={setDeleteTarget}
|
||||||
|
/>
|
||||||
|
</DataPageCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UserSheet
|
||||||
|
key={sheetOpen ? (editUser?.id ?? "create") : "closed"}
|
||||||
|
open={sheetOpen}
|
||||||
|
user={editUser}
|
||||||
|
users={users}
|
||||||
|
servers={serverOptions}
|
||||||
|
isLive={isLive}
|
||||||
|
backendUrl={backendUrl}
|
||||||
|
saving={saving}
|
||||||
|
onSave={(f) => { void handleSave(f) }}
|
||||||
|
onClose={() => setSheetOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{deleteTarget && (
|
||||||
|
<AlertDialog open={!!deleteTarget} onOpenChange={(v) => { if (!v) setDeleteTarget(null) }}>
|
||||||
|
<AlertDialogContent size="default">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||||
|
<TrashIcon />
|
||||||
|
</AlertDialogMedia>
|
||||||
|
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{deleteTarget.name} · {deleteTarget.email || deleteTarget.login}. Привязки интерфейсов будут удалены.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => setDeleteTarget(null)}>Отмена</AlertDialogCancel>
|
||||||
|
<AlertDialogAction variant="destructive" onClick={() => { void handleDelete() }}>
|
||||||
|
Удалить
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+50
-78
@@ -4,22 +4,16 @@ 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, CopyIcon, CheckIcon,
|
NetworkIcon, PlusIcon, CodeXmlIcon, LayersIcon,
|
||||||
CodeXmlIcon, LayersIcon,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import {
|
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
|
||||||
SheetDescription, SheetFooter, SheetClose,
|
|
||||||
} from "@/components/ui/sheet"
|
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -70,57 +64,23 @@ function generateVxlanRsc(t: VxlanTunnel): string {
|
|||||||
function ExportSheet({ open, tunnel, onClose }: {
|
function ExportSheet({ open, tunnel, onClose }: {
|
||||||
open: boolean; tunnel: VxlanTunnel | null; onClose: () => void
|
open: boolean; tunnel: VxlanTunnel | null; onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel])
|
const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel])
|
||||||
|
|
||||||
function handleCopy() {
|
|
||||||
navigator.clipboard.writeText(code).then(() => {
|
|
||||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
<CodeExportSheet
|
||||||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
|
open={open}
|
||||||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
onClose={onClose}
|
||||||
<div className="flex items-start justify-between gap-4">
|
title="Экспорт VXLAN"
|
||||||
<div>
|
description="RouterOS 7.x · /interface/vxlan + vteps"
|
||||||
<SheetTitle>Экспорт VXLAN</SheetTitle>
|
formats={[
|
||||||
<SheetDescription>RouterOS 7.x · /interface/vxlan + vteps</SheetDescription>
|
{
|
||||||
</div>
|
id: "rsc",
|
||||||
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
|
label: "MikroTik .rsc",
|
||||||
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
|
filename: `${tunnel?.name ?? "vxlan"}.rsc`,
|
||||||
</Button>
|
code,
|
||||||
</div>
|
},
|
||||||
</SheetHeader>
|
]}
|
||||||
<div className="flex-1 overflow-y-auto">
|
/>
|
||||||
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
|
|
||||||
{code.split("\n").map((line, i) => {
|
|
||||||
const isComment = line.startsWith("#")
|
|
||||||
const isCmd = /^\//.test(line.trimStart())
|
|
||||||
const isParam = /^\s+[a-z]/.test(line)
|
|
||||||
return (
|
|
||||||
<span key={i} className={
|
|
||||||
isComment ? "text-muted-foreground"
|
|
||||||
: isCmd ? "text-sky-400"
|
|
||||||
: isParam ? "text-violet-300"
|
|
||||||
: "text-foreground"
|
|
||||||
}>
|
|
||||||
{line}{"\n"}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
|
|
||||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
|
||||||
<Button className="flex-1" onClick={handleCopy}>
|
|
||||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
|
||||||
{copied ? "Скопировано" : "Копировать .rsc"}
|
|
||||||
</Button>
|
|
||||||
</SheetFooter>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,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">
|
||||||
|
|||||||
+430
-168
@@ -10,10 +10,30 @@ import {
|
|||||||
WireguardDataGrid,
|
WireguardDataGrid,
|
||||||
type WgIfaceWithServer,
|
type WgIfaceWithServer,
|
||||||
} from "@/components/data-grids/wireguard-data-grid"
|
} from "@/components/data-grids/wireguard-data-grid"
|
||||||
|
import {
|
||||||
|
WireguardPeersGrid,
|
||||||
|
type WgPeerRow,
|
||||||
|
} from "@/components/data-grids/wireguard-peers-grid"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import {
|
||||||
import { IconTile } from "@/components/reui/icon-tile"
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
AlertTitle,
|
||||||
|
} from "@/components/reui/alert"
|
||||||
|
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 {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
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 {
|
import {
|
||||||
@@ -31,12 +51,25 @@ 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 {
|
||||||
|
ALL_SERVERS_ID,
|
||||||
|
type ServerTileItem,
|
||||||
|
} 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,
|
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon,
|
||||||
|
Trash2Icon, CodeXmlIcon, AlertCircleIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
|
type WgWorkspaceTab = "interfaces" | "peers" | "cli"
|
||||||
|
type WgStatusFilter = "all" | "up" | "down"
|
||||||
|
type WgFailure = { serverId: string; serverName?: string; error: string }
|
||||||
|
type PendingDelete =
|
||||||
|
| { kind: "iface"; iface: WgIfaceWithServer }
|
||||||
|
| { kind: "peer"; iface: WgIfaceWithServer; peerId: string }
|
||||||
|
|
||||||
function collectMockInterfaces(): WgIfaceWithServer[] {
|
function collectMockInterfaces(): WgIfaceWithServer[] {
|
||||||
const result: WgIfaceWithServer[] = []
|
const result: WgIfaceWithServer[] = []
|
||||||
for (const srv of mockServers) {
|
for (const srv of mockServers) {
|
||||||
@@ -94,7 +127,11 @@ interface BackendServer {
|
|||||||
name: string
|
name: string
|
||||||
host: string
|
host: string
|
||||||
country: string
|
country: string
|
||||||
|
type?: Server["type"]
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
status?: Server["status"]
|
||||||
|
latency?: number | null
|
||||||
|
asn?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapBackendServer(s: BackendServer): Server {
|
function mapBackendServer(s: BackendServer): Server {
|
||||||
@@ -106,11 +143,11 @@ function mapBackendServer(s: BackendServer): Server {
|
|||||||
os: "—",
|
os: "—",
|
||||||
site: "",
|
site: "",
|
||||||
country: s.country || "UN",
|
country: s.country || "UN",
|
||||||
asn: "",
|
asn: s.asn ?? "",
|
||||||
type: "exit-node",
|
type: s.type ?? "exit-node",
|
||||||
enabled: s.enabled,
|
enabled: s.enabled,
|
||||||
status: "online",
|
status: s.status ?? "online",
|
||||||
latency: null,
|
latency: s.latency ?? null,
|
||||||
sessions: 0,
|
sessions: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,20 +163,31 @@ function parseEndpoint(endpoint: string): { address?: string; port?: number } {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function peerRowId(iface: WgIfaceWithServer, peer: WgIfaceWithServer["peers"][number], index: number): string {
|
||||||
|
return peer.id ?? peer.rosId ?? `${iface.id}-${peer.publicKey}-${index}`
|
||||||
|
}
|
||||||
|
|
||||||
export default function WireGuardPage() {
|
export default function WireGuardPage() {
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
|
|
||||||
const [liveIfaces, setLiveIfaces] = useState<WgIfaceWithServer[]>([])
|
const [liveIfaces, setLiveIfaces] = useState<WgIfaceWithServer[]>([])
|
||||||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||||
|
const [failures, setFailures] = useState<WgFailure[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||||
|
const [workspaceTab, setWorkspaceTab] = useState<WgWorkspaceTab>("interfaces")
|
||||||
|
const [statusFilter, setStatusFilter] = useState<WgStatusFilter>("all")
|
||||||
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)
|
||||||
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
|
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
|
||||||
|
const [exportInitialTab, setExportInitialTab] = useState<"rsc" | "conf" | "peer">("rsc")
|
||||||
|
const [exportPeerId, setExportPeerId] = useState<string | null>(null)
|
||||||
const [peerIface, setPeerIface] = useState<WgIfaceWithServer | null>(null)
|
const [peerIface, setPeerIface] = useState<WgIfaceWithServer | null>(null)
|
||||||
|
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
|
||||||
const [liveExport, setLiveExport] = useState<{
|
const [liveExport, setLiveExport] = useState<{
|
||||||
rsc?: string
|
rsc?: string
|
||||||
conf?: string
|
conf?: string
|
||||||
@@ -157,6 +205,7 @@ export default function WireGuardPage() {
|
|||||||
])
|
])
|
||||||
setLiveIfaces(wg.interfaces.map(dtoToRow))
|
setLiveIfaces(wg.interfaces.map(dtoToRow))
|
||||||
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer))
|
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer))
|
||||||
|
setFailures(wg.failures ?? [])
|
||||||
if (wg.failures?.length) {
|
if (wg.failures?.length) {
|
||||||
toast.warning(
|
toast.warning(
|
||||||
`Не удалось опросить: ${wg.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
|
`Не удалось опросить: ${wg.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
|
||||||
@@ -165,6 +214,7 @@ export default function WireGuardPage() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Ошибка загрузки WireGuard")
|
toast.error(e instanceof Error ? e.message : "Ошибка загрузки WireGuard")
|
||||||
setLiveIfaces([])
|
setLiveIfaces([])
|
||||||
|
setFailures([])
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -175,6 +225,7 @@ export default function WireGuardPage() {
|
|||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
setLiveIfaces([])
|
setLiveIfaces([])
|
||||||
setLiveServers([])
|
setLiveServers([])
|
||||||
|
setFailures([])
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -186,10 +237,35 @@ export default function WireGuardPage() {
|
|||||||
const displayIfaces = isLive ? liveIfaces : collectMockInterfaces()
|
const displayIfaces = isLive ? liveIfaces : collectMockInterfaces()
|
||||||
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
|
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const effectiveServerId =
|
||||||
if (!search) return displayIfaces
|
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
|
||||||
|
? selectedServerId
|
||||||
|
: ALL_SERVERS_ID
|
||||||
|
|
||||||
|
const scopedIfaces = useMemo(() => {
|
||||||
|
if (effectiveServerId === ALL_SERVERS_ID) return displayIfaces
|
||||||
|
return displayIfaces.filter((i) => i.serverId === effectiveServerId)
|
||||||
|
}, [displayIfaces, effectiveServerId])
|
||||||
|
|
||||||
|
const statusCounts = useMemo(() => {
|
||||||
|
let up = 0
|
||||||
|
let down = 0
|
||||||
|
for (const iface of scopedIfaces) {
|
||||||
|
if (iface.status === "up") up += 1
|
||||||
|
else down += 1
|
||||||
|
}
|
||||||
|
return { all: scopedIfaces.length, up, down }
|
||||||
|
}, [scopedIfaces])
|
||||||
|
|
||||||
|
const statusFiltered = useMemo(() => {
|
||||||
|
if (statusFilter === "all") return scopedIfaces
|
||||||
|
return scopedIfaces.filter((i) => i.status === statusFilter)
|
||||||
|
}, [scopedIfaces, statusFilter])
|
||||||
|
|
||||||
|
const filteredIfaces = useMemo(() => {
|
||||||
|
if (!search) return statusFiltered
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
return displayIfaces.filter(
|
return statusFiltered.filter(
|
||||||
(i) =>
|
(i) =>
|
||||||
i.name.toLowerCase().includes(q) ||
|
i.name.toLowerCase().includes(q) ||
|
||||||
i.serverName.toLowerCase().includes(q) ||
|
i.serverName.toLowerCase().includes(q) ||
|
||||||
@@ -199,14 +275,47 @@ export default function WireGuardPage() {
|
|||||||
(p.endpoint ?? "").includes(q),
|
(p.endpoint ?? "").includes(q),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}, [displayIfaces, search])
|
}, [statusFiltered, search])
|
||||||
|
|
||||||
const totalPeers = displayIfaces.reduce((s, i) => s + i.peers.length, 0)
|
const peerRows = useMemo<WgPeerRow[]>(() => {
|
||||||
const onlinePeers = displayIfaces.reduce(
|
const q = search.toLowerCase()
|
||||||
|
const rows: WgPeerRow[] = []
|
||||||
|
for (const iface of scopedIfaces) {
|
||||||
|
iface.peers.forEach((peer, index) => {
|
||||||
|
const id = peerRowId(iface, peer, index)
|
||||||
|
if (q) {
|
||||||
|
const hay = [
|
||||||
|
peer.publicKey,
|
||||||
|
peer.name ?? "",
|
||||||
|
peer.endpoint ?? "",
|
||||||
|
peer.allowedIps.join(" "),
|
||||||
|
iface.name,
|
||||||
|
iface.serverName,
|
||||||
|
].join(" ").toLowerCase()
|
||||||
|
if (!hay.includes(q)) return
|
||||||
|
}
|
||||||
|
rows.push({
|
||||||
|
...peer,
|
||||||
|
id,
|
||||||
|
ifaceId: iface.id,
|
||||||
|
ifaceName: iface.name,
|
||||||
|
serverId: iface.serverId,
|
||||||
|
serverName: iface.serverName,
|
||||||
|
serverCountry: iface.serverCountry,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}, [scopedIfaces, search])
|
||||||
|
|
||||||
|
const totalPeers = scopedIfaces.reduce((s, i) => s + i.peers.length, 0)
|
||||||
|
const onlinePeers = scopedIfaces.reduce(
|
||||||
(s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length,
|
(s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length,
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
const upIfaces = displayIfaces.filter((i) => i.status === "up").length
|
const upIfaces = scopedIfaces.filter((i) => i.status === "up").length
|
||||||
|
const compactServer = effectiveServerId !== ALL_SERVERS_ID
|
||||||
|
const sheetServerId = compactServer ? effectiveServerId : undefined
|
||||||
|
|
||||||
const serverOptions = displayServers.map((s) => ({
|
const serverOptions = displayServers.map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
@@ -214,6 +323,25 @@ export default function WireGuardPage() {
|
|||||||
host: s.host,
|
host: s.host,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const railItems = useMemo<ServerTileItem[]>(() => {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
for (const iface of displayIfaces) {
|
||||||
|
counts.set(iface.serverId, (counts.get(iface.serverId) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
return displayServers.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
status: s.status,
|
||||||
|
type: s.type,
|
||||||
|
count: counts.get(s.id) ?? 0,
|
||||||
|
enabled: s.enabled,
|
||||||
|
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
|
||||||
|
}))
|
||||||
|
}, [displayServers, displayIfaces])
|
||||||
|
|
||||||
async function handleCreate(form: WgCreateFormState) {
|
async function handleCreate(form: WgCreateFormState) {
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
toast.info("Создание на роутер доступно только в live-режиме")
|
toast.info("Создание на роутер доступно только в live-режиме")
|
||||||
@@ -302,15 +430,26 @@ export default function WireGuardPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(iface: WgIfaceWithServer) {
|
async function confirmDelete() {
|
||||||
if (!isLive || !iface.rosId) {
|
if (!pendingDelete) return
|
||||||
|
if (!isLive) {
|
||||||
toast.info("Доступно только в live-режиме")
|
toast.info("Доступно только в live-режиме")
|
||||||
|
setPendingDelete(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!window.confirm(`Удалить интерфейс ${iface.name} на ${iface.serverName}?`)) return
|
|
||||||
try {
|
try {
|
||||||
await deleteWireGuardInterface(backendUrl, iface.serverId, iface.rosId)
|
if (pendingDelete.kind === "iface") {
|
||||||
toast.success("Удалено")
|
if (!pendingDelete.iface.rosId) {
|
||||||
|
toast.info("Доступно только в live-режиме")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await deleteWireGuardInterface(backendUrl, pendingDelete.iface.serverId, pendingDelete.iface.rosId)
|
||||||
|
toast.success("Удалено")
|
||||||
|
} else {
|
||||||
|
await deleteWireGuardPeer(backendUrl, pendingDelete.iface.serverId, pendingDelete.peerId)
|
||||||
|
toast.success("Пир удалён")
|
||||||
|
}
|
||||||
|
setPendingDelete(null)
|
||||||
await loadLive()
|
await loadLive()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Ошибка удаления")
|
toast.error(e instanceof Error ? e.message : "Ошибка удаления")
|
||||||
@@ -348,21 +487,6 @@ export default function WireGuardPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeletePeer(iface: WgIfaceWithServer, peerId: string) {
|
|
||||||
if (!isLive) {
|
|
||||||
toast.info("Доступно только в live-режиме")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!window.confirm("Удалить пира?")) return
|
|
||||||
try {
|
|
||||||
await deleteWireGuardPeer(backendUrl, iface.serverId, peerId)
|
|
||||||
toast.success("Пир удалён")
|
|
||||||
await loadLive()
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : "Ошибка")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLiveExport(format: "rsc" | "conf" | "peer-conf") {
|
async function handleLiveExport(format: "rsc" | "conf" | "peer-conf") {
|
||||||
if (!exportIface || !isLive) return
|
if (!exportIface || !isLive) return
|
||||||
setExportBusy(true)
|
setExportBusy(true)
|
||||||
@@ -372,6 +496,7 @@ export default function WireGuardPage() {
|
|||||||
interfaceName: exportIface.name,
|
interfaceName: exportIface.name,
|
||||||
format,
|
format,
|
||||||
includePrivateKey: format !== "peer-conf",
|
includePrivateKey: format !== "peer-conf",
|
||||||
|
peerId: format === "peer-conf" ? (exportPeerId ?? undefined) : undefined,
|
||||||
})
|
})
|
||||||
setLiveExport((prev) => ({
|
setLiveExport((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -389,149 +514,255 @@ export default function WireGuardPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openExport(iface: WgIfaceWithServer, tab: "rsc" | "conf" | "peer" = "rsc", peerId?: string) {
|
||||||
|
setLiveExport(null)
|
||||||
|
setExportInitialTab(tab)
|
||||||
|
setExportPeerId(peerId ?? null)
|
||||||
|
setExportIface(iface)
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyCreateAction = (
|
||||||
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
Новый интерфейс
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<>
|
||||||
<PageHeader
|
<ServerRailLayout
|
||||||
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
|
items={railItems}
|
||||||
actions={
|
selectedId={effectiveServerId}
|
||||||
<>
|
onSelect={setSelectedServerId}
|
||||||
{isLive && (
|
showAll
|
||||||
<Button
|
allCount={displayIfaces.length}
|
||||||
size="sm"
|
loading={isLive && loading && displayServers.length === 0}
|
||||||
variant="outline"
|
header={
|
||||||
disabled={loading}
|
<PageHeader
|
||||||
onClick={() => void loadLive()}
|
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
|
||||||
>
|
actions={
|
||||||
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
<>
|
||||||
Обновить
|
<ServerRailMobileButton />
|
||||||
|
{isLive && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => void loadLive()}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
|
||||||
|
<UploadIcon className="size-4" />
|
||||||
|
Импорт
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
<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 flex-col gap-4">
|
||||||
}
|
<KpiStatGrid
|
||||||
/>
|
aria-label="Сводка WireGuard"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: "ifaces",
|
||||||
|
label: "Интерфейсов",
|
||||||
|
value: scopedIfaces.length,
|
||||||
|
icon: <ShieldCheckIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "up",
|
||||||
|
label: "Активных (UP)",
|
||||||
|
value: upIfaces,
|
||||||
|
icon: <ActivityIcon className="size-4" />,
|
||||||
|
iconClassName: "text-success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "peers",
|
||||||
|
label: "Всего пиров",
|
||||||
|
value: totalPeers,
|
||||||
|
icon: <UsersIcon className="size-4" />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "online",
|
||||||
|
label: "Пиров онлайн",
|
||||||
|
value: `${onlinePeers}/${totalPeers}`,
|
||||||
|
icon: <KeyRoundIcon className="size-4" />,
|
||||||
|
iconClassName: "text-primary",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
{failures.length > 0 ? (
|
||||||
<div className="flex flex-col gap-5">
|
<Alert variant="destructive">
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<AlertCircleIcon />
|
||||||
{[
|
<AlertTitle>Не удалось опросить часть роутеров</AlertTitle>
|
||||||
{ label: "Интерфейсов", value: displayIfaces.length, icon: <ShieldCheckIcon className="size-4 text-muted-foreground" /> },
|
<AlertDescription>
|
||||||
{ label: "Активных (UP)", value: upIfaces, icon: <ActivityIcon className="size-4 text-emerald-500" /> },
|
{failures.map((f) => f.serverName ?? f.serverId).join(", ")}
|
||||||
{ label: "Всего пиров", value: totalPeers, icon: <UsersIcon className="size-4 text-sky-400" /> },
|
</AlertDescription>
|
||||||
{ label: "Пиров онлайн", value: `${onlinePeers}/${totalPeers}`, icon: <KeyRoundIcon className="size-4 text-violet-400" /> },
|
</Alert>
|
||||||
].map((s) => (
|
) : null}
|
||||||
<Frame key={s.label} className="h-full">
|
|
||||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
{!isLive ? (
|
||||||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
<Alert variant="info">
|
||||||
{s.icon}
|
<InfoIcon />
|
||||||
</IconTile>
|
<AlertTitle>Mock-режим</AlertTitle>
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
<AlertDescription>
|
||||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
Переключитесь в live в настройках, чтобы применять изменения на MikroTik.
|
||||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={workspaceTab}
|
||||||
|
onValueChange={(v) => setWorkspaceTab(v as WgWorkspaceTab)}
|
||||||
|
className="gap-3"
|
||||||
|
>
|
||||||
|
<TabsList variant="line">
|
||||||
|
<TabsTrigger value="interfaces" className="gap-1.5">
|
||||||
|
<ShieldCheckIcon className="size-3.5" />
|
||||||
|
Интерфейсы
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="peers" className="gap-1.5">
|
||||||
|
<UsersIcon className="size-3.5" />
|
||||||
|
Пиры
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="cli" className="gap-1.5">
|
||||||
|
<CodeXmlIcon className="size-3.5" />
|
||||||
|
CLI
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="interfaces" className="mt-0 outline-none">
|
||||||
|
<DataPageCard>
|
||||||
|
<DataPageToolbar
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
searchPlaceholder="Поиск по имени, серверу, IP…"
|
||||||
|
countLabel={`${filteredIfaces.length} интерфейсов`}
|
||||||
|
segmented={{
|
||||||
|
value: statusFilter,
|
||||||
|
onChange: setStatusFilter,
|
||||||
|
options: [
|
||||||
|
{ value: "all", label: "Все", count: statusCounts.all },
|
||||||
|
{ value: "up", label: "UP", count: statusCounts.up },
|
||||||
|
{ value: "down", label: "DOWN", count: statusCounts.down },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<WireguardDataGrid
|
||||||
|
interfaces={filteredIfaces}
|
||||||
|
compactServer={compactServer}
|
||||||
|
emptyAction={emptyCreateAction}
|
||||||
|
onExport={(iface) => openExport(iface, "rsc")}
|
||||||
|
onAddPeer={setPeerIface}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
onDelete={(iface) => setPendingDelete({ kind: "iface", iface })}
|
||||||
|
onDeletePeer={(iface, peerId) => setPendingDelete({ kind: "peer", iface, peerId })}
|
||||||
|
onExportPeer={(iface, peerId) => openExport(iface, "peer", peerId)}
|
||||||
|
/>
|
||||||
|
</DataPageCard>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="peers" className="mt-0 outline-none">
|
||||||
|
<DataPageCard>
|
||||||
|
<DataPageToolbar
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
searchPlaceholder="Поиск по ключу, IP, endpoint…"
|
||||||
|
countLabel={`${peerRows.length} пиров`}
|
||||||
|
/>
|
||||||
|
<WireguardPeersGrid
|
||||||
|
peers={peerRows}
|
||||||
|
compactServer={compactServer}
|
||||||
|
emptyAction={
|
||||||
|
scopedIfaces.length === 1 ? (
|
||||||
|
<Button size="sm" onClick={() => setPeerIface(scopedIfaces[0])}>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
Добавить пира
|
||||||
|
</Button>
|
||||||
|
) : emptyCreateAction
|
||||||
|
}
|
||||||
|
onDeletePeer={(row) => {
|
||||||
|
const iface = scopedIfaces.find((i) => i.id === row.ifaceId)
|
||||||
|
if (!iface) return
|
||||||
|
setPendingDelete({ kind: "peer", iface, peerId: row.id })
|
||||||
|
}}
|
||||||
|
onExportPeer={(row) => {
|
||||||
|
const iface = scopedIfaces.find((i) => i.id === row.ifaceId)
|
||||||
|
if (!iface) return
|
||||||
|
openExport(iface, "peer", row.id)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DataPageCard>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="cli" className="mt-0 outline-none">
|
||||||
|
<OpsPanel title="RouterOS 7 · /interface wireguard — быстрые команды" contentClassName="px-5 py-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 font-mono text-xs sm:grid-cols-3">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
title: "Создать интерфейс",
|
||||||
|
lines: [
|
||||||
|
"/interface wireguard add \\",
|
||||||
|
" name=wg0 \\",
|
||||||
|
" listen-port=13231 \\",
|
||||||
|
" mtu=1420",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Добавить пира",
|
||||||
|
lines: [
|
||||||
|
"/interface wireguard peers add \\",
|
||||||
|
" interface=wg0 \\",
|
||||||
|
' public-key="<ключ>" \\',
|
||||||
|
" allowed-address=10.0.0.2/32 \\",
|
||||||
|
" endpoint-address=1.2.3.4 \\",
|
||||||
|
" persistent-keepalive=25",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Назначить IP",
|
||||||
|
lines: [
|
||||||
|
"/ip address add \\",
|
||||||
|
" address=10.210.0.1/30 \\",
|
||||||
|
" interface=wg0",
|
||||||
|
"",
|
||||||
|
"# Статус:",
|
||||||
|
"/interface wireguard print",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
].map((b) => (
|
||||||
|
<div key={b.title}>
|
||||||
|
<p className="mb-1.5 font-sans text-[11px] font-semibold uppercase tracking-wide text-foreground/80">
|
||||||
|
{b.title}
|
||||||
|
</p>
|
||||||
|
<pre className="overflow-x-auto rounded-md bg-muted p-2.5 text-[11px] leading-relaxed text-muted-foreground">
|
||||||
|
{b.lines.join("\n")}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</FramePanel>
|
</OpsPanel>
|
||||||
</Frame>
|
</TabsContent>
|
||||||
))}
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
</ServerRailLayout>
|
||||||
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
|
|
||||||
<ShieldCheckIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-sky-600 dark:text-sky-400">
|
|
||||||
WireGuard — live-интеграция RouterOS 7.x
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground text-xs mt-0.5">
|
|
||||||
{isLive
|
|
||||||
? "Опрос /interface/wireguard на включённых серверах. Создание, импорт .rsc/.conf и экспорт с роутера."
|
|
||||||
: "Сейчас mock-режим. Переключитесь в live в настройках, чтобы применять изменения на MikroTik."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataPageCard>
|
|
||||||
<DataPageToolbar
|
|
||||||
search={search}
|
|
||||||
onSearchChange={setSearch}
|
|
||||||
searchPlaceholder="Поиск по имени, серверу, IP…"
|
|
||||||
countLabel={`${filtered.length} интерфейсов`}
|
|
||||||
/>
|
|
||||||
<WireguardDataGrid
|
|
||||||
interfaces={filtered}
|
|
||||||
onExport={(iface) => {
|
|
||||||
setLiveExport(null)
|
|
||||||
setExportIface(iface)
|
|
||||||
}}
|
|
||||||
onAddPeer={setPeerIface}
|
|
||||||
onToggle={handleToggle}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
onDeletePeer={handleDeletePeer}
|
|
||||||
onExportPeer={(iface) => {
|
|
||||||
setLiveExport(null)
|
|
||||||
setExportIface(iface)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DataPageCard>
|
|
||||||
|
|
||||||
<OpsPanel title="RouterOS 7 · /interface wireguard — быстрые команды" contentClassName="px-5 py-4">
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
title: "Создать интерфейс",
|
|
||||||
lines: [
|
|
||||||
"/interface wireguard add \\",
|
|
||||||
" name=wg0 \\",
|
|
||||||
" listen-port=13231 \\",
|
|
||||||
" mtu=1420",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Добавить пира",
|
|
||||||
lines: [
|
|
||||||
"/interface wireguard peers add \\",
|
|
||||||
" interface=wg0 \\",
|
|
||||||
' public-key="<ключ>" \\',
|
|
||||||
" allowed-address=10.0.0.2/32 \\",
|
|
||||||
" endpoint-address=1.2.3.4 \\",
|
|
||||||
" persistent-keepalive=25",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Назначить IP",
|
|
||||||
lines: [
|
|
||||||
"/ip address add \\",
|
|
||||||
" address=10.210.0.1/30 \\",
|
|
||||||
" interface=wg0",
|
|
||||||
"",
|
|
||||||
"# Статус:",
|
|
||||||
"/interface wireguard print",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
].map((b) => (
|
|
||||||
<div key={b.title}>
|
|
||||||
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">
|
|
||||||
{b.title}
|
|
||||||
</p>
|
|
||||||
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto">
|
|
||||||
{b.lines.join("\n")}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</OpsPanel>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<WgCreateSheet
|
<WgCreateSheet
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onOpenChange={setCreateOpen}
|
onOpenChange={setCreateOpen}
|
||||||
servers={serverOptions}
|
servers={serverOptions}
|
||||||
|
defaultServerId={sheetServerId}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onSubmit={handleCreate}
|
onSubmit={handleCreate}
|
||||||
/>
|
/>
|
||||||
@@ -539,6 +770,7 @@ export default function WireGuardPage() {
|
|||||||
open={importOpen}
|
open={importOpen}
|
||||||
onOpenChange={setImportOpen}
|
onOpenChange={setImportOpen}
|
||||||
servers={serverOptions}
|
servers={serverOptions}
|
||||||
|
defaultServerId={sheetServerId}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onImport={handleImport}
|
onImport={handleImport}
|
||||||
/>
|
/>
|
||||||
@@ -552,14 +784,44 @@ export default function WireGuardPage() {
|
|||||||
<WgExportSheet
|
<WgExportSheet
|
||||||
open={!!exportIface}
|
open={!!exportIface}
|
||||||
iface={exportIface}
|
iface={exportIface}
|
||||||
|
initialTab={exportInitialTab}
|
||||||
|
peerId={exportPeerId}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setExportIface(null)
|
setExportIface(null)
|
||||||
|
setExportPeerId(null)
|
||||||
|
setExportInitialTab("rsc")
|
||||||
setLiveExport(null)
|
setLiveExport(null)
|
||||||
}}
|
}}
|
||||||
liveContent={liveExport}
|
liveContent={liveExport}
|
||||||
liveBusy={exportBusy}
|
liveBusy={exportBusy}
|
||||||
onRequestLiveExport={isLive ? handleLiveExport : undefined}
|
onRequestLiveExport={isLive ? handleLiveExport : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
<AlertDialog open={!!pendingDelete} onOpenChange={(v) => { if (!v) setPendingDelete(null) }}>
|
||||||
|
<AlertDialogContent size="default">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||||
|
<Trash2Icon />
|
||||||
|
</AlertDialogMedia>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{pendingDelete?.kind === "peer" ? "Удалить пира?" : "Удалить интерфейс?"}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{pendingDelete?.kind === "iface"
|
||||||
|
? `${pendingDelete.iface.name} на ${pendingDelete.iface.serverName}. Вместе с интерфейсом будут удалены связанные пиры на роутере.`
|
||||||
|
: pendingDelete
|
||||||
|
? `Пир на ${pendingDelete.iface.name} (${pendingDelete.iface.serverName}).`
|
||||||
|
: null}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => setPendingDelete(null)}>Отмена</AlertDialogCancel>
|
||||||
|
<AlertDialogAction variant="destructive" onClick={() => void confirmDelete()}>
|
||||||
|
Удалить
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,10 @@
|
|||||||
"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",
|
||||||
|
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-analytics.test.ts",
|
||||||
|
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ CREATE TABLE IF NOT EXISTS traffic_samples (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
server_id INTEGER NOT NULL,
|
server_id INTEGER NOT NULL,
|
||||||
interface_name TEXT NOT NULL,
|
interface_name TEXT NOT NULL,
|
||||||
|
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||||
sampled_at TEXT NOT NULL,
|
sampled_at TEXT NOT NULL,
|
||||||
rx_bytes INTEGER NOT NULL DEFAULT 0,
|
rx_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
tx_bytes INTEGER NOT NULL DEFAULT 0,
|
tx_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
@@ -120,6 +121,63 @@ CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_time
|
|||||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time
|
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time
|
||||||
ON traffic_samples(server_id, interface_name, sampled_at);
|
ON traffic_samples(server_id, interface_name, sampled_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS traffic_flow_settings (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
collector_ip TEXT NOT NULL DEFAULT '10.255.254.1',
|
||||||
|
flow_listen_port INTEGER NOT NULL DEFAULT 4739,
|
||||||
|
wg_listen_port INTEGER NOT NULL DEFAULT 51821,
|
||||||
|
prefix TEXT NOT NULL DEFAULT '10.255.254.0/24',
|
||||||
|
public_endpoint TEXT NOT NULL DEFAULT '',
|
||||||
|
host_public_key TEXT NOT NULL DEFAULT '',
|
||||||
|
host_private_key TEXT NOT NULL DEFAULT '',
|
||||||
|
hub_server_id INTEGER,
|
||||||
|
retention_hours INTEGER NOT NULL DEFAULT 24,
|
||||||
|
top_n INTEGER NOT NULL DEFAULT 200,
|
||||||
|
last_datagram_at TEXT,
|
||||||
|
last_exporter_ip TEXT,
|
||||||
|
last_error TEXT,
|
||||||
|
packets_received INTEGER NOT NULL DEFAULT 0,
|
||||||
|
peers_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_buckets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
server_id INTEGER NOT NULL,
|
||||||
|
bucket_at TEXT NOT NULL,
|
||||||
|
src TEXT NOT NULL,
|
||||||
|
dst TEXT NOT NULL,
|
||||||
|
proto INTEGER NOT NULL DEFAULT 0,
|
||||||
|
src_port INTEGER NOT NULL DEFAULT 0,
|
||||||
|
dst_port INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
packets INTEGER NOT NULL DEFAULT 0,
|
||||||
|
in_iface TEXT NOT NULL DEFAULT '',
|
||||||
|
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_flow_buckets_unique
|
||||||
|
ON flow_buckets(server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time
|
||||||
|
ON flow_buckets(server_id, bucket_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_ip_meta (
|
||||||
|
prefix TEXT PRIMARY KEY,
|
||||||
|
asn INTEGER NOT NULL DEFAULT 0,
|
||||||
|
country TEXT NOT NULL DEFAULT '',
|
||||||
|
lat REAL,
|
||||||
|
lng REAL,
|
||||||
|
holder TEXT NOT NULL DEFAULT '',
|
||||||
|
ok INTEGER NOT NULL DEFAULT 1,
|
||||||
|
fetched_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_asn_meta (
|
||||||
|
asn INTEGER PRIMARY KEY,
|
||||||
|
holder TEXT NOT NULL DEFAULT '',
|
||||||
|
fetched_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS uptime_settings (
|
CREATE TABLE IF NOT EXISTS uptime_settings (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
@@ -511,9 +569,102 @@ CREATE TABLE IF NOT EXISTS alert_engine_cursor (
|
|||||||
last_source_finished_at TEXT,
|
last_source_finished_at TEXT,
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
login TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT NOT NULL DEFAULT '',
|
||||||
|
role TEXT NOT NULL DEFAULT 'viewer',
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
avatar TEXT NOT NULL DEFAULT '',
|
||||||
|
last_seen TEXT,
|
||||||
|
sections_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
servers_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_interface_bindings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
server_id INTEGER NOT NULL,
|
||||||
|
interface_name TEXT NOT NULL,
|
||||||
|
interface_type TEXT NOT NULL DEFAULT 'other',
|
||||||
|
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||||
|
peer_name TEXT NOT NULL DEFAULT '',
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE (server_id, interface_name, peer_public_key)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user
|
||||||
|
ON user_interface_bindings(user_id);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// Lightweight schema evolution for existing databases without migrations
|
// Lightweight schema evolution for existing databases without migrations
|
||||||
|
{
|
||||||
|
const sampleCols = sqlite.prepare(`PRAGMA table_info('traffic_samples')`).all() as Array<{ name?: string }>
|
||||||
|
if (!sampleCols.some((c) => c.name === "peer_public_key")) {
|
||||||
|
sqlite.exec(`ALTER TABLE traffic_samples ADD COLUMN peer_public_key TEXT NOT NULL DEFAULT ''`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const bindCols = sqlite.prepare(`PRAGMA table_info('user_interface_bindings')`).all() as Array<{ name?: string }>
|
||||||
|
if (!bindCols.some((c) => c.name === "peer_public_key")) {
|
||||||
|
sqlite.exec(`ALTER TABLE user_interface_bindings ADD COLUMN peer_public_key TEXT NOT NULL DEFAULT ''`)
|
||||||
|
}
|
||||||
|
if (!bindCols.some((c) => c.name === "peer_name")) {
|
||||||
|
sqlite.exec(`ALTER TABLE user_interface_bindings ADD COLUMN peer_name TEXT NOT NULL DEFAULT ''`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexes = sqlite.prepare(`PRAGMA index_list('user_interface_bindings')`).all() as Array<{
|
||||||
|
name?: string
|
||||||
|
unique?: number
|
||||||
|
}>
|
||||||
|
let hasPeerUnique = false
|
||||||
|
for (const idx of indexes) {
|
||||||
|
if (!idx.name || !idx.unique) continue
|
||||||
|
const info = sqlite.prepare(`PRAGMA index_info(${JSON.stringify(idx.name)})`).all() as Array<{ name?: string }>
|
||||||
|
const names = info.map((c) => c.name)
|
||||||
|
if (names.includes("server_id") && names.includes("interface_name") && names.includes("peer_public_key")) {
|
||||||
|
hasPeerUnique = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasPeerUnique) {
|
||||||
|
sqlite.exec(`PRAGMA foreign_keys = OFF`)
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE user_interface_bindings_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
server_id INTEGER NOT NULL,
|
||||||
|
interface_name TEXT NOT NULL,
|
||||||
|
interface_type TEXT NOT NULL DEFAULT 'other',
|
||||||
|
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||||
|
peer_name TEXT NOT NULL DEFAULT '',
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE (server_id, interface_name, peer_public_key)
|
||||||
|
);
|
||||||
|
INSERT INTO user_interface_bindings_new
|
||||||
|
(id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name, comment, created_at, updated_at)
|
||||||
|
SELECT id, user_id, server_id, interface_name, interface_type,
|
||||||
|
COALESCE(peer_public_key, ''), COALESCE(peer_name, ''), comment, created_at, updated_at
|
||||||
|
FROM user_interface_bindings;
|
||||||
|
DROP TABLE user_interface_bindings;
|
||||||
|
ALTER TABLE user_interface_bindings_new RENAME TO user_interface_bindings;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id);
|
||||||
|
`)
|
||||||
|
sqlite.exec(`PRAGMA foreign_keys = ON`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const recursiveCols = sqlite.prepare(`PRAGMA table_info('recursive_routes')`).all() as Array<{ name?: string }>
|
const recursiveCols = sqlite.prepare(`PRAGMA table_info('recursive_routes')`).all() as Array<{ name?: string }>
|
||||||
const hasCountryColumn = recursiveCols.some((c) => c.name === "country")
|
const hasCountryColumn = recursiveCols.some((c) => c.name === "country")
|
||||||
if (!hasCountryColumn) {
|
if (!hasCountryColumn) {
|
||||||
@@ -580,6 +731,9 @@ if (!serverCols.some((c) => c.name === "lan_subnet")) {
|
|||||||
if (!serverCols.some((c) => c.name === "wan_uplinks")) {
|
if (!serverCols.some((c) => c.name === "wan_uplinks")) {
|
||||||
sqlite.exec(`ALTER TABLE servers ADD COLUMN wan_uplinks TEXT NOT NULL DEFAULT '[]'`)
|
sqlite.exec(`ALTER TABLE servers ADD COLUMN wan_uplinks TEXT NOT NULL DEFAULT '[]'`)
|
||||||
}
|
}
|
||||||
|
if (!serverCols.some((c) => c.name === "mgmt_tunnel_ip")) {
|
||||||
|
sqlite.exec(`ALTER TABLE servers ADD COLUMN mgmt_tunnel_ip TEXT NOT NULL DEFAULT ''`)
|
||||||
|
}
|
||||||
|
|
||||||
const alertTgCols = sqlite.prepare(`PRAGMA table_info('alert_telegram_settings')`).all() as Array<{ name?: string }>
|
const alertTgCols = sqlite.prepare(`PRAGMA table_info('alert_telegram_settings')`).all() as Array<{ name?: string }>
|
||||||
if (!alertTgCols.some((c) => c.name === "message_thread_id")) {
|
if (!alertTgCols.some((c) => c.name === "message_thread_id")) {
|
||||||
@@ -625,6 +779,12 @@ SELECT 1, 1, 30, 14
|
|||||||
WHERE NOT EXISTS (SELECT 1 FROM traffic_settings WHERE id = 1);
|
WHERE NOT EXISTS (SELECT 1 FROM traffic_settings WHERE id = 1);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
sqlite.exec(`
|
||||||
|
INSERT INTO traffic_flow_settings (id, enabled, collector_ip, flow_listen_port, wg_listen_port, prefix)
|
||||||
|
SELECT 1, 0, '10.255.254.1', 4739, 51821, '10.255.254.0/24'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM traffic_flow_settings WHERE id = 1);
|
||||||
|
`)
|
||||||
|
|
||||||
sqlite.exec(`
|
sqlite.exec(`
|
||||||
INSERT INTO uptime_settings (id, enabled, interval_sec, retention_days)
|
INSERT INTO uptime_settings (id, enabled, interval_sec, retention_days)
|
||||||
SELECT 1, 1, 15, 14
|
SELECT 1, 1, 15, 14
|
||||||
@@ -661,6 +821,29 @@ SELECT 1, 'https://acme-v02.api.letsencrypt.org/directory', '', '', ''
|
|||||||
WHERE NOT EXISTS (SELECT 1 FROM acme_settings WHERE id = 1);
|
WHERE NOT EXISTS (SELECT 1 FROM acme_settings WHERE id = 1);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
{
|
||||||
|
const flowIndexes = sqlite.prepare(`PRAGMA index_list('flow_buckets')`).all() as Array<{
|
||||||
|
name?: string
|
||||||
|
unique?: number
|
||||||
|
}>
|
||||||
|
let hasIfaceUnique = false
|
||||||
|
for (const idx of flowIndexes) {
|
||||||
|
if (!idx.name || !idx.unique) continue
|
||||||
|
const info = sqlite.prepare(`PRAGMA index_info(${JSON.stringify(idx.name)})`).all() as Array<{ name?: string }>
|
||||||
|
const names = info.map((c) => c.name)
|
||||||
|
if (names.includes("in_iface") && names.includes("src") && names.includes("dst")) {
|
||||||
|
hasIfaceUnique = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasIfaceUnique) {
|
||||||
|
sqlite.exec(`DROP INDEX IF EXISTS idx_flow_buckets_unique`)
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_flow_buckets_unique
|
||||||
|
ON flow_buckets(server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const certIssueJobCols = sqlite.prepare(`PRAGMA table_info('certificate_issue_jobs')`).all() as Array<{ name?: string }>
|
const certIssueJobCols = sqlite.prepare(`PRAGMA table_info('certificate_issue_jobs')`).all() as Array<{ name?: string }>
|
||||||
if (!certIssueJobCols.some((c) => c.name === "source")) {
|
if (!certIssueJobCols.some((c) => c.name === "source")) {
|
||||||
sqlite.exec(`ALTER TABLE certificate_issue_jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'`)
|
sqlite.exec(`ALTER TABLE certificate_issue_jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'`)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
real,
|
real,
|
||||||
sqliteTable,
|
sqliteTable,
|
||||||
text,
|
text,
|
||||||
|
uniqueIndex,
|
||||||
} from "drizzle-orm/sqlite-core"
|
} from "drizzle-orm/sqlite-core"
|
||||||
|
|
||||||
// ── servers ────────────────────────────────────────────────────────────────────
|
// ── servers ────────────────────────────────────────────────────────────────────
|
||||||
@@ -31,6 +32,8 @@ export const servers = sqliteTable("servers", {
|
|||||||
lanSubnet: text("lan_subnet").notNull().default(""),
|
lanSubnet: text("lan_subnet").notNull().default(""),
|
||||||
/** JSON-массив WAN-аплинков [{ id, name, isp, iface, ip, maxDl, maxUl }, …] */
|
/** JSON-массив WAN-аплинков [{ id, name, isp, iface, ip, maxDl, maxUl }, …] */
|
||||||
wanUplinks: text("wan_uplinks").notNull().default("[]"),
|
wanUplinks: text("wan_uplinks").notNull().default("[]"),
|
||||||
|
/** Адрес в оверлее wg-flow (экспортёр IPFIX), например 10.255.254.5 */
|
||||||
|
mgmtTunnelIp: text("mgmt_tunnel_ip").notNull().default(""),
|
||||||
|
|
||||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||||
@@ -157,12 +160,72 @@ export const alertBgpPeerSamples = sqliteTable("alert_bgp_peer_samples", {
|
|||||||
|
|
||||||
// ── raw traffic samples (per server/interface/timepoint) ──────────────────────
|
// ── raw traffic samples (per server/interface/timepoint) ──────────────────────
|
||||||
|
|
||||||
|
export const trafficFlowSettings = sqliteTable("traffic_flow_settings", {
|
||||||
|
id: integer("id").primaryKey(),
|
||||||
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
|
||||||
|
collectorIp: text("collector_ip").notNull().default("10.255.254.1"),
|
||||||
|
flowListenPort: integer("flow_listen_port").notNull().default(4739),
|
||||||
|
wgListenPort: integer("wg_listen_port").notNull().default(51821),
|
||||||
|
prefix: text("prefix").notNull().default("10.255.254.0/24"),
|
||||||
|
publicEndpoint: text("public_endpoint").notNull().default(""),
|
||||||
|
hostPublicKey: text("host_public_key").notNull().default(""),
|
||||||
|
hostPrivateKey: text("host_private_key").notNull().default(""),
|
||||||
|
hubServerId: integer("hub_server_id"),
|
||||||
|
retentionHours: integer("retention_hours").notNull().default(24),
|
||||||
|
topN: integer("top_n").notNull().default(200),
|
||||||
|
lastDatagramAt: text("last_datagram_at"),
|
||||||
|
lastExporterIp: text("last_exporter_ip"),
|
||||||
|
lastError: text("last_error"),
|
||||||
|
packetsReceived: integer("packets_received").notNull().default(0),
|
||||||
|
peersJson: text("peers_json").notNull().default("[]"),
|
||||||
|
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||||
|
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const flowBuckets = sqliteTable("flow_buckets", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
serverId: integer("server_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => servers.id, { onDelete: "cascade" }),
|
||||||
|
bucketAt: text("bucket_at").notNull(),
|
||||||
|
src: text("src").notNull(),
|
||||||
|
dst: text("dst").notNull(),
|
||||||
|
proto: integer("proto").notNull().default(0),
|
||||||
|
srcPort: integer("src_port").notNull().default(0),
|
||||||
|
dstPort: integer("dst_port").notNull().default(0),
|
||||||
|
bytes: integer("bytes").notNull().default(0),
|
||||||
|
packets: integer("packets").notNull().default(0),
|
||||||
|
inIface: text("in_iface").notNull().default(""),
|
||||||
|
}, (t) => [
|
||||||
|
uniqueIndex("idx_flow_buckets_unique").on(
|
||||||
|
t.serverId, t.bucketAt, t.src, t.dst, t.proto, t.srcPort, t.dstPort, t.inIface,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
export const flowIpMeta = sqliteTable("flow_ip_meta", {
|
||||||
|
prefix: text("prefix").primaryKey(),
|
||||||
|
asn: integer("asn").notNull().default(0),
|
||||||
|
country: text("country").notNull().default(""),
|
||||||
|
lat: real("lat"),
|
||||||
|
lng: real("lng"),
|
||||||
|
holder: text("holder").notNull().default(""),
|
||||||
|
ok: integer("ok").notNull().default(1),
|
||||||
|
fetchedAt: text("fetched_at").notNull(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const flowAsnMeta = sqliteTable("flow_asn_meta", {
|
||||||
|
asn: integer("asn").primaryKey(),
|
||||||
|
holder: text("holder").notNull().default(""),
|
||||||
|
fetchedAt: text("fetched_at").notNull(),
|
||||||
|
})
|
||||||
|
|
||||||
export const trafficSamples = sqliteTable("traffic_samples", {
|
export const trafficSamples = sqliteTable("traffic_samples", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
serverId: integer("server_id")
|
serverId: integer("server_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => servers.id, { onDelete: "cascade" }),
|
.references(() => servers.id, { onDelete: "cascade" }),
|
||||||
interfaceName: text("interface_name").notNull(),
|
interfaceName: text("interface_name").notNull(),
|
||||||
|
peerPublicKey: text("peer_public_key").notNull().default(""),
|
||||||
sampledAt: text("sampled_at").notNull(),
|
sampledAt: text("sampled_at").notNull(),
|
||||||
rxBytes: integer("rx_bytes").notNull().default(0),
|
rxBytes: integer("rx_bytes").notNull().default(0),
|
||||||
txBytes: integer("tx_bytes").notNull().default(0),
|
txBytes: integer("tx_bytes").notNull().default(0),
|
||||||
@@ -540,6 +603,44 @@ export const internetPathSettings = sqliteTable("internet_path_settings", {
|
|||||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── app users (local catalog, not portal JWT) ────────────────────────────────
|
||||||
|
|
||||||
|
export const appUsers = sqliteTable("app_users", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
name: text("name").notNull().default(""),
|
||||||
|
login: text("login").notNull().unique(),
|
||||||
|
email: text("email").notNull().default(""),
|
||||||
|
role: text("role", { enum: ["admin", "operator", "viewer"] }).notNull().default("viewer"),
|
||||||
|
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
||||||
|
avatar: text("avatar").notNull().default(""),
|
||||||
|
lastSeen: text("last_seen"),
|
||||||
|
sectionsJson: text("sections_json").notNull().default("[]"),
|
||||||
|
serversJson: text("servers_json").notNull().default("[]"),
|
||||||
|
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||||
|
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const userInterfaceBindings = sqliteTable("user_interface_bindings", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => appUsers.id, { onDelete: "cascade" }),
|
||||||
|
serverId: integer("server_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => servers.id, { onDelete: "cascade" }),
|
||||||
|
interfaceName: text("interface_name").notNull(),
|
||||||
|
interfaceType: text("interface_type", { enum: ["ether", "gre", "wg", "other"] })
|
||||||
|
.notNull()
|
||||||
|
.default("other"),
|
||||||
|
peerPublicKey: text("peer_public_key").notNull().default(""),
|
||||||
|
peerName: text("peer_name").notNull().default(""),
|
||||||
|
comment: text("comment").notNull().default(""),
|
||||||
|
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||||
|
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||||
|
}, (t) => [
|
||||||
|
uniqueIndex("idx_user_iface_bind_server_name_peer").on(t.serverId, t.interfaceName, t.peerPublicKey),
|
||||||
|
])
|
||||||
|
|
||||||
export const internetPathSnapshots = sqliteTable("internet_path_snapshots", {
|
export const internetPathSnapshots = sqliteTable("internet_path_snapshots", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
sampledAt: text("sampled_at").notNull(),
|
sampledAt: text("sampled_at").notNull(),
|
||||||
@@ -555,6 +656,10 @@ export type SnapshotInsert = typeof serverSnapshots.$inferInsert
|
|||||||
export type FilterRuleRow = typeof filterRules.$inferSelect
|
export type FilterRuleRow = typeof filterRules.$inferSelect
|
||||||
export type RecursiveRouteRow = typeof recursiveRoutes.$inferSelect
|
export type RecursiveRouteRow = typeof recursiveRoutes.$inferSelect
|
||||||
export type TrafficSettingsRow = typeof trafficSettings.$inferSelect
|
export type TrafficSettingsRow = typeof trafficSettings.$inferSelect
|
||||||
|
export type TrafficFlowSettingsRow = typeof trafficFlowSettings.$inferSelect
|
||||||
|
export type FlowBucketRow = typeof flowBuckets.$inferSelect
|
||||||
|
export type FlowIpMetaRow = typeof flowIpMeta.$inferSelect
|
||||||
|
export type FlowAsnMetaRow = typeof flowAsnMeta.$inferSelect
|
||||||
export type ServersApiPingSettingsRow = typeof serversApiPingSettings.$inferSelect
|
export type ServersApiPingSettingsRow = typeof serversApiPingSettings.$inferSelect
|
||||||
export type TrafficSampleRow = typeof trafficSamples.$inferSelect
|
export type TrafficSampleRow = typeof trafficSamples.$inferSelect
|
||||||
export type UptimeSettingsRow = typeof uptimeSettings.$inferSelect
|
export type UptimeSettingsRow = typeof uptimeSettings.$inferSelect
|
||||||
@@ -582,3 +687,5 @@ export type AlertDestinationRow = typeof alertDestinations.$inferSelect
|
|||||||
export type AlertHistoryRow = typeof alertHistory.$inferSelect
|
export type AlertHistoryRow = typeof alertHistory.$inferSelect
|
||||||
export type AlertOutboxRow = typeof alertOutbox.$inferSelect
|
export type AlertOutboxRow = typeof alertOutbox.$inferSelect
|
||||||
export type AlertEngineCursorRow = typeof alertEngineCursor.$inferSelect
|
export type AlertEngineCursorRow = typeof alertEngineCursor.$inferSelect
|
||||||
|
export type AppUserRow = typeof appUsers.$inferSelect
|
||||||
|
export type UserInterfaceBindingRow = typeof userInterfaceBindings.$inferSelect
|
||||||
@@ -10,6 +10,7 @@ import execRoutes from "./routes/exec.js"
|
|||||||
import filtersRoutes from "./routes/filters.js"
|
import filtersRoutes from "./routes/filters.js"
|
||||||
import recursiveRoutes from "./routes/recursive-routes.js"
|
import recursiveRoutes from "./routes/recursive-routes.js"
|
||||||
import trafficRoutes from "./routes/traffic.js"
|
import trafficRoutes from "./routes/traffic.js"
|
||||||
|
import trafficFlowRoutes from "./routes/traffic-flow.js"
|
||||||
import serversApiPingRoutes from "./routes/servers-api-ping.js"
|
import serversApiPingRoutes from "./routes/servers-api-ping.js"
|
||||||
import uptimeRoutes from "./routes/uptime.js"
|
import uptimeRoutes from "./routes/uptime.js"
|
||||||
import networkRoutes from "./routes/network.js"
|
import networkRoutes from "./routes/network.js"
|
||||||
@@ -24,7 +25,10 @@ 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 usersRoutes from "./routes/users.js"
|
||||||
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
||||||
|
import { startTrafficFlowListener, stopTrafficFlowListener } from "./services/traffic-flow-ingest.js"
|
||||||
|
|
||||||
export async function buildApp(opts?: {
|
export async function buildApp(opts?: {
|
||||||
logger?: boolean
|
logger?: boolean
|
||||||
@@ -91,6 +95,7 @@ export async function buildApp(opts?: {
|
|||||||
await app.register(filtersRoutes, { prefix: "/api" })
|
await app.register(filtersRoutes, { prefix: "/api" })
|
||||||
await app.register(recursiveRoutes, { prefix: "/api" })
|
await app.register(recursiveRoutes, { prefix: "/api" })
|
||||||
await app.register(trafficRoutes, { prefix: "/api" })
|
await app.register(trafficRoutes, { prefix: "/api" })
|
||||||
|
await app.register(trafficFlowRoutes, { prefix: "/api" })
|
||||||
await app.register(serversApiPingRoutes, { prefix: "/api" })
|
await app.register(serversApiPingRoutes, { prefix: "/api" })
|
||||||
await app.register(uptimeRoutes, { prefix: "/api" })
|
await app.register(uptimeRoutes, { prefix: "/api" })
|
||||||
await app.register(networkRoutes, { prefix: "/api" })
|
await app.register(networkRoutes, { prefix: "/api" })
|
||||||
@@ -105,11 +110,15 @@ 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" })
|
||||||
|
await app.register(usersRoutes, { prefix: "/api" })
|
||||||
|
|
||||||
if (opts?.startScheduler !== false) {
|
if (opts?.startScheduler !== false) {
|
||||||
refreshScheduler()
|
refreshScheduler()
|
||||||
|
startTrafficFlowListener()
|
||||||
app.addHook("onClose", async () => {
|
app.addHook("onClose", async () => {
|
||||||
stopScheduler()
|
stopScheduler()
|
||||||
|
stopTrafficFlowListener()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,20 @@ 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("GET", "/api/users"),
|
||||||
|
"mm:users:read",
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
permissionForRequest("POST", "/api/users"),
|
||||||
|
"mm:users:write",
|
||||||
|
)
|
||||||
|
assert.equal(hasPermission(["mm:settings:admin"], "mm:users:read"), true)
|
||||||
|
assert.equal(hasPermission(["mm:settings:admin"], "mm:users:write"), true)
|
||||||
|
assert.equal(hasPermission(["mm:dashboard:read"], "mm:users:write"), false)
|
||||||
|
|
||||||
console.log("permissions.test.ts: ok")
|
console.log("permissions.test.ts: ok")
|
||||||
@@ -17,6 +17,9 @@ export function hasPermission(
|
|||||||
required: string,
|
required: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (granted.includes(required)) return true
|
if (granted.includes(required)) return true
|
||||||
|
if (required.startsWith("mm:users:") && granted.includes("mm:settings:admin")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
const parts = required.split(":")
|
const parts = required.split(":")
|
||||||
if (parts.length !== 3) return false
|
if (parts.length !== 3) return false
|
||||||
const [app, section, action] = parts
|
const [app, section, action] = parts
|
||||||
@@ -49,8 +52,17 @@ const RULES: Rule[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
methods: ["GET"],
|
methods: ["GET"],
|
||||||
match: (p) =>
|
match: (p) => p.startsWith("/api/users"),
|
||||||
p.startsWith("/api/sidebar-counts") || p.startsWith("/api/events"),
|
permission: "mm:users:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) => p.startsWith("/api/users"),
|
||||||
|
permission: "mm:users:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) => p.startsWith("/api/sidebar-counts") || p.startsWith("/api/events"),
|
||||||
permission: "mm:dashboard:read",
|
permission: "mm:dashboard:read",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -142,7 +154,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 +167,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,90 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import Database from "better-sqlite3"
|
||||||
|
import { normalizeBindingPeer, PeerBindError } from "./peer-bind.js"
|
||||||
|
|
||||||
|
const sqlite = new Database(":memory:")
|
||||||
|
sqlite.pragma("foreign_keys = ON")
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE servers (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
host TEXT NOT NULL DEFAULT '127.0.0.1'
|
||||||
|
);
|
||||||
|
CREATE TABLE app_users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
login TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT NOT NULL DEFAULT '',
|
||||||
|
role TEXT NOT NULL DEFAULT 'viewer',
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
avatar TEXT NOT NULL DEFAULT '',
|
||||||
|
last_seen TEXT,
|
||||||
|
sections_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
servers_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE user_interface_bindings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
server_id INTEGER NOT NULL,
|
||||||
|
interface_name TEXT NOT NULL,
|
||||||
|
interface_type TEXT NOT NULL DEFAULT 'other',
|
||||||
|
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||||
|
peer_name TEXT NOT NULL DEFAULT '',
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE (server_id, interface_name, peer_public_key)
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
|
||||||
|
sqlite.prepare("INSERT INTO servers (id, name, host) VALUES (1, 'jh', '10.0.0.1')").run()
|
||||||
|
sqlite.prepare("INSERT INTO app_users (id, name, login) VALUES ('u1', 'A', 'a.user')").run()
|
||||||
|
sqlite.prepare("INSERT INTO app_users (id, name, login) VALUES ('u2', 'B', 'b.user')").run()
|
||||||
|
sqlite.prepare(`
|
||||||
|
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
|
||||||
|
VALUES ('b1', 'u1', 1, 'gre-office', 'gre')
|
||||||
|
`).run()
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => sqlite.prepare(`
|
||||||
|
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
|
||||||
|
VALUES ('b2', 'u2', 1, 'gre-office', 'gre')
|
||||||
|
`).run(),
|
||||||
|
/UNIQUE/i,
|
||||||
|
"один интерфейс на сервере — один пользователь",
|
||||||
|
)
|
||||||
|
|
||||||
|
sqlite.prepare(`
|
||||||
|
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
|
||||||
|
VALUES ('wg1', 'u1', 1, 'wg-server', 'wg', 'peer-key-aaa', 'phone')
|
||||||
|
`).run()
|
||||||
|
sqlite.prepare(`
|
||||||
|
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
|
||||||
|
VALUES ('wg2', 'u2', 1, 'wg-server', 'wg', 'peer-key-bbb', 'laptop')
|
||||||
|
`).run()
|
||||||
|
assert.throws(
|
||||||
|
() => sqlite.prepare(`
|
||||||
|
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key)
|
||||||
|
VALUES ('wg3', 'u2', 1, 'wg-server', 'wg', 'peer-key-aaa')
|
||||||
|
`).run(),
|
||||||
|
/UNIQUE/i,
|
||||||
|
"один пир — один пользователь",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeBindingPeer("wg", ""),
|
||||||
|
(err: unknown) => err instanceof PeerBindError && err.status === 400,
|
||||||
|
"WG без ключа — 400",
|
||||||
|
)
|
||||||
|
assert.equal(normalizeBindingPeer("ether", "ignored"), "")
|
||||||
|
assert.equal(normalizeBindingPeer("wg", " abc "), "abc")
|
||||||
|
|
||||||
|
sqlite.prepare("DELETE FROM app_users WHERE id = 'u1'").run()
|
||||||
|
const leftover = sqlite.prepare("SELECT COUNT(*) AS n FROM user_interface_bindings").get() as { n: number }
|
||||||
|
assert.equal(leftover.n, 1, "каскад: привязки u1 удаляются, пир u2 остаётся")
|
||||||
|
|
||||||
|
console.log("users bindings unique+cascade tests ok")
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { mapRosInterfaceType, parseRawInterfaces, isUniqueConstraintError } from "./iface-type.js"
|
||||||
|
|
||||||
|
assert.equal(mapRosInterfaceType("ether"), "ether")
|
||||||
|
assert.equal(mapRosInterfaceType("ethernet"), "ether")
|
||||||
|
assert.equal(mapRosInterfaceType("GRE"), "gre")
|
||||||
|
assert.equal(mapRosInterfaceType("gre-tunnel"), "gre")
|
||||||
|
assert.equal(mapRosInterfaceType("gre6-tunnel"), "gre")
|
||||||
|
assert.equal(mapRosInterfaceType("wg"), "wg")
|
||||||
|
assert.equal(mapRosInterfaceType("wireguard"), "wg")
|
||||||
|
assert.equal(mapRosInterfaceType("vlan"), "other")
|
||||||
|
assert.equal(mapRosInterfaceType(""), "other")
|
||||||
|
assert.equal(mapRosInterfaceType("", "gre-tunnel1"), "gre")
|
||||||
|
assert.equal(mapRosInterfaceType("", "MSK-DC"), "other")
|
||||||
|
assert.equal(mapRosInterfaceType("gre-tunnel", "MSK-DC"), "gre")
|
||||||
|
assert.equal(mapRosInterfaceType("", "wg-msk-spb"), "wg")
|
||||||
|
assert.equal(mapRosInterfaceType("", "ether1"), "ether")
|
||||||
|
|
||||||
|
const parsed = parseRawInterfaces(JSON.stringify([
|
||||||
|
{ name: "ether1", type: "ether", running: "true", disabled: "false" },
|
||||||
|
{ name: "gre-office", type: "gre-tunnel", running: "false", disabled: "false" },
|
||||||
|
{ name: "wg-msk", type: "wg", running: true, disabled: false },
|
||||||
|
{ name: "MSK-DC", type: "gre-tunnel", running: true, disabled: false },
|
||||||
|
{ name: "", type: "ether" },
|
||||||
|
]))
|
||||||
|
assert.equal(parsed.length, 4)
|
||||||
|
assert.equal(parsed[0]?.type, "ether")
|
||||||
|
assert.equal(parsed[0]?.running, true)
|
||||||
|
assert.equal(parsed[1]?.type, "gre")
|
||||||
|
assert.equal(parsed[1]?.running, false)
|
||||||
|
assert.equal(parsed[2]?.type, "wg")
|
||||||
|
assert.equal(parsed[3]?.type, "gre")
|
||||||
|
|
||||||
|
assert.equal(parseRawInterfaces("not-json").length, 0)
|
||||||
|
assert.equal(parseRawInterfaces(null).length, 0)
|
||||||
|
|
||||||
|
assert.equal(isUniqueConstraintError({ code: "SQLITE_CONSTRAINT_UNIQUE", message: "UNIQUE" }), true)
|
||||||
|
assert.equal(isUniqueConstraintError({ message: "UNIQUE constraint failed: t.c" }), true)
|
||||||
|
assert.equal(isUniqueConstraintError({ message: "other" }), false)
|
||||||
|
|
||||||
|
console.log("users iface-type tests ok")
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
export type InterfaceType = "ether" | "gre" | "wg" | "other"
|
||||||
|
|
||||||
|
export function mapRosInterfaceType(raw: string | undefined | null, name?: string): InterfaceType {
|
||||||
|
const t = String(raw ?? "").trim().toLowerCase()
|
||||||
|
if (t === "ether" || t === "ethernet" || t.startsWith("ether")) return "ether"
|
||||||
|
// RouterOS /interface type for GRE is "gre-tunnel" (also gre, gre6, gre6-tunnel)
|
||||||
|
if (t === "gre" || t.startsWith("gre-") || t.startsWith("gre6")) return "gre"
|
||||||
|
if (t === "wg" || t === "wireguard") return "wg"
|
||||||
|
|
||||||
|
const n = String(name ?? "").trim().toLowerCase()
|
||||||
|
if (n.startsWith("gre") || n.includes("gre-tunnel")) return "gre"
|
||||||
|
if (n.startsWith("wg-") || n.startsWith("wireguard")) return "wg"
|
||||||
|
if (n.startsWith("ether") || n.startsWith("sfp")) return "ether"
|
||||||
|
return "other"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedRosIface {
|
||||||
|
name: string
|
||||||
|
type: InterfaceType
|
||||||
|
running: boolean
|
||||||
|
disabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function asBool(raw: unknown): boolean {
|
||||||
|
if (typeof raw === "boolean") return raw
|
||||||
|
const s = String(raw ?? "").trim().toLowerCase()
|
||||||
|
return s === "true" || s === "yes" || s === "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRawInterfaces(json: string | null | undefined): ParsedRosIface[] {
|
||||||
|
if (!json) return []
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(json) as unknown
|
||||||
|
const arr = Array.isArray(parsed) ? parsed : []
|
||||||
|
const out: ParsedRosIface[] = []
|
||||||
|
for (const item of arr) {
|
||||||
|
if (!item || typeof item !== "object") continue
|
||||||
|
const rec = item as Record<string, unknown>
|
||||||
|
const name = String(rec.name ?? "").trim()
|
||||||
|
if (!name) continue
|
||||||
|
out.push({
|
||||||
|
name,
|
||||||
|
type: mapRosInterfaceType(String(rec.type ?? ""), name),
|
||||||
|
running: asBool(rec.running),
|
||||||
|
disabled: asBool(rec.disabled),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUniqueConstraintError(err: unknown): boolean {
|
||||||
|
if (!err || typeof err !== "object") return false
|
||||||
|
const rec = err as { code?: unknown; message?: unknown }
|
||||||
|
const code = String(rec.code ?? "")
|
||||||
|
const msg = String(rec.message ?? "")
|
||||||
|
return code.includes("SQLITE_CONSTRAINT") || /unique constraint/i.test(msg)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { InterfaceType } from "./iface-type.js"
|
||||||
|
|
||||||
|
export class PeerBindError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly status: number,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = "PeerBindError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncPeerKey(key: string): string {
|
||||||
|
const k = key.trim()
|
||||||
|
if (k.length <= 20) return k
|
||||||
|
return `${k.slice(0, 8)}…${k.slice(-8)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function peerDisplayName(opts: {
|
||||||
|
publicKey: string
|
||||||
|
name?: string | null
|
||||||
|
comment?: string | null
|
||||||
|
}): string {
|
||||||
|
const name = (opts.name ?? "").trim()
|
||||||
|
if (name) return name
|
||||||
|
const comment = (opts.comment ?? "").trim()
|
||||||
|
if (comment) return comment
|
||||||
|
return truncPeerKey(opts.publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ether/GRE — пустой ключ. WG — обязательный public-key. */
|
||||||
|
export function normalizeBindingPeer(
|
||||||
|
type: InterfaceType,
|
||||||
|
peerPublicKey: string | undefined,
|
||||||
|
): string {
|
||||||
|
const key = (peerPublicKey ?? "").trim()
|
||||||
|
if (type === "wg") {
|
||||||
|
if (!key) {
|
||||||
|
throw new PeerBindError("Для WireGuard укажите пир (public-key)", 400)
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { and, eq } from "drizzle-orm"
|
||||||
|
import { db } from "../../../db/index.js"
|
||||||
|
import { appUsers, userInterfaceBindings } from "../../../db/schema.js"
|
||||||
|
|
||||||
|
export type AppUserRow = typeof appUsers.$inferSelect
|
||||||
|
export type BindingRow = typeof userInterfaceBindings.$inferSelect
|
||||||
|
|
||||||
|
export function listUserRows(): AppUserRow[] {
|
||||||
|
return db.select().from(appUsers).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserRowById(id: string): AppUserRow | undefined {
|
||||||
|
return db.select().from(appUsers).where(eq(appUsers.id, id)).limit(1).all()[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserRowByLogin(login: string): AppUserRow | undefined {
|
||||||
|
return db.select().from(appUsers).where(eq(appUsers.login, login)).limit(1).all()[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUserRow(values: typeof appUsers.$inferInsert): AppUserRow {
|
||||||
|
const [inserted] = db.insert(appUsers).values(values).returning().all()
|
||||||
|
return inserted
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUserRowById(
|
||||||
|
id: string,
|
||||||
|
values: Partial<AppUserRow>,
|
||||||
|
): AppUserRow {
|
||||||
|
const [updated] = db.update(appUsers).set(values).where(eq(appUsers.id, id)).returning().all()
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteUserRowById(id: string): void {
|
||||||
|
db.delete(appUsers).where(eq(appUsers.id, id)).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBindingRows(): BindingRow[] {
|
||||||
|
return db.select().from(userInterfaceBindings).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBindingRowsByUser(userId: string): BindingRow[] {
|
||||||
|
return db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId)).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBindingRowById(id: string): BindingRow | undefined {
|
||||||
|
return db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).limit(1).all()[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBindingByServerIfacePeer(
|
||||||
|
serverId: number,
|
||||||
|
interfaceName: string,
|
||||||
|
peerPublicKey = "",
|
||||||
|
): BindingRow | undefined {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(userInterfaceBindings)
|
||||||
|
.where(and(
|
||||||
|
eq(userInterfaceBindings.serverId, serverId),
|
||||||
|
eq(userInterfaceBindings.interfaceName, interfaceName),
|
||||||
|
eq(userInterfaceBindings.peerPublicKey, peerPublicKey),
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
.all()[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBindingRow(values: typeof userInterfaceBindings.$inferInsert): BindingRow {
|
||||||
|
const [inserted] = db.insert(userInterfaceBindings).values(values).returning().all()
|
||||||
|
return inserted
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBindingRowById(id: string): void {
|
||||||
|
db.delete(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countUserRows(): number {
|
||||||
|
return db.select().from(appUsers).all().length
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import { randomUUID } from "node:crypto"
|
||||||
|
import { desc, eq } from "drizzle-orm"
|
||||||
|
import type {
|
||||||
|
AppUserCreate,
|
||||||
|
AppUserRead,
|
||||||
|
AppUserUpdate,
|
||||||
|
CatalogInterface,
|
||||||
|
InterfaceType,
|
||||||
|
SectionPerm,
|
||||||
|
ServerPerm,
|
||||||
|
UserBinding,
|
||||||
|
UserBindingCreate,
|
||||||
|
} from "@mmapp/contracts/users"
|
||||||
|
import { db } from "../../../db/index.js"
|
||||||
|
import { servers, trafficSamples } from "../../../db/schema.js"
|
||||||
|
import {
|
||||||
|
createBindingRow,
|
||||||
|
createUserRow,
|
||||||
|
deleteBindingRowById,
|
||||||
|
deleteUserRowById,
|
||||||
|
getBindingByServerIfacePeer,
|
||||||
|
getBindingRowById,
|
||||||
|
getUserRowById,
|
||||||
|
getUserRowByLogin,
|
||||||
|
listBindingRows,
|
||||||
|
listBindingRowsByUser,
|
||||||
|
listUserRows,
|
||||||
|
updateUserRowById,
|
||||||
|
type AppUserRow,
|
||||||
|
type BindingRow,
|
||||||
|
} from "../repository/users-repository.js"
|
||||||
|
import { getLatestSnapshot } from "../../servers/repository/servers-repository.js"
|
||||||
|
import {
|
||||||
|
isUniqueConstraintError,
|
||||||
|
mapRosInterfaceType,
|
||||||
|
parseRawInterfaces,
|
||||||
|
} from "../iface-type.js"
|
||||||
|
import {
|
||||||
|
normalizeBindingPeer,
|
||||||
|
PeerBindError,
|
||||||
|
peerDisplayName,
|
||||||
|
} from "../peer-bind.js"
|
||||||
|
import { listWireGuardPeersForCatalog } from "../../../services/wireguard-live.js"
|
||||||
|
|
||||||
|
export class UsersServiceError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly status: number,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = "UsersServiceError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonArray<T>(raw: string, fallback: T[]): T[] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
return Array.isArray(parsed) ? (parsed as T[]) : fallback
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initials(name: string): string {
|
||||||
|
const parts = name.trim().split(/\s+/).filter(Boolean)
|
||||||
|
return parts.map((p) => p[0] ?? "").slice(0, 2).join("").toUpperCase() || "??"
|
||||||
|
}
|
||||||
|
|
||||||
|
function serverMeta(serverId: number): { name: string; site: string; country: string } {
|
||||||
|
const row = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||||
|
return {
|
||||||
|
name: row?.name || row?.host || String(serverId),
|
||||||
|
site: row?.site || "—",
|
||||||
|
country: row?.country || "UN",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBindingDto(row: BindingRow): UserBinding {
|
||||||
|
const meta = serverMeta(row.serverId)
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.userId,
|
||||||
|
serverId: row.serverId,
|
||||||
|
serverName: meta.name,
|
||||||
|
serverSite: meta.site,
|
||||||
|
serverCountry: meta.country,
|
||||||
|
interfaceName: row.interfaceName,
|
||||||
|
interfaceType: row.interfaceType,
|
||||||
|
peerPublicKey: row.peerPublicKey ?? "",
|
||||||
|
peerName: row.peerName ?? "",
|
||||||
|
comment: row.comment,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUserDto(row: AppUserRow, bindings: BindingRow[]): AppUserRead {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
login: row.login,
|
||||||
|
email: row.email,
|
||||||
|
role: row.role,
|
||||||
|
active: Boolean(row.active),
|
||||||
|
avatar: row.avatar,
|
||||||
|
lastSeen: row.lastSeen ?? null,
|
||||||
|
sections: parseJsonArray<SectionPerm>(row.sectionsJson, []),
|
||||||
|
servers: parseJsonArray<ServerPerm>(row.serversJson, []),
|
||||||
|
bindings: bindings.map(toBindingDto),
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listUsers(): AppUserRead[] {
|
||||||
|
const users = listUserRows()
|
||||||
|
const allBindings = listBindingRows()
|
||||||
|
const byUser = new Map<string, BindingRow[]>()
|
||||||
|
for (const b of allBindings) {
|
||||||
|
const arr = byUser.get(b.userId) ?? []
|
||||||
|
arr.push(b)
|
||||||
|
byUser.set(b.userId, arr)
|
||||||
|
}
|
||||||
|
return users.map((u) => toUserDto(u, byUser.get(u.id) ?? []))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserById(id: string): AppUserRead | undefined {
|
||||||
|
const row = getUserRowById(id)
|
||||||
|
if (!row) return undefined
|
||||||
|
return toUserDto(row, listBindingRowsByUser(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUser(input: AppUserCreate): AppUserRead {
|
||||||
|
const login = input.login.trim()
|
||||||
|
if (getUserRowByLogin(login)) {
|
||||||
|
throw new UsersServiceError("Логин уже занят", 409)
|
||||||
|
}
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const row = createUserRow({
|
||||||
|
id: randomUUID(),
|
||||||
|
name: input.name.trim(),
|
||||||
|
login,
|
||||||
|
email: input.email.trim(),
|
||||||
|
role: input.role ?? "viewer",
|
||||||
|
active: input.active ?? true,
|
||||||
|
avatar: (input.avatar ?? "").trim() || initials(input.name),
|
||||||
|
lastSeen: input.lastSeen ?? null,
|
||||||
|
sectionsJson: JSON.stringify(input.sections ?? []),
|
||||||
|
serversJson: JSON.stringify(input.servers ?? []),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
return toUserDto(row, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUser(id: string, input: AppUserUpdate): AppUserRead {
|
||||||
|
const existing = getUserRowById(id)
|
||||||
|
if (!existing) throw new UsersServiceError("Пользователь не найден", 404)
|
||||||
|
if (input.login != null) {
|
||||||
|
const other = getUserRowByLogin(input.login.trim())
|
||||||
|
if (other && other.id !== id) throw new UsersServiceError("Логин уже занят", 409)
|
||||||
|
}
|
||||||
|
const patch: Partial<AppUserRow> = { updatedAt: new Date().toISOString() }
|
||||||
|
if (input.name != null) patch.name = input.name.trim()
|
||||||
|
if (input.login != null) patch.login = input.login.trim()
|
||||||
|
if (input.email != null) patch.email = input.email.trim()
|
||||||
|
if (input.role != null) patch.role = input.role
|
||||||
|
if (input.active != null) patch.active = input.active
|
||||||
|
if (input.avatar != null) patch.avatar = input.avatar.trim() || existing.avatar
|
||||||
|
if (input.lastSeen !== undefined) patch.lastSeen = input.lastSeen
|
||||||
|
if (input.sections != null) patch.sectionsJson = JSON.stringify(input.sections)
|
||||||
|
if (input.servers != null) patch.serversJson = JSON.stringify(input.servers)
|
||||||
|
const updated = updateUserRowById(id, patch)
|
||||||
|
return toUserDto(updated, listBindingRowsByUser(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteUser(id: string): void {
|
||||||
|
const existing = getUserRowById(id)
|
||||||
|
if (!existing) throw new UsersServiceError("Пользователь не найден", 404)
|
||||||
|
deleteUserRowById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addBinding(userId: string, input: UserBindingCreate): UserBinding {
|
||||||
|
const user = getUserRowById(userId)
|
||||||
|
if (!user) throw new UsersServiceError("Пользователь не найден", 404)
|
||||||
|
const server = db.select().from(servers).where(eq(servers.id, input.serverId)).limit(1).all()[0]
|
||||||
|
if (!server) throw new UsersServiceError("Сервер не найден", 404)
|
||||||
|
const ifaceName = input.interfaceName.trim()
|
||||||
|
if (!ifaceName) throw new UsersServiceError("Имя интерфейса обязательно", 400)
|
||||||
|
const type: InterfaceType = input.interfaceType ?? inferIfaceType(input.serverId, ifaceName)
|
||||||
|
let peerPublicKey = ""
|
||||||
|
try {
|
||||||
|
peerPublicKey = normalizeBindingPeer(type, input.peerPublicKey)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof PeerBindError) throw new UsersServiceError(err.message, err.status)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
const peerName = type === "wg"
|
||||||
|
? peerDisplayName({
|
||||||
|
publicKey: peerPublicKey,
|
||||||
|
name: input.peerName,
|
||||||
|
})
|
||||||
|
: ""
|
||||||
|
const taken = getBindingByServerIfacePeer(input.serverId, ifaceName, peerPublicKey)
|
||||||
|
if (taken) {
|
||||||
|
throw new UsersServiceError(
|
||||||
|
type === "wg"
|
||||||
|
? "Этот пир уже привязан к другому пользователю"
|
||||||
|
: "Интерфейс уже привязан к другому пользователю",
|
||||||
|
409,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
try {
|
||||||
|
const row = createBindingRow({
|
||||||
|
id: randomUUID(),
|
||||||
|
userId,
|
||||||
|
serverId: input.serverId,
|
||||||
|
interfaceName: ifaceName,
|
||||||
|
interfaceType: type,
|
||||||
|
peerPublicKey,
|
||||||
|
peerName,
|
||||||
|
comment: (input.comment ?? "").trim(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
return toBindingDto(row)
|
||||||
|
} catch (err) {
|
||||||
|
if (isUniqueConstraintError(err)) {
|
||||||
|
throw new UsersServiceError(
|
||||||
|
type === "wg"
|
||||||
|
? "Этот пир уже привязан к другому пользователю"
|
||||||
|
: "Интерфейс уже привязан к другому пользователю",
|
||||||
|
409,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeBinding(userId: string, bindingId: string): void {
|
||||||
|
const row = getBindingRowById(bindingId)
|
||||||
|
if (!row || row.userId !== userId) {
|
||||||
|
throw new UsersServiceError("Привязка не найдена", 404)
|
||||||
|
}
|
||||||
|
deleteBindingRowById(bindingId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferIfaceType(serverId: number, ifaceName: string): InterfaceType {
|
||||||
|
const snap = getLatestSnapshot(serverId)
|
||||||
|
const parsed = parseRawInterfaces(snap?.rawInterfaces)
|
||||||
|
const found = parsed.find((i) => i.name === ifaceName)
|
||||||
|
return found?.type ?? "other"
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listInterfaceCatalog(serverId: number): Promise<CatalogInterface[]> {
|
||||||
|
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||||
|
if (!server) throw new UsersServiceError("Сервер не найден", 404)
|
||||||
|
|
||||||
|
const snap = getLatestSnapshot(serverId)
|
||||||
|
let ifaces = parseRawInterfaces(snap?.rawInterfaces)
|
||||||
|
if (ifaces.length === 0) {
|
||||||
|
const last = db
|
||||||
|
.select({ sampledAt: trafficSamples.sampledAt })
|
||||||
|
.from(trafficSamples)
|
||||||
|
.where(eq(trafficSamples.serverId, serverId))
|
||||||
|
.orderBy(desc(trafficSamples.sampledAt))
|
||||||
|
.limit(1)
|
||||||
|
.all()[0]
|
||||||
|
if (last) {
|
||||||
|
const rows = db
|
||||||
|
.select({
|
||||||
|
interfaceName: trafficSamples.interfaceName,
|
||||||
|
peerPublicKey: trafficSamples.peerPublicKey,
|
||||||
|
running: trafficSamples.running,
|
||||||
|
disabled: trafficSamples.disabled,
|
||||||
|
})
|
||||||
|
.from(trafficSamples)
|
||||||
|
.where(eq(trafficSamples.serverId, serverId))
|
||||||
|
.all()
|
||||||
|
.filter((r) => r.interfaceName && !/^(lo|loopback)/i.test(r.interfaceName) && !(r.peerPublicKey ?? ""))
|
||||||
|
const seen = new Set<string>()
|
||||||
|
ifaces = []
|
||||||
|
for (const r of rows) {
|
||||||
|
if (seen.has(r.interfaceName)) continue
|
||||||
|
seen.add(r.interfaceName)
|
||||||
|
ifaces.push({
|
||||||
|
name: r.interfaceName,
|
||||||
|
type: mapRosInterfaceType("", r.interfaceName),
|
||||||
|
running: Boolean(r.running),
|
||||||
|
disabled: Boolean(r.disabled),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindings = listBindingRows().filter((b) => b.serverId === serverId)
|
||||||
|
const usersById = new Map(listUserRows().map((u) => [u.id, u]))
|
||||||
|
const hasWg = ifaces.some((i) => i.type === "wg")
|
||||||
|
const wgLive = hasWg
|
||||||
|
? await listWireGuardPeersForCatalog(serverId)
|
||||||
|
: { peers: [] as Awaited<ReturnType<typeof listWireGuardPeersForCatalog>>["peers"] }
|
||||||
|
const peersByIface = new Map<string, typeof wgLive.peers>()
|
||||||
|
for (const peer of wgLive.peers) {
|
||||||
|
const list = peersByIface.get(peer.interfaceName) ?? []
|
||||||
|
list.push(peer)
|
||||||
|
peersByIface.set(peer.interfaceName, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ifaces.map((iface) => {
|
||||||
|
const ifaceBind = bindings.find((b) => b.interfaceName === iface.name && !(b.peerPublicKey ?? ""))
|
||||||
|
const owner = ifaceBind ? usersById.get(ifaceBind.userId) : undefined
|
||||||
|
const base: CatalogInterface = {
|
||||||
|
name: iface.name,
|
||||||
|
type: iface.type,
|
||||||
|
running: iface.running,
|
||||||
|
disabled: iface.disabled,
|
||||||
|
boundUserId: ifaceBind?.userId ?? null,
|
||||||
|
boundUserLogin: owner?.login ?? null,
|
||||||
|
}
|
||||||
|
if (iface.type !== "wg") return base
|
||||||
|
const livePeers = peersByIface.get(iface.name) ?? []
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
peersError: wgLive.error,
|
||||||
|
peers: livePeers.map((p) => {
|
||||||
|
const bind = bindings.find((b) => b.interfaceName === iface.name && b.peerPublicKey === p.publicKey)
|
||||||
|
const peerOwner = bind ? usersById.get(bind.userId) : undefined
|
||||||
|
return {
|
||||||
|
publicKey: p.publicKey,
|
||||||
|
name: peerDisplayName({ publicKey: p.publicKey, name: p.name, comment: p.comment }),
|
||||||
|
comment: p.comment,
|
||||||
|
allowedIps: p.allowedIps,
|
||||||
|
latestHandshake: p.latestHandshake,
|
||||||
|
boundUserId: bind?.userId ?? null,
|
||||||
|
boundUserLogin: peerOwner?.login ?? null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
export { parseRawInterfaces, mapRosInterfaceType }
|
||||||
@@ -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
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
uptimeProbes,
|
uptimeProbes,
|
||||||
uptimeSpeedProbes,
|
uptimeSpeedProbes,
|
||||||
} from "../db/schema.js"
|
} from "../db/schema.js"
|
||||||
|
import { listUsers } from "../modules/users/service/users-service.js"
|
||||||
|
|
||||||
const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
app.get("/sidebar-counts", async (_req, reply) => {
|
app.get("/sidebar-counts", async (_req, reply) => {
|
||||||
@@ -20,6 +21,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
recursiveRoutesTotal,
|
recursiveRoutesTotal,
|
||||||
certificatesTotal,
|
certificatesTotal,
|
||||||
wireguardTotal,
|
wireguardTotal,
|
||||||
|
usersTotal,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
Promise.resolve(db.select().from(servers).all().length),
|
Promise.resolve(db.select().from(servers).all().length),
|
||||||
Promise.resolve(db.select().from(filterRules).all().length),
|
Promise.resolve(db.select().from(filterRules).all().length),
|
||||||
@@ -28,6 +30,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
Promise.resolve(db.select().from(recursiveRoutes).all().length),
|
Promise.resolve(db.select().from(recursiveRoutes).all().length),
|
||||||
listCertificatesFromServers().then((res) => res.certificates.length),
|
listCertificatesFromServers().then((res) => res.certificates.length),
|
||||||
countWireGuardInterfaces().catch(() => 0),
|
countWireGuardInterfaces().catch(() => 0),
|
||||||
|
Promise.resolve(listUsers().length),
|
||||||
])
|
])
|
||||||
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
@@ -39,6 +42,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
recursiveRoutes: recursiveRoutesTotal,
|
recursiveRoutes: recursiveRoutesTotal,
|
||||||
certificates: certificatesTotal,
|
certificates: certificatesTotal,
|
||||||
wireguard: wireguardTotal,
|
wireguard: wireguardTotal,
|
||||||
|
users: usersTotal,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
|
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||||
|
import { env } from "../config.js"
|
||||||
|
import {
|
||||||
|
trafficFlowOverlayRequestSchema,
|
||||||
|
trafficFlowSettingsPatchSchema,
|
||||||
|
} from "@mmapp/contracts/traffic-flow"
|
||||||
|
import {
|
||||||
|
ensureHostKeys,
|
||||||
|
getTrafficFlowSettingsRow,
|
||||||
|
toTrafficFlowSettingsDto,
|
||||||
|
updateTrafficFlowSettings,
|
||||||
|
} from "../services/traffic-flow-settings.js"
|
||||||
|
import {
|
||||||
|
getFlowListenerState,
|
||||||
|
startTrafficFlowListener,
|
||||||
|
listFlowTalkers,
|
||||||
|
} from "../services/traffic-flow-ingest.js"
|
||||||
|
import {
|
||||||
|
buildFlowAnalytics,
|
||||||
|
listFlowClients,
|
||||||
|
listFlowExporters,
|
||||||
|
} from "../services/traffic-flow-analytics.js"
|
||||||
|
import { applyFlowOverlay } from "../services/traffic-flow-overlay.js"
|
||||||
|
import { listTrafficFlowHostFiles } from "../services/traffic-flow-host-files.js"
|
||||||
|
|
||||||
|
const LIVE_TICK_MS = 2000
|
||||||
|
|
||||||
|
function rangeToMinutes(range: string | undefined): number {
|
||||||
|
switch ((range ?? "5m").toLowerCase()) {
|
||||||
|
case "5m": return 5
|
||||||
|
case "15m": return 15
|
||||||
|
case "1h": return 60
|
||||||
|
case "4h": return 240
|
||||||
|
case "24h": return 1440
|
||||||
|
default: return 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseId(raw: unknown): number | undefined {
|
||||||
|
if (raw == null || raw === "") return undefined
|
||||||
|
const n = Number.parseInt(String(raw), 10)
|
||||||
|
return Number.isFinite(n) ? n : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDedup(raw: unknown): boolean {
|
||||||
|
if (raw == null || raw === "") return true
|
||||||
|
const s = String(raw).toLowerCase()
|
||||||
|
return s !== "0" && s !== "false" && s !== "off"
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyticsQuery(req: FastifyRequest) {
|
||||||
|
const q = req.query as { range?: string; serverId?: string; userId?: string; iface?: string; dedup?: string }
|
||||||
|
return {
|
||||||
|
minutes: rangeToMinutes(q.range),
|
||||||
|
serverId: parseId(q.serverId),
|
||||||
|
userId: q.userId?.trim() || undefined,
|
||||||
|
iface: q.iface?.trim() || undefined,
|
||||||
|
dedup: parseDedup(q.dedup),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendFlowTalkers(req: FastifyRequest, reply: FastifyReply) {
|
||||||
|
const q = req.query as { range?: string }
|
||||||
|
return reply.send(listFlowTalkers(rangeToMinutes(q.range)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestPublicHost(req: FastifyRequest): string {
|
||||||
|
const forwarded = req.headers["x-forwarded-host"]
|
||||||
|
const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded
|
||||||
|
return raw || req.hostname || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyOverlayHandler(req: FastifyRequest, reply: FastifyReply) {
|
||||||
|
const parsed = trafficFlowOverlayRequestSchema.safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await applyFlowOverlay(parsed.data.serverId, {
|
||||||
|
publicEndpoint: parsed.data.publicEndpoint,
|
||||||
|
requestHost: requestPublicHost(req),
|
||||||
|
})
|
||||||
|
return reply.send(result)
|
||||||
|
} catch (e) {
|
||||||
|
const status = (e as { statusCode?: number }).statusCode ?? 502
|
||||||
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
|
return reply.status(status).send({ error: msg })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
|
app.get("/traffic/flow/settings", async (_req, reply) => {
|
||||||
|
return reply.send(toTrafficFlowSettingsDto(getFlowListenerState()))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put("/traffic/flow/settings", async (req, reply) => {
|
||||||
|
const parsed = trafficFlowSettingsPatchSchema.safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
updateTrafficFlowSettings(parsed.data)
|
||||||
|
startTrafficFlowListener()
|
||||||
|
return reply.send({ ok: true, settings: toTrafficFlowSettingsDto(getFlowListenerState()) })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/traffic/flow/settings/generate-keys", async (_req, reply) => {
|
||||||
|
const result = ensureHostKeys()
|
||||||
|
return reply.send({
|
||||||
|
ok: true,
|
||||||
|
created: result.created,
|
||||||
|
publicKey: result.publicKey,
|
||||||
|
settings: toTrafficFlowSettingsDto(getFlowListenerState()),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/traffic/flow/host-files", async (_req, reply) => {
|
||||||
|
const row = getTrafficFlowSettingsRow()
|
||||||
|
if (!row.hostPrivateKey) ensureHostKeys()
|
||||||
|
return reply.send({ files: listTrafficFlowHostFiles() })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/traffic/flow/overlay", applyOverlayHandler)
|
||||||
|
app.post("/traffic/flow-overlay", applyOverlayHandler)
|
||||||
|
|
||||||
|
app.get("/traffic/flow", sendFlowTalkers)
|
||||||
|
app.get("/traffic/flows", sendFlowTalkers)
|
||||||
|
|
||||||
|
app.get("/traffic/flow/exporters", async (req, reply) => {
|
||||||
|
const q = req.query as { range?: string }
|
||||||
|
return reply.send(listFlowExporters(rangeToMinutes(q.range)))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/traffic/flow/clients", async (req, reply) => {
|
||||||
|
const q = req.query as { range?: string }
|
||||||
|
return reply.send(listFlowClients(rangeToMinutes(q.range)))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/traffic/flow/analytics", async (req, reply) => {
|
||||||
|
return reply.send(buildFlowAnalytics(analyticsQuery(req)))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/traffic/flow/live", async (req, reply) => {
|
||||||
|
const query = analyticsQuery(req)
|
||||||
|
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")
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (!abort.signal.aborted) {
|
||||||
|
writeSse(reply.raw, "sample", buildFlowAnalytics(query))
|
||||||
|
await sleep(LIVE_TICK_MS, abort.signal)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* abort / disconnect */
|
||||||
|
} finally {
|
||||||
|
req.raw.off("close", onClose)
|
||||||
|
try {
|
||||||
|
reply.raw.end()
|
||||||
|
} catch {
|
||||||
|
/* already closed */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default trafficFlowRoutes
|
||||||
+249
-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,19 @@ 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"
|
||||||
|
import {
|
||||||
|
buildBoundInterfaceTraffic,
|
||||||
|
buildUserTrafficList,
|
||||||
|
} from "../services/traffic-users.js"
|
||||||
|
|
||||||
type SnapshotRow = typeof serverSnapshots.$inferSelect
|
type SnapshotRow = typeof serverSnapshots.$inferSelect
|
||||||
|
|
||||||
@@ -40,6 +54,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 +78,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 +91,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 +299,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 +383,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 +391,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 +417,50 @@ 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 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get("/traffic/users", async (req, reply) => {
|
||||||
|
const q = req.query as { range?: string }
|
||||||
|
const minutes = rangeToMinutes(q.range)
|
||||||
|
const rangeEndMs = Date.now()
|
||||||
|
const rangeStartMs = rangeEndMs - minutes * 60_000
|
||||||
|
return reply.send({ users: buildUserTrafficList(rangeStartMs, rangeEndMs) })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/traffic/users/:id", async (req, reply) => {
|
||||||
|
const p = req.params as { id?: string }
|
||||||
|
const q = req.query as { range?: string }
|
||||||
|
const id = String(p.id ?? "")
|
||||||
|
if (!id) return reply.status(400).send({ error: "id is required" })
|
||||||
|
const minutes = rangeToMinutes(q.range)
|
||||||
|
const rangeEndMs = Date.now()
|
||||||
|
const rangeStartMs = rangeEndMs - minutes * 60_000
|
||||||
|
const users = buildUserTrafficList(rangeStartMs, rangeEndMs)
|
||||||
|
const user = users.find((u) => u.id === id)
|
||||||
|
if (!user) return reply.status(404).send({ error: "Пользователь не найден" })
|
||||||
|
return reply.send({ user })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/traffic/bound-interfaces", async (req, reply) => {
|
||||||
|
const q = req.query as { range?: string }
|
||||||
|
const minutes = rangeToMinutes(q.range)
|
||||||
|
const rangeEndMs = Date.now()
|
||||||
|
const rangeStartMs = rangeEndMs - minutes * 60_000
|
||||||
|
return reply.send({ interfaces: buildBoundInterfaceTraffic(rangeStartMs, rangeEndMs) })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export default trafficRoutes
|
export default trafficRoutes
|
||||||
|
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
|
import {
|
||||||
|
appUserCreateSchema,
|
||||||
|
appUserIdParamSchema,
|
||||||
|
appUserUpdateSchema,
|
||||||
|
bindingIdParamSchema,
|
||||||
|
interfaceCatalogQuerySchema,
|
||||||
|
userBindingCreateSchema,
|
||||||
|
type AppUserCreate,
|
||||||
|
type AppUserUpdate,
|
||||||
|
type UserBindingCreate,
|
||||||
|
} from "@mmapp/contracts/users"
|
||||||
|
import {
|
||||||
|
addBinding,
|
||||||
|
createUser,
|
||||||
|
deleteUser,
|
||||||
|
getUserById,
|
||||||
|
listInterfaceCatalog,
|
||||||
|
listUsers,
|
||||||
|
removeBinding,
|
||||||
|
updateUser,
|
||||||
|
UsersServiceError,
|
||||||
|
} from "../modules/users/service/users-service.js"
|
||||||
|
|
||||||
|
function sendServiceError(reply: { status: (c: number) => { send: (b: unknown) => unknown } }, err: unknown) {
|
||||||
|
if (err instanceof UsersServiceError) {
|
||||||
|
return reply.status(err.status).send({ error: err.message })
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
const usersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
|
app.get("/users", async (_req, reply) => {
|
||||||
|
return reply.send({ users: listUsers() })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/users/interface-catalog", {
|
||||||
|
schema: { querystring: interfaceCatalogQuerySchema },
|
||||||
|
}, async (req, reply) => {
|
||||||
|
const q = req.query as { serverId: number }
|
||||||
|
try {
|
||||||
|
return reply.send({ interfaces: await listInterfaceCatalog(q.serverId) })
|
||||||
|
} catch (err) {
|
||||||
|
return sendServiceError(reply, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/users/:id", {
|
||||||
|
schema: { params: appUserIdParamSchema },
|
||||||
|
}, async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const user = getUserById(id)
|
||||||
|
if (!user) return reply.status(404).send({ error: "Пользователь не найден" })
|
||||||
|
return reply.send({ user })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/users", {
|
||||||
|
schema: { body: appUserCreateSchema },
|
||||||
|
}, async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const user = createUser(req.body as AppUserCreate)
|
||||||
|
return reply.status(201).send({ user })
|
||||||
|
} catch (err) {
|
||||||
|
return sendServiceError(reply, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch("/users/:id", {
|
||||||
|
schema: { params: appUserIdParamSchema, body: appUserUpdateSchema },
|
||||||
|
}, async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
try {
|
||||||
|
const user = updateUser(id, req.body as AppUserUpdate)
|
||||||
|
return reply.send({ user })
|
||||||
|
} catch (err) {
|
||||||
|
return sendServiceError(reply, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete("/users/:id", {
|
||||||
|
schema: { params: appUserIdParamSchema },
|
||||||
|
}, async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
try {
|
||||||
|
deleteUser(id)
|
||||||
|
return reply.status(204).send()
|
||||||
|
} catch (err) {
|
||||||
|
return sendServiceError(reply, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/users/:id/bindings", {
|
||||||
|
schema: { params: appUserIdParamSchema, body: userBindingCreateSchema },
|
||||||
|
}, async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
try {
|
||||||
|
const binding = addBinding(id, req.body as UserBindingCreate)
|
||||||
|
return reply.status(201).send({ binding })
|
||||||
|
} catch (err) {
|
||||||
|
return sendServiceError(reply, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete("/users/:id/bindings/:bindingId", {
|
||||||
|
schema: { params: bindingIdParamSchema },
|
||||||
|
}, async (req, reply) => {
|
||||||
|
const { id, bindingId } = req.params as { id: string; bindingId: string }
|
||||||
|
try {
|
||||||
|
removeBinding(id, bindingId)
|
||||||
|
return reply.status(204).send()
|
||||||
|
} catch (err) {
|
||||||
|
return sendServiceError(reply, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default usersRoutes
|
||||||
@@ -21,6 +21,12 @@ import {
|
|||||||
getEnabledServerById,
|
getEnabledServerById,
|
||||||
listWireGuardInterfaces,
|
listWireGuardInterfaces,
|
||||||
} from "../services/wireguard-live.js"
|
} from "../services/wireguard-live.js"
|
||||||
|
import {
|
||||||
|
putIpAddress,
|
||||||
|
putWireguardInterface,
|
||||||
|
putWireguardPeer,
|
||||||
|
toRosBody,
|
||||||
|
} from "../services/wireguard-ros.js"
|
||||||
|
|
||||||
function serverIdParam(v: string): string {
|
function serverIdParam(v: string): string {
|
||||||
return decodeURIComponent(v)
|
return decodeURIComponent(v)
|
||||||
@@ -30,14 +36,6 @@ function rosIdParam(v: string): string {
|
|||||||
return decodeURIComponent(v)
|
return decodeURIComponent(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 peerToRosBody(p: Omit<WgCreatePeerRequest, "serverId" | "interfaceName"> & { interfaceName: string }) {
|
function peerToRosBody(p: Omit<WgCreatePeerRequest, "serverId" | "interfaceName"> & { interfaceName: string }) {
|
||||||
return toRosBody({
|
return toRosBody({
|
||||||
interface: p.interfaceName,
|
interface: p.interfaceName,
|
||||||
@@ -99,20 +97,17 @@ async function applyParsedConfig(
|
|||||||
comment: parsed.interface.comment,
|
comment: parsed.interface.comment,
|
||||||
disabled: parsed.interface.disabled ? "yes" : undefined,
|
disabled: parsed.interface.disabled ? "yes" : undefined,
|
||||||
})
|
})
|
||||||
await client.put("/interface/wireguard", ifaceBody)
|
await putWireguardInterface(client, ifaceBody)
|
||||||
|
|
||||||
if (parsed.interface.address) {
|
if (parsed.interface.address) {
|
||||||
await client.put("/ip/address", {
|
await putIpAddress(client, parsed.interface.address, name)
|
||||||
address: parsed.interface.address,
|
|
||||||
interface: name,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let peersCreated = 0
|
let peersCreated = 0
|
||||||
for (const p of parsed.peers) {
|
for (const p of parsed.peers) {
|
||||||
if (!p.publicKey) continue
|
if (!p.publicKey) continue
|
||||||
await client.put(
|
await putWireguardPeer(
|
||||||
"/interface/wireguard/peers",
|
client,
|
||||||
peerToRosBody({
|
peerToRosBody({
|
||||||
interfaceName: name,
|
interfaceName: name,
|
||||||
publicKey: p.publicKey,
|
publicKey: p.publicKey,
|
||||||
@@ -164,30 +159,21 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
|
|
||||||
const client = MikrotikClient.fromServer(server)
|
const client = MikrotikClient.fromServer(server)
|
||||||
try {
|
try {
|
||||||
await client.put(
|
await putWireguardInterface(client, {
|
||||||
"/interface/wireguard",
|
name: body.name,
|
||||||
toRosBody({
|
"listen-port": String(body.listenPort),
|
||||||
name: body.name,
|
mtu: String(body.mtu),
|
||||||
"listen-port": String(body.listenPort),
|
comment: body.comment,
|
||||||
mtu: String(body.mtu),
|
"private-key": body.privateKey,
|
||||||
comment: body.comment,
|
disabled: body.disabled ? "yes" : undefined,
|
||||||
"private-key": body.privateKey,
|
})
|
||||||
disabled: body.disabled ? "yes" : undefined,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (body.address) {
|
if (body.address) {
|
||||||
await client.put("/ip/address", {
|
await putIpAddress(client, body.address, body.name)
|
||||||
address: body.address,
|
|
||||||
interface: body.name,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.peer) {
|
if (body.peer) {
|
||||||
await client.put(
|
await putWireguardPeer(client, peerToRosBody({ ...body.peer, interfaceName: body.name }))
|
||||||
"/interface/wireguard/peers",
|
|
||||||
peerToRosBody({ ...body.peer, interfaceName: body.name }),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = await listWireGuardInterfaces({
|
const list = await listWireGuardInterfaces({
|
||||||
@@ -255,7 +241,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||||
const client = MikrotikClient.fromServer(server)
|
const client = MikrotikClient.fromServer(server)
|
||||||
try {
|
try {
|
||||||
await client.put("/interface/wireguard/peers", peerToRosBody(body))
|
await putWireguardPeer(client, peerToRosBody(body))
|
||||||
return reply.status(201).send({ ok: true })
|
return reply.status(201).send({ ok: true })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e)
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
|
|||||||
@@ -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,12 +1,16 @@
|
|||||||
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"
|
||||||
|
import { rememberServerIfaces } from "./traffic-flow-ifindex.js"
|
||||||
|
|
||||||
interface RosIfaceTraffic {
|
interface RosIfaceTraffic {
|
||||||
|
".id"?: string
|
||||||
name?: string
|
name?: string
|
||||||
|
ifindex?: string
|
||||||
running?: string
|
running?: string
|
||||||
disabled?: string
|
disabled?: string
|
||||||
"rx-byte"?: string
|
"rx-byte"?: string
|
||||||
@@ -15,6 +19,38 @@ interface RosIfaceTraffic {
|
|||||||
"tx-bits-per-second"?: string
|
"tx-bits-per-second"?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RosWgPeerTraffic {
|
||||||
|
interface?: string
|
||||||
|
name?: string
|
||||||
|
comment?: string
|
||||||
|
"public-key"?: string
|
||||||
|
rx?: string
|
||||||
|
tx?: string
|
||||||
|
disabled?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function waveKey(interfaceName: string, peerPublicKey = ""): string {
|
||||||
|
return `${interfaceName}\0${peerPublicKey}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleRate(
|
||||||
|
prevWave: Map<string, { rxBytes: number; txBytes: number; sampledAt: string }>,
|
||||||
|
key: string,
|
||||||
|
rxBytes: number,
|
||||||
|
txBytes: number,
|
||||||
|
nowMs: number,
|
||||||
|
): { rxBps: number; txBps: number } {
|
||||||
|
const prev = prevWave.get(key)
|
||||||
|
const prevMs = prev ? Date.parse(prev.sampledAt) : NaN
|
||||||
|
const rxBps = prev && Number.isFinite(prevMs)
|
||||||
|
? (rateBpsFromDelta(prev.rxBytes, rxBytes, prevMs, nowMs) ?? 0)
|
||||||
|
: 0
|
||||||
|
const txBps = prev && Number.isFinite(prevMs)
|
||||||
|
? (rateBpsFromDelta(prev.txBytes, txBytes, prevMs, nowMs) ?? 0)
|
||||||
|
: 0
|
||||||
|
return { rxBps, txBps }
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrafficCollectorState {
|
export interface TrafficCollectorState {
|
||||||
running: boolean
|
running: boolean
|
||||||
lastRunAt: string | null
|
lastRunAt: string | null
|
||||||
@@ -50,6 +86,32 @@ 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,
|
||||||
|
peerPublicKey: trafficSamples.peerPublicKey,
|
||||||
|
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}\0${r.peerPublicKey ?? ""}`, 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 +142,70 @@ 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
|
rememberServerIfaces(srv.id, ifaces)
|
||||||
let sumTx = 0
|
const prevWave = readPreviousWave(srv.id)
|
||||||
for (const i of ifaces) {
|
const nowMs = Date.parse(now)
|
||||||
sumRx += toNum(i["rx-bits-per-second"]) / 1_000_000
|
let sumRxMbps = 0
|
||||||
sumTx += toNum(i["tx-bits-per-second"]) / 1_000_000
|
let sumTxMbps = 0
|
||||||
}
|
const rows = ifaces.map((i) => {
|
||||||
if (ifaces.length > 0) {
|
const interfaceName = i.name ?? "unknown"
|
||||||
db.insert(trafficSamples).values(
|
const rxBytes = toNum(i["rx-byte"])
|
||||||
ifaces.map((i) => ({
|
const txBytes = toNum(i["tx-byte"])
|
||||||
|
const running = (i.running ?? "false") === "true"
|
||||||
|
const disabled = (i.disabled ?? "false") === "true"
|
||||||
|
const { rxBps, txBps } = sampleRate(prevWave, waveKey(interfaceName), rxBytes, txBytes, nowMs)
|
||||||
|
if (shouldIncludeIface(interfaceName, running, disabled)) {
|
||||||
|
sumRxMbps += bpsToMbps(rxBps)
|
||||||
|
sumTxMbps += bpsToMbps(txBps)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
serverId: srv.id,
|
||||||
|
interfaceName,
|
||||||
|
peerPublicKey: "",
|
||||||
|
sampledAt: now,
|
||||||
|
rxBytes,
|
||||||
|
txBytes,
|
||||||
|
rxBps,
|
||||||
|
txBps,
|
||||||
|
running,
|
||||||
|
disabled,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const peers = await client.get<RosWgPeerTraffic[]>("/interface/wireguard/peers")
|
||||||
|
for (const p of peers) {
|
||||||
|
const interfaceName = (p.interface ?? "").trim()
|
||||||
|
const peerPublicKey = (p["public-key"] ?? "").trim()
|
||||||
|
if (!interfaceName || !peerPublicKey) continue
|
||||||
|
const rxBytes = toNum(p.rx)
|
||||||
|
const txBytes = toNum(p.tx)
|
||||||
|
const disabled = (p.disabled ?? "false") === "true" || p.disabled === "yes"
|
||||||
|
const running = !disabled
|
||||||
|
const { rxBps, txBps } = sampleRate(
|
||||||
|
prevWave,
|
||||||
|
waveKey(interfaceName, peerPublicKey),
|
||||||
|
rxBytes,
|
||||||
|
txBytes,
|
||||||
|
nowMs,
|
||||||
|
)
|
||||||
|
rows.push({
|
||||||
serverId: srv.id,
|
serverId: srv.id,
|
||||||
interfaceName: i.name ?? "unknown",
|
interfaceName,
|
||||||
|
peerPublicKey,
|
||||||
sampledAt: now,
|
sampledAt: now,
|
||||||
rxBytes: toNum(i["rx-byte"]),
|
rxBytes,
|
||||||
txBytes: toNum(i["tx-byte"]),
|
txBytes,
|
||||||
rxBps: toNum(i["rx-bits-per-second"]),
|
rxBps,
|
||||||
txBps: toNum(i["tx-bits-per-second"]),
|
txBps,
|
||||||
running: (i.running ?? "false") === "true",
|
running,
|
||||||
disabled: (i.disabled ?? "false") === "true",
|
disabled,
|
||||||
})),
|
})
|
||||||
).run()
|
}
|
||||||
|
} catch {
|
||||||
|
/* WG peers optional — iface samples already recorded */
|
||||||
|
}
|
||||||
|
if (rows.length > 0) {
|
||||||
|
db.insert(trafficSamples).values(rows).run()
|
||||||
}
|
}
|
||||||
snapshot.servers.push({
|
snapshot.servers.push({
|
||||||
serverId: srv.id,
|
serverId: srv.id,
|
||||||
@@ -107,8 +213,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,168 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { rememberServerIfaces, resetIfaceCacheForTests } from "./traffic-flow-ifindex.js"
|
||||||
|
import {
|
||||||
|
ingestParsedFlowsForServerForTests,
|
||||||
|
resetFlowRingsForTests,
|
||||||
|
} from "./traffic-flow-ingest.js"
|
||||||
|
import { buildFlowAnalytics } from "./traffic-flow-analytics.js"
|
||||||
|
import { disableCatalogFetchForTests, resetFlowCatalogForTests, seedFlowCatalogForTests } from "./traffic-flow-classify.js"
|
||||||
|
import {
|
||||||
|
disableRipeEnqueueForTests,
|
||||||
|
disableRipePersistForTests,
|
||||||
|
resetRipeCacheForTests,
|
||||||
|
seedRipeCacheForTests,
|
||||||
|
} from "./traffic-flow-ripe.js"
|
||||||
|
|
||||||
|
disableCatalogFetchForTests()
|
||||||
|
resetFlowCatalogForTests()
|
||||||
|
disableRipePersistForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipeEnqueueForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
rememberServerIfaces(7, [
|
||||||
|
{ ".id": "*2", name: "ether1" },
|
||||||
|
{ ".id": "*A", name: "wg-flow" },
|
||||||
|
])
|
||||||
|
|
||||||
|
ingestParsedFlowsForServerForTests(7, [
|
||||||
|
{
|
||||||
|
src: "10.1.1.8",
|
||||||
|
dst: "8.8.8.8",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 51234,
|
||||||
|
dstPort: 443,
|
||||||
|
bytes: 12_000,
|
||||||
|
packets: 10,
|
||||||
|
inIface: "2",
|
||||||
|
outIface: "10",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "10.1.1.8",
|
||||||
|
dst: "1.1.1.1",
|
||||||
|
proto: 17,
|
||||||
|
srcPort: 53000,
|
||||||
|
dstPort: 53,
|
||||||
|
bytes: 800,
|
||||||
|
packets: 4,
|
||||||
|
inIface: "2",
|
||||||
|
outIface: "",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
try {
|
||||||
|
const all = buildFlowAnalytics({ minutes: 5, serverId: 7 })
|
||||||
|
assert.equal(all.applications[0]?.label, "HTTPS")
|
||||||
|
assert.ok(all.protocols.some((p) => p.label === "TCP"))
|
||||||
|
assert.equal(all.ifaces[0]?.name, "ether1")
|
||||||
|
assert.notEqual(all.ifaces[0]?.name, "2")
|
||||||
|
const conv = all.conversationsList[0]
|
||||||
|
assert.ok(conv)
|
||||||
|
assert.equal(conv.inIface, "ether1")
|
||||||
|
assert.equal(conv.inIfaceIndex, "2")
|
||||||
|
assert.equal(conv.application, "HTTPS")
|
||||||
|
assert.ok(!/^\d+$/.test(conv.inIface))
|
||||||
|
|
||||||
|
const filtered = buildFlowAnalytics({ minutes: 5, serverId: 7, iface: "ether1" })
|
||||||
|
assert.ok(filtered.bytes >= 12_000)
|
||||||
|
assert.equal(filtered.ifaces[0]?.name, "ether1")
|
||||||
|
|
||||||
|
const miss = buildFlowAnalytics({ minutes: 5, serverId: 7, iface: "wg-flow" })
|
||||||
|
assert.equal(miss.conversations, 0)
|
||||||
|
|
||||||
|
const other = buildFlowAnalytics({ minutes: 5, serverId: 99 })
|
||||||
|
assert.equal(other.conversations, 0)
|
||||||
|
} finally {
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
rememberServerIfaces(7, [
|
||||||
|
{ ".id": "*2", name: "ether1" },
|
||||||
|
{ ".id": "*A", name: "wg-flow" },
|
||||||
|
])
|
||||||
|
ingestParsedFlowsForServerForTests(7, [
|
||||||
|
{
|
||||||
|
src: "10.1.1.8",
|
||||||
|
dst: "8.8.8.8",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 51234,
|
||||||
|
dstPort: 443,
|
||||||
|
bytes: 12_000,
|
||||||
|
packets: 10,
|
||||||
|
inIface: "2",
|
||||||
|
outIface: "10",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "10.1.1.8",
|
||||||
|
dst: "8.8.8.8",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 51234,
|
||||||
|
dstPort: 443,
|
||||||
|
bytes: 9_000,
|
||||||
|
packets: 9,
|
||||||
|
inIface: "10",
|
||||||
|
outIface: "",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
try {
|
||||||
|
const summed = buildFlowAnalytics({ minutes: 5, serverId: 7, dedup: false })
|
||||||
|
assert.equal(summed.bytes, 21_000)
|
||||||
|
assert.equal(summed.conversations, 2)
|
||||||
|
const deduped = buildFlowAnalytics({ minutes: 5, serverId: 7, dedup: true })
|
||||||
|
assert.equal(deduped.bytes, 12_000)
|
||||||
|
assert.equal(deduped.conversations, 1)
|
||||||
|
assert.equal(deduped.dedupApplied, true)
|
||||||
|
assert.equal(deduped.interfaces.length, 2)
|
||||||
|
} finally {
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
disableRipeEnqueueForTests()
|
||||||
|
seedRipeCacheForTests({
|
||||||
|
prefix: "8.8.8.0/24",
|
||||||
|
asn: 15169,
|
||||||
|
country: "US",
|
||||||
|
lat: 37.4,
|
||||||
|
lng: -122.1,
|
||||||
|
holder: "GOOGLE",
|
||||||
|
ok: true,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
})
|
||||||
|
seedFlowCatalogForTests({
|
||||||
|
cidrs: [{ cidr: "8.8.8.0/24", purpose: "steam-gaming" }],
|
||||||
|
})
|
||||||
|
rememberServerIfaces(7, [{ ".id": "*2", name: "ether1" }])
|
||||||
|
ingestParsedFlowsForServerForTests(7, [
|
||||||
|
{
|
||||||
|
src: "10.1.1.8",
|
||||||
|
dst: "8.8.8.8",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 51234,
|
||||||
|
dstPort: 443,
|
||||||
|
bytes: 12_000,
|
||||||
|
packets: 10,
|
||||||
|
inIface: "2",
|
||||||
|
outIface: "",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
try {
|
||||||
|
const geo = buildFlowAnalytics({ minutes: 5, serverId: 7 })
|
||||||
|
assert.equal(geo.categories?.[0]?.label, "Игры")
|
||||||
|
assert.ok(geo.asns?.some((r) => r.label.includes("AS15169")))
|
||||||
|
assert.equal(geo.countries?.[0]?.id, "US")
|
||||||
|
assert.equal(geo.mapEdges?.[0]?.toCountry, "US")
|
||||||
|
assert.equal(geo.conversationsList[0]?.dstCountry, "US")
|
||||||
|
} finally {
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
resetFlowCatalogForTests()
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("traffic-flow-analytics.test.ts: ok")
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { db } from "../db/index.js"
|
||||||
|
import { appUsers, servers, userInterfaceBindings } from "../db/schema.js"
|
||||||
|
import type {
|
||||||
|
FlowAnalyticsDto,
|
||||||
|
FlowBreakdownRow,
|
||||||
|
FlowClientsDto,
|
||||||
|
FlowEntityCard,
|
||||||
|
FlowExportersDto,
|
||||||
|
FlowMapEdge,
|
||||||
|
FlowTalkerDto,
|
||||||
|
} from "@mmapp/contracts/traffic-flow"
|
||||||
|
import { protoName } from "./traffic-flow-parse.js"
|
||||||
|
import {
|
||||||
|
getFlowListenerState,
|
||||||
|
getRingMbps,
|
||||||
|
listStoredFlowRows,
|
||||||
|
type PendingFlowRow,
|
||||||
|
} from "./traffic-flow-ingest.js"
|
||||||
|
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
|
||||||
|
import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js"
|
||||||
|
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
|
||||||
|
import { dedupFlowRowsMaxBytes, flowTupleKey } from "./traffic-flow-dedup.js"
|
||||||
|
import { enqueueRipeMisses, lookupRipeCached } from "./traffic-flow-ripe.js"
|
||||||
|
import { classifyFlowDst, refreshFlowCatalogInBackground } from "./traffic-flow-classify.js"
|
||||||
|
|
||||||
|
export interface FlowAnalyticsQuery {
|
||||||
|
minutes: number
|
||||||
|
serverId?: number
|
||||||
|
userId?: string
|
||||||
|
iface?: string
|
||||||
|
/** Default true: один 5-tuple = max байт по ifaces. */
|
||||||
|
dedup?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function bpsToMbps(bps: number): number {
|
||||||
|
return bps / 1_000_000
|
||||||
|
}
|
||||||
|
|
||||||
|
function topN(map: Map<string, { bytes: number; packets: number }>, windowSec: number, n: number): FlowBreakdownRow[] {
|
||||||
|
const total = [...map.values()].reduce((a, v) => a + v.bytes, 0) || 1
|
||||||
|
return [...map.entries()]
|
||||||
|
.sort((a, b) => b[1].bytes - a[1].bytes)
|
||||||
|
.slice(0, n)
|
||||||
|
.map(([id, v]) => ({
|
||||||
|
id,
|
||||||
|
label: id,
|
||||||
|
bytes: v.bytes,
|
||||||
|
packets: v.packets,
|
||||||
|
bps: (v.bytes * 8) / windowSec,
|
||||||
|
percent: (v.bytes / total) * 100,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function bump(map: Map<string, { bytes: number; packets: number }>, id: string, bytes: number, packets: number) {
|
||||||
|
const prev = map.get(id) ?? { bytes: 0, packets: 0 }
|
||||||
|
prev.bytes += bytes
|
||||||
|
prev.packets += packets
|
||||||
|
map.set(id, prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
function userIfaceAllow(userId: string): Map<number, Set<string>> | null {
|
||||||
|
if (!userId) return null
|
||||||
|
const binds = db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId)).all()
|
||||||
|
const allow = new Map<number, Set<string>>()
|
||||||
|
for (const b of binds) {
|
||||||
|
const set = allow.get(b.serverId) ?? new Set<string>()
|
||||||
|
set.add(b.interfaceName)
|
||||||
|
allow.set(b.serverId, set)
|
||||||
|
}
|
||||||
|
return allow
|
||||||
|
}
|
||||||
|
|
||||||
|
function seriesFromRows(rows: PendingFlowRow[], minutes: number): { rx: number[]; tx: number[] } {
|
||||||
|
const slots = Math.min(60, Math.max(5, minutes))
|
||||||
|
const slotMs = (minutes * 60_000) / slots
|
||||||
|
const start = Date.now() - minutes * 60_000
|
||||||
|
const rx = Array(slots).fill(0) as number[]
|
||||||
|
const tx = Array(slots).fill(0) as number[]
|
||||||
|
for (const r of rows) {
|
||||||
|
const t = Date.parse(r.bucketAt)
|
||||||
|
if (!Number.isFinite(t)) continue
|
||||||
|
const idx = Math.min(slots - 1, Math.max(0, Math.floor((t - start) / slotMs)))
|
||||||
|
rx[idx] += r.bytes
|
||||||
|
}
|
||||||
|
const slotSec = Math.max(1, slotMs / 1000)
|
||||||
|
return {
|
||||||
|
rx: rx.map((b) => bpsToMbps((b * 8) / slotSec)),
|
||||||
|
tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotStatus(serverId: number): FlowEntityCard["status"] {
|
||||||
|
void serverId
|
||||||
|
return "online"
|
||||||
|
}
|
||||||
|
|
||||||
|
function topLabel(map: Map<string, { bytes: number; packets: number }>, fallback = "—"): string {
|
||||||
|
let best = fallback
|
||||||
|
let bestBytes = 0
|
||||||
|
for (const [label, v] of map) {
|
||||||
|
if (v.bytes > bestBytes) {
|
||||||
|
bestBytes = v.bytes
|
||||||
|
best = label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFlowAnalytics(q: FlowAnalyticsQuery): FlowAnalyticsDto {
|
||||||
|
const settings = getTrafficFlowSettingsRow()
|
||||||
|
const top = Math.min(50, Math.max(10, settings.topN))
|
||||||
|
const windowSec = Math.max(60, q.minutes * 60)
|
||||||
|
const sinceIso = new Date(Date.now() - q.minutes * 60_000).toISOString()
|
||||||
|
const raw = listStoredFlowRows(sinceIso)
|
||||||
|
const allow = q.userId ? userIfaceAllow(q.userId) : null
|
||||||
|
const serverRows = db.select().from(servers).all()
|
||||||
|
const nameById = new Map(serverRows.map((s) => [s.id, s.name || s.host]))
|
||||||
|
const countryById = new Map(serverRows.map((s) => [s.id, (s.country || "").toUpperCase() || "UN"]))
|
||||||
|
const ifaceFilter = q.iface && q.iface !== "__all__" ? q.iface : ""
|
||||||
|
const wantDedup = q.dedup !== false && !ifaceFilter
|
||||||
|
|
||||||
|
refreshFlowCatalogInBackground()
|
||||||
|
|
||||||
|
const applications = new Map<string, { bytes: number; packets: number }>()
|
||||||
|
const protocols = new Map<string, { bytes: number; packets: number }>()
|
||||||
|
const sources = new Map<string, { bytes: number; packets: number }>()
|
||||||
|
const destinations = new Map<string, { bytes: number; packets: number }>()
|
||||||
|
const ifacesMap = new Map<string, { bytes: number; packets: number; index: string }>()
|
||||||
|
const asns = new Map<string, { bytes: number; packets: number }>()
|
||||||
|
const countries = new Map<string, { bytes: number; packets: number }>()
|
||||||
|
const categories = new Map<string, { bytes: number; packets: number }>()
|
||||||
|
const services = new Map<string, { bytes: number; packets: number }>()
|
||||||
|
const conv = new Map<string, FlowTalkerDto & { rawBytes: number }>()
|
||||||
|
const edgeAcc = new Map<string, FlowMapEdge & { catBytes: Map<string, number> }>()
|
||||||
|
const srcs = new Set<string>()
|
||||||
|
const dsts = new Set<string>()
|
||||||
|
const matched: PendingFlowRow[] = []
|
||||||
|
|
||||||
|
for (const r of raw) {
|
||||||
|
const resolved = resolveIfaceName(r.serverId, r.inIface)
|
||||||
|
if (!flowRowMatchesFilter(r, resolved.name, q, allow)) continue
|
||||||
|
matched.push(r)
|
||||||
|
|
||||||
|
const ifaceKey = resolved.name
|
||||||
|
const prevIf = ifacesMap.get(ifaceKey) ?? { bytes: 0, packets: 0, index: resolved.index }
|
||||||
|
prevIf.bytes += r.bytes
|
||||||
|
prevIf.packets += r.packets
|
||||||
|
ifacesMap.set(ifaceKey, prevIf)
|
||||||
|
}
|
||||||
|
|
||||||
|
const working = wantDedup ? dedupFlowRowsMaxBytes(matched) : matched
|
||||||
|
const conversationsRaw = new Set(matched.map((r) => `${flowTupleKey(r)}|${r.inIface}`)).size
|
||||||
|
|
||||||
|
let totalBytes = 0
|
||||||
|
let totalPackets = 0
|
||||||
|
for (const r of working) {
|
||||||
|
const resolved = resolveIfaceName(r.serverId, r.inIface)
|
||||||
|
totalBytes += r.bytes
|
||||||
|
totalPackets += r.packets
|
||||||
|
srcs.add(r.src)
|
||||||
|
dsts.add(r.dst)
|
||||||
|
const app = applicationName(r.proto, r.dstPort, r.srcPort)
|
||||||
|
const ripe = lookupRipeCached(r.dst)
|
||||||
|
const classified = classifyFlowDst(r.dst, r.proto, r.dstPort, r.srcPort, ripe)
|
||||||
|
bump(applications, app, r.bytes, r.packets)
|
||||||
|
bump(protocols, protoName(r.proto), r.bytes, r.packets)
|
||||||
|
bump(sources, r.src, r.bytes, r.packets)
|
||||||
|
bump(destinations, r.dst, r.bytes, r.packets)
|
||||||
|
bump(categories, classified.category, r.bytes, r.packets)
|
||||||
|
bump(services, classified.service, r.bytes, r.packets)
|
||||||
|
if (ripe?.ok && ripe.asn) {
|
||||||
|
const asnLabel = ripe.holder ? `AS${ripe.asn} ${ripe.holder}` : `AS${ripe.asn}`
|
||||||
|
bump(asns, asnLabel, r.bytes, r.packets)
|
||||||
|
}
|
||||||
|
if (ripe?.ok && ripe.country && ripe.country !== "—") {
|
||||||
|
bump(countries, ripe.country, r.bytes, r.packets)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ckey = wantDedup
|
||||||
|
? flowTupleKey(r)
|
||||||
|
: `${flowTupleKey(r)}|${r.inIface}`
|
||||||
|
const prev = conv.get(ckey)
|
||||||
|
if (prev) {
|
||||||
|
prev.rawBytes += r.bytes
|
||||||
|
prev.bytes += r.bytes
|
||||||
|
prev.packets += r.packets
|
||||||
|
} else {
|
||||||
|
conv.set(ckey, {
|
||||||
|
serverId: String(r.serverId),
|
||||||
|
serverName: nameById.get(r.serverId) ?? String(r.serverId),
|
||||||
|
src: r.src,
|
||||||
|
dst: r.dst,
|
||||||
|
proto: r.proto,
|
||||||
|
protoName: protoName(r.proto),
|
||||||
|
srcPort: r.srcPort,
|
||||||
|
dstPort: r.dstPort,
|
||||||
|
bytes: r.bytes,
|
||||||
|
packets: r.packets,
|
||||||
|
bps: 0,
|
||||||
|
inIface: resolved.name,
|
||||||
|
inIfaceIndex: resolved.index,
|
||||||
|
application: app,
|
||||||
|
category: classified.category,
|
||||||
|
service: classified.service,
|
||||||
|
dstCountry: ripe?.country && ripe.country !== "—" ? ripe.country : undefined,
|
||||||
|
dstAsn: ripe?.asn || undefined,
|
||||||
|
rawBytes: r.bytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toCountry = ripe?.ok && ripe.country && ripe.country !== "—" ? ripe.country : ""
|
||||||
|
if (toCountry) {
|
||||||
|
const fromCountry = countryById.get(r.serverId) || "UN"
|
||||||
|
const ekey = `${r.serverId}|${toCountry}`
|
||||||
|
let edge = edgeAcc.get(ekey)
|
||||||
|
if (!edge) {
|
||||||
|
edge = {
|
||||||
|
fromId: String(r.serverId),
|
||||||
|
fromLabel: nameById.get(r.serverId) ?? String(r.serverId),
|
||||||
|
fromCountry,
|
||||||
|
toCountry,
|
||||||
|
toAsn: ripe?.asn ?? 0,
|
||||||
|
category: classified.category,
|
||||||
|
bytes: 0,
|
||||||
|
bps: 0,
|
||||||
|
catBytes: new Map(),
|
||||||
|
}
|
||||||
|
edgeAcc.set(ekey, edge)
|
||||||
|
}
|
||||||
|
edge.bytes += r.bytes
|
||||||
|
if (ripe?.asn) edge.toAsn = ripe.asn
|
||||||
|
edge.catBytes.set(classified.category, (edge.catBytes.get(classified.category) ?? 0) + r.bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueueRipeMisses(dsts)
|
||||||
|
|
||||||
|
const conversationsList = [...conv.values()]
|
||||||
|
.map((t) => ({ ...t, bps: (t.rawBytes * 8) / windowSec }))
|
||||||
|
.sort((a, b) => b.bytes - a.bytes)
|
||||||
|
.slice(0, top)
|
||||||
|
.map(({ rawBytes: _raw, ...rest }) => rest)
|
||||||
|
|
||||||
|
const topProto = topLabel(protocols)
|
||||||
|
const topCategory = topLabel(categories)
|
||||||
|
|
||||||
|
const ringServer = q.serverId ?? (matched[0]?.serverId ?? 0)
|
||||||
|
const ring = ringServer
|
||||||
|
? getRingMbps(ringServer, ifaceFilter === "" ? "__all__" : (ifacesMap.get(ifaceFilter)?.index || ifaceFilter))
|
||||||
|
: { rx: Array(60).fill(0) as number[], tx: Array(60).fill(0) as number[], rxNow: 0, txNow: 0 }
|
||||||
|
|
||||||
|
const fromBuckets = seriesFromRows(matched, q.minutes)
|
||||||
|
const rxSeries = q.minutes <= 15 ? ring.rx : fromBuckets.rx
|
||||||
|
const txSeries = q.minutes <= 15 ? ring.tx : fromBuckets.tx
|
||||||
|
|
||||||
|
const ifaceRows = [...ifacesMap.entries()]
|
||||||
|
.sort((a, b) => b[1].bytes - a[1].bytes)
|
||||||
|
.map(([name, v]) => ({
|
||||||
|
name,
|
||||||
|
index: v.index,
|
||||||
|
bps: (v.bytes * 8) / windowSec,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const ifaceRawBytes = [...ifacesMap.values()].reduce((a, v) => a + v.bytes, 0) || 1
|
||||||
|
const listener = getFlowListenerState()
|
||||||
|
const mapEdges: FlowMapEdge[] = [...edgeAcc.values()]
|
||||||
|
.map((e) => {
|
||||||
|
let cat = e.category
|
||||||
|
let catBest = 0
|
||||||
|
for (const [label, bytes] of e.catBytes) {
|
||||||
|
if (bytes > catBest) {
|
||||||
|
catBest = bytes
|
||||||
|
cat = label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
fromId: e.fromId,
|
||||||
|
fromLabel: e.fromLabel,
|
||||||
|
fromCountry: e.fromCountry,
|
||||||
|
toCountry: e.toCountry,
|
||||||
|
toAsn: e.toAsn,
|
||||||
|
category: cat,
|
||||||
|
bytes: e.bytes,
|
||||||
|
bps: (e.bytes * 8) / windowSec,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.bytes - a.bytes)
|
||||||
|
.slice(0, top)
|
||||||
|
|
||||||
|
return {
|
||||||
|
bpsNow: (ring.rxNow + ring.txNow) * 1_000_000 || (totalBytes * 8) / windowSec,
|
||||||
|
bytes: totalBytes,
|
||||||
|
packets: totalPackets,
|
||||||
|
conversations: conv.size,
|
||||||
|
conversationsRaw,
|
||||||
|
uniqueSrc: srcs.size,
|
||||||
|
uniqueDst: dsts.size,
|
||||||
|
topProto,
|
||||||
|
topCategory,
|
||||||
|
rxSeries,
|
||||||
|
txSeries,
|
||||||
|
applications: topN(applications, windowSec, top),
|
||||||
|
protocols: topN(protocols, windowSec, top),
|
||||||
|
sources: topN(sources, windowSec, top),
|
||||||
|
destinations: topN(destinations, windowSec, top),
|
||||||
|
interfaces: [...ifacesMap.entries()].map(([label, v]) => ({
|
||||||
|
id: label,
|
||||||
|
label,
|
||||||
|
bytes: v.bytes,
|
||||||
|
packets: v.packets,
|
||||||
|
bps: (v.bytes * 8) / windowSec,
|
||||||
|
percent: (v.bytes / ifaceRawBytes) * 100,
|
||||||
|
})).sort((a, b) => b.bytes - a.bytes),
|
||||||
|
asns: topN(asns, windowSec, top),
|
||||||
|
countries: topN(countries, windowSec, top),
|
||||||
|
categories: topN(categories, windowSec, top),
|
||||||
|
services: topN(services, windowSec, top),
|
||||||
|
mapEdges,
|
||||||
|
conversationsList,
|
||||||
|
ifaces: ifaceRows,
|
||||||
|
live: listener.bound,
|
||||||
|
dedupApplied: wantDedup,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardFromServer(
|
||||||
|
s: typeof servers.$inferSelect,
|
||||||
|
minutes: number,
|
||||||
|
): FlowEntityCard {
|
||||||
|
const analytics = buildFlowAnalytics({ minutes, serverId: s.id })
|
||||||
|
const ring = getRingMbps(s.id, "__all__")
|
||||||
|
return {
|
||||||
|
id: String(s.id),
|
||||||
|
name: s.name || s.host,
|
||||||
|
subtitle: s.host,
|
||||||
|
site: s.site || "—",
|
||||||
|
country: s.country || "UN",
|
||||||
|
status: snapshotStatus(s.id),
|
||||||
|
rxNow: ring.rxNow || bpsToMbps(analytics.bpsNow),
|
||||||
|
txNow: ring.txNow,
|
||||||
|
sessions: analytics.conversations,
|
||||||
|
rxSeries: ring.rx.some((v) => v > 0) ? ring.rx : analytics.rxSeries,
|
||||||
|
txSeries: ring.tx,
|
||||||
|
bytes: analytics.bytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFlowExporters(minutes: number): FlowExportersDto {
|
||||||
|
const settings = getTrafficFlowSettingsRow()
|
||||||
|
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
|
||||||
|
const rows = listStoredFlowRows(sinceIso)
|
||||||
|
const ids = new Set<number>()
|
||||||
|
for (const r of rows) ids.add(r.serverId)
|
||||||
|
for (const p of listHostPeers()) ids.add(p.serverId)
|
||||||
|
const serverRows = db.select().from(servers).all()
|
||||||
|
const exporters = serverRows
|
||||||
|
.filter((s) => ids.has(s.id))
|
||||||
|
.map((s) => cardFromServer(s, minutes))
|
||||||
|
.sort((a, b) => b.rxNow - a.rxNow)
|
||||||
|
const listener = getFlowListenerState()
|
||||||
|
return {
|
||||||
|
exporters,
|
||||||
|
lastExporterIp: settings.lastExporterIp ?? null,
|
||||||
|
lastError: settings.lastError || null,
|
||||||
|
packetsReceived: settings.packetsReceived,
|
||||||
|
lastDatagramAt: settings.lastDatagramAt ?? null,
|
||||||
|
listenerBound: listener.bound,
|
||||||
|
listenerAddress: listener.address,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFlowClients(minutes: number): FlowClientsDto {
|
||||||
|
const users = db.select().from(appUsers).all()
|
||||||
|
const binds = db.select().from(userInterfaceBindings).all()
|
||||||
|
const byUser = new Map<string, typeof binds>()
|
||||||
|
for (const b of binds) {
|
||||||
|
const list = byUser.get(b.userId) ?? []
|
||||||
|
list.push(b)
|
||||||
|
byUser.set(b.userId, list)
|
||||||
|
}
|
||||||
|
const clients: FlowEntityCard[] = []
|
||||||
|
for (const u of users) {
|
||||||
|
const userBinds = byUser.get(u.id) ?? []
|
||||||
|
if (userBinds.length === 0) continue
|
||||||
|
const analytics = buildFlowAnalytics({ minutes, userId: u.id })
|
||||||
|
const firstServer = userBinds[0]?.serverId
|
||||||
|
const ring = firstServer ? getRingMbps(firstServer, "__all__") : { rx: Array(60).fill(0) as number[], tx: Array(60).fill(0) as number[], rxNow: 0, txNow: 0 }
|
||||||
|
clients.push({
|
||||||
|
id: u.id,
|
||||||
|
name: u.login,
|
||||||
|
subtitle: u.name || u.login,
|
||||||
|
site: `${userBinds.length} ifaces`,
|
||||||
|
country: "UN",
|
||||||
|
status: u.active ? "online" : "offline",
|
||||||
|
rxNow: bpsToMbps(analytics.bpsNow) || ring.rxNow,
|
||||||
|
txNow: ring.txNow,
|
||||||
|
sessions: analytics.conversations,
|
||||||
|
rxSeries: analytics.rxSeries,
|
||||||
|
txSeries: analytics.txSeries,
|
||||||
|
bytes: analytics.bytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
clients.sort((a, b) => b.rxNow - a.rxNow)
|
||||||
|
return { clients }
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { protoName } from "./traffic-flow-parse.js"
|
||||||
|
|
||||||
|
const WELL_KNOWN: Record<string, string> = {
|
||||||
|
"6:80": "HTTP",
|
||||||
|
"6:443": "HTTPS",
|
||||||
|
"6:8080": "HTTP-alt",
|
||||||
|
"6:8443": "HTTPS-alt",
|
||||||
|
"6:22": "SSH",
|
||||||
|
"6:21": "FTP",
|
||||||
|
"6:25": "SMTP",
|
||||||
|
"6:110": "POP3",
|
||||||
|
"6:143": "IMAP",
|
||||||
|
"6:993": "IMAPS",
|
||||||
|
"6:995": "POP3S",
|
||||||
|
"6:587": "SMTP",
|
||||||
|
"6:465": "SMTPS",
|
||||||
|
"6:3306": "MySQL",
|
||||||
|
"6:5432": "PostgreSQL",
|
||||||
|
"6:6379": "Redis",
|
||||||
|
"6:3389": "RDP",
|
||||||
|
"6:445": "SMB",
|
||||||
|
"6:139": "NetBIOS",
|
||||||
|
"6:179": "BGP",
|
||||||
|
"6:8291": "WinBox",
|
||||||
|
"6:8728": "ROS-API",
|
||||||
|
"6:8729": "ROS-API-SSL",
|
||||||
|
"17:53": "DNS",
|
||||||
|
"6:53": "DNS",
|
||||||
|
"17:123": "NTP",
|
||||||
|
"17:161": "SNMP",
|
||||||
|
"17:162": "SNMP-trap",
|
||||||
|
"17:500": "IKE",
|
||||||
|
"17:4500": "NAT-T",
|
||||||
|
"17:1194": "OpenVPN",
|
||||||
|
"17:51820": "WireGuard",
|
||||||
|
"17:4789": "VXLAN",
|
||||||
|
"17:4739": "IPFIX",
|
||||||
|
"17:2055": "NetFlow",
|
||||||
|
"17:67": "DHCP",
|
||||||
|
"17:68": "DHCP",
|
||||||
|
"17:69": "TFTP",
|
||||||
|
"17:1812": "RADIUS",
|
||||||
|
"1:0": "ICMP",
|
||||||
|
"47:0": "GRE",
|
||||||
|
"50:0": "ESP",
|
||||||
|
"89:0": "OSPF",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applicationName(proto: number, dstPort: number, srcPort = 0): string {
|
||||||
|
if (proto === 1) return "ICMP"
|
||||||
|
if (proto === 47) return "GRE"
|
||||||
|
if (proto === 50) return "ESP"
|
||||||
|
if (proto === 89) return "OSPF"
|
||||||
|
const dstKey = `${proto}:${dstPort}`
|
||||||
|
const srcKey = `${proto}:${srcPort}`
|
||||||
|
return WELL_KNOWN[dstKey] ?? WELL_KNOWN[srcKey] ?? `${protoName(proto)}/${dstPort || srcPort || "—"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowMatchQuery {
|
||||||
|
serverId?: number
|
||||||
|
userId?: string
|
||||||
|
iface?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flowRowMatchesFilter(
|
||||||
|
row: { serverId: number; inIface: string },
|
||||||
|
resolvedName: string,
|
||||||
|
q: FlowMatchQuery,
|
||||||
|
allow: Map<number, Set<string>> | null,
|
||||||
|
): boolean {
|
||||||
|
if (q.serverId != null && row.serverId !== q.serverId) return false
|
||||||
|
if (allow) {
|
||||||
|
const names = allow.get(row.serverId)
|
||||||
|
if (!names || !names.has(resolvedName)) return false
|
||||||
|
}
|
||||||
|
const iface = q.iface && q.iface !== "__all__" ? q.iface : ""
|
||||||
|
if (iface && resolvedName !== iface && row.inIface !== iface) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { classifyFlowDst, disableCatalogFetchForTests, resetFlowCatalogForTests, seedFlowCatalogForTests } from "./traffic-flow-classify.js"
|
||||||
|
|
||||||
|
disableCatalogFetchForTests()
|
||||||
|
resetFlowCatalogForTests()
|
||||||
|
seedFlowCatalogForTests({
|
||||||
|
cidrs: [{ cidr: "192.0.2.0/24", purpose: "steam-gaming" }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const hit = classifyFlowDst("192.0.2.10", 6, 443, 50000, null)
|
||||||
|
assert.equal(hit.category, "Игры")
|
||||||
|
assert.equal(hit.service, "steam-gaming")
|
||||||
|
|
||||||
|
const miss = classifyFlowDst("203.0.113.9", 17, 53, 53000, null)
|
||||||
|
assert.equal(miss.category, "DNS")
|
||||||
|
|
||||||
|
const cdn = classifyFlowDst("203.0.113.9", 6, 443, 1, { prefix: "203.0.113.0/24", asn: 13335, country: "US", lat: null, lng: null, holder: "CLOUDFLARENET", ok: true, fetchedAt: Date.now() })
|
||||||
|
assert.equal(cdn.category, "CDN")
|
||||||
|
|
||||||
|
console.log("traffic-flow-classify.test.ts: ok")
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { db } from "../db/index.js"
|
||||||
|
import { evobgpSettings } from "../db/schema.js"
|
||||||
|
import { ipInCidrV4, parseCidrV4 } from "./traffic-flow-ip.js"
|
||||||
|
import type { FlowIpMeta } from "./traffic-flow-ripe.js"
|
||||||
|
import { applicationName } from "./traffic-flow-apps.js"
|
||||||
|
|
||||||
|
export interface FlowClassification {
|
||||||
|
service: string
|
||||||
|
category: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CatalogCidr {
|
||||||
|
cidr: string
|
||||||
|
purpose: string
|
||||||
|
prefixLen: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATALOG_TTL_MS = 10 * 60_000
|
||||||
|
let cidrs: CatalogCidr[] = []
|
||||||
|
let asnPurpose = new Map<number, string>()
|
||||||
|
let fetchedAt = 0
|
||||||
|
let catalogFetchEnabled = true
|
||||||
|
let inflight: Promise<void> | null = null
|
||||||
|
|
||||||
|
export function disableCatalogFetchForTests(): void {
|
||||||
|
catalogFetchEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetFlowCatalogForTests(): void {
|
||||||
|
cidrs = []
|
||||||
|
asnPurpose = new Map()
|
||||||
|
fetchedAt = 0
|
||||||
|
inflight = null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seedFlowCatalogForTests(input: {
|
||||||
|
cidrs?: Array<{ cidr: string; purpose: string }>
|
||||||
|
asns?: Array<{ asn: number; purpose: string }>
|
||||||
|
}): void {
|
||||||
|
cidrs = (input.cidrs ?? [])
|
||||||
|
.map((c) => ({ cidr: c.cidr, purpose: c.purpose, prefixLen: parseCidrV4(c.cidr)?.prefixLen ?? 0 }))
|
||||||
|
.sort((a, b) => b.prefixLen - a.prefixLen)
|
||||||
|
asnPurpose = new Map((input.asns ?? []).map((a) => [a.asn, a.purpose]))
|
||||||
|
fetchedAt = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function categoryFromPurpose(purpose: string, proto: number, dstPort: number, srcPort: number): string {
|
||||||
|
const p = purpose.toLowerCase()
|
||||||
|
if (/gaming|steam|epic|riot/.test(p)) return "Игры"
|
||||||
|
if (/streaming|youtube|netflix|twitch|video/.test(p)) return "Видео / стриминг"
|
||||||
|
if (/cdn|cloudflare|akamai|fastly/.test(p)) return "CDN"
|
||||||
|
if (/voip|discord|zoom/.test(p)) return "Голос"
|
||||||
|
const app = applicationName(proto, dstPort, srcPort)
|
||||||
|
if (app === "DNS" || app === "SSH" || app === "BGP") return app
|
||||||
|
return "Проче"
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchCidr(ip: string): CatalogCidr | null {
|
||||||
|
for (const row of cidrs) {
|
||||||
|
if (ipInCidrV4(ip, row.cidr)) return row
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyFlowDst(
|
||||||
|
dst: string,
|
||||||
|
proto: number,
|
||||||
|
dstPort: number,
|
||||||
|
srcPort: number,
|
||||||
|
ripe: FlowIpMeta | null,
|
||||||
|
): FlowClassification {
|
||||||
|
const hit = matchCidr(dst)
|
||||||
|
const asnName = ripe?.asn ? asnPurpose.get(ripe.asn) : undefined
|
||||||
|
const purpose = hit?.purpose || asnName || ripe?.holder || ""
|
||||||
|
const service = (hit?.purpose || asnName || ripe?.holder || "Проче").trim() || "Проче"
|
||||||
|
return {
|
||||||
|
service,
|
||||||
|
category: categoryFromPurpose(purpose, proto, dstPort, srcPort),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCatalog(): Promise<void> {
|
||||||
|
if (!catalogFetchEnabled) return
|
||||||
|
if (Date.now() - fetchedAt < CATALOG_TTL_MS) return
|
||||||
|
if (inflight) return inflight
|
||||||
|
inflight = (async () => {
|
||||||
|
try {
|
||||||
|
const row = db.select().from(evobgpSettings).limit(1).all()[0]
|
||||||
|
if (!row?.enabled) return
|
||||||
|
const root = String(row.baseUrl ?? "").replace(/\/+$/, "")
|
||||||
|
const token = String(row.apiKey ?? "").replace(/^Bearer\s+/i, "").trim()
|
||||||
|
if (!root || !token) return
|
||||||
|
const ac = new AbortController()
|
||||||
|
const t = setTimeout(() => ac.abort(), 20_000)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${root}/v1/router-lists/catalog`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
|
||||||
|
signal: ac.signal,
|
||||||
|
})
|
||||||
|
if (!res.ok) return
|
||||||
|
const catalog = await res.json() as {
|
||||||
|
modules?: { items?: Array<{ id: string; name: string }> }
|
||||||
|
ip_ranges?: { items?: Array<{ module_id: string; entry: { prefix: string } }> }
|
||||||
|
asns?: { items?: Array<{ module_id: string; entry: { asn: number } }> }
|
||||||
|
}
|
||||||
|
const mods = new Map((catalog.modules?.items ?? []).map((m) => [m.id, m.name]))
|
||||||
|
const next: CatalogCidr[] = []
|
||||||
|
for (const item of catalog.ip_ranges?.items ?? []) {
|
||||||
|
const prefix = String(item.entry?.prefix ?? "").trim()
|
||||||
|
const purpose = mods.get(item.module_id) ?? ""
|
||||||
|
const parsed = parseCidrV4(prefix)
|
||||||
|
if (!prefix || !parsed) continue
|
||||||
|
next.push({ cidr: prefix, purpose, prefixLen: parsed.prefixLen })
|
||||||
|
}
|
||||||
|
next.sort((a, b) => b.prefixLen - a.prefixLen)
|
||||||
|
const nextAsn = new Map<number, string>()
|
||||||
|
for (const item of catalog.asns?.items ?? []) {
|
||||||
|
const purpose = mods.get(item.module_id)
|
||||||
|
const asn = Number(item.entry?.asn)
|
||||||
|
if (purpose && Number.isFinite(asn) && asn > 0) nextAsn.set(asn, purpose)
|
||||||
|
}
|
||||||
|
cidrs = next
|
||||||
|
asnPurpose = nextAsn
|
||||||
|
fetchedAt = Date.now()
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* catalog optional */
|
||||||
|
} finally {
|
||||||
|
inflight = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return inflight
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Background refresh — analytics never awaits the HTTP. */
|
||||||
|
export function refreshFlowCatalogInBackground(): void {
|
||||||
|
void fetchCatalog()
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { dedupFlowRowsMaxBytes, flowTupleKey } from "./traffic-flow-dedup.js"
|
||||||
|
|
||||||
|
const a = {
|
||||||
|
serverId: 7,
|
||||||
|
src: "10.1.1.8",
|
||||||
|
dst: "8.8.8.8",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 1,
|
||||||
|
dstPort: 443,
|
||||||
|
inIface: "2",
|
||||||
|
bytes: 12_000,
|
||||||
|
packets: 10,
|
||||||
|
}
|
||||||
|
const b = { ...a, inIface: "10", bytes: 8_000, packets: 8 }
|
||||||
|
const out = dedupFlowRowsMaxBytes([a, b])
|
||||||
|
assert.equal(out.length, 1)
|
||||||
|
assert.equal(out[0]?.bytes, 12_000)
|
||||||
|
assert.equal(out[0]?.inIface, "2")
|
||||||
|
assert.equal(flowTupleKey(a), flowTupleKey(b))
|
||||||
|
|
||||||
|
const sameIface = dedupFlowRowsMaxBytes([a, { ...a, bytes: 3_000, packets: 2 }])
|
||||||
|
assert.equal(sameIface[0]?.bytes, 15_000)
|
||||||
|
|
||||||
|
console.log("traffic-flow-dedup.test.ts: ok")
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export interface FlowTupleRow {
|
||||||
|
serverId: number
|
||||||
|
src: string
|
||||||
|
dst: string
|
||||||
|
proto: number
|
||||||
|
srcPort: number
|
||||||
|
dstPort: number
|
||||||
|
inIface: string
|
||||||
|
bytes: number
|
||||||
|
packets: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flowTupleKey(r: Pick<FlowTupleRow, "serverId" | "src" | "dst" | "proto" | "srcPort" | "dstPort">): string {
|
||||||
|
return `${r.serverId}|${r.src}|${r.dst}|${r.proto}|${r.srcPort}|${r.dstPort}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function ifaceKey(r: FlowTupleRow): string {
|
||||||
|
return `${flowTupleKey(r)}|${r.inIface}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Один 5-tuple на двух ifIndex — это один поток: сначала сумма по бакетам/iface,
|
||||||
|
* затем max байт между интерфейсами (не sum).
|
||||||
|
*/
|
||||||
|
export function dedupFlowRowsMaxBytes<T extends FlowTupleRow>(rows: T[]): T[] {
|
||||||
|
const byIface = new Map<string, T>()
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = ifaceKey(row)
|
||||||
|
const prev = byIface.get(key)
|
||||||
|
if (!prev) {
|
||||||
|
byIface.set(key, { ...row })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prev.bytes += row.bytes
|
||||||
|
prev.packets += row.packets
|
||||||
|
}
|
||||||
|
const byTuple = new Map<string, T>()
|
||||||
|
for (const row of byIface.values()) {
|
||||||
|
const key = flowTupleKey(row)
|
||||||
|
const prev = byTuple.get(key)
|
||||||
|
if (!prev || row.bytes > prev.bytes) byTuple.set(key, row)
|
||||||
|
}
|
||||||
|
return [...byTuple.values()]
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { generateNativeConf } from "./wireguard-config.js"
|
||||||
|
import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js"
|
||||||
|
import type { TrafficFlowHostFile } from "@mmapp/contracts/traffic-flow"
|
||||||
|
|
||||||
|
const COMPOSE_DIR = "/opt/cdn-mm"
|
||||||
|
|
||||||
|
export function buildHostWgQuickConf(): string {
|
||||||
|
const row = getTrafficFlowSettingsRow()
|
||||||
|
const peers = listHostPeers()
|
||||||
|
return generateNativeConf({
|
||||||
|
name: "wg-flow",
|
||||||
|
mtu: 1420,
|
||||||
|
privateKey: row.hostPrivateKey || undefined,
|
||||||
|
address: `${row.collectorIp}/24`,
|
||||||
|
comment: "MikrotikManager traffic-flow collector",
|
||||||
|
peers: peers.map((p) => ({
|
||||||
|
publicKey: p.publicKey,
|
||||||
|
allowedIps: p.allowedIps,
|
||||||
|
comment: p.name,
|
||||||
|
endpoint: p.endpoint,
|
||||||
|
persistentKeepalive: p.endpoint ? 25 : undefined,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHostComposeOverride(): string {
|
||||||
|
const row = getTrafficFlowSettingsRow()
|
||||||
|
return [
|
||||||
|
"# Docker Compose merge для /opt/cdn-mm",
|
||||||
|
"# Не править docker-compose.yml. Traefik не трогать.",
|
||||||
|
"# Сначала: wg-quick up wg-flow (адрес " + row.collectorIp + ")",
|
||||||
|
"# затем: docker compose up -d --force-recreate backend",
|
||||||
|
"# Docker userland-proxy может SNAT UDP source в 172.x — ingest сопоставит единственный JH.",
|
||||||
|
"",
|
||||||
|
"services:",
|
||||||
|
" backend:",
|
||||||
|
" environment:",
|
||||||
|
" FLOW_LISTEN_HOST: \"0.0.0.0\"",
|
||||||
|
" ports:",
|
||||||
|
` - "${row.collectorIp}:${row.flowListenPort}:${row.flowListenPort}/udp"`,
|
||||||
|
"",
|
||||||
|
].join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHostLinuxInstallSh(): string {
|
||||||
|
const row = getTrafficFlowSettingsRow()
|
||||||
|
const conf = buildHostWgQuickConf().replace(/\s+$/, "") + "\n"
|
||||||
|
const override = buildHostComposeOverride()
|
||||||
|
const collector = row.collectorIp
|
||||||
|
const flowPort = row.flowListenPort
|
||||||
|
|
||||||
|
return `#!/usr/bin/env bash
|
||||||
|
# WG-клиент на хосте /opt/cdn-mm → JH:13232, IPFIX в контейнер backend.
|
||||||
|
# Запуск: sudo bash install-wg-flow.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ \${EUID:-$(id -u)} -ne 0 ]]; then
|
||||||
|
echo "Запустите от root: sudo bash $0" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
COLLECTOR_IP="${collector}"
|
||||||
|
FLOW_PORT="${flowPort}"
|
||||||
|
COMPOSE_DIR="${COMPOSE_DIR}"
|
||||||
|
|
||||||
|
if ! command -v wg >/dev/null 2>&1; then
|
||||||
|
apt-get update
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y wireguard
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -d -m 700 /etc/wireguard
|
||||||
|
cat > /etc/wireguard/wg-flow.conf <<'WGEOF'
|
||||||
|
${conf}WGEOF
|
||||||
|
chmod 600 /etc/wireguard/wg-flow.conf
|
||||||
|
|
||||||
|
systemctl enable --now wg-quick@wg-flow
|
||||||
|
echo "=== wg show wg-flow ==="
|
||||||
|
wg show wg-flow
|
||||||
|
echo "=== адрес (ожидаем \${COLLECTOR_IP}/24) ==="
|
||||||
|
ip -4 addr show dev wg-flow
|
||||||
|
|
||||||
|
if [[ ! -d "\$COMPOSE_DIR" ]]; then
|
||||||
|
echo "Нет \$COMPOSE_DIR — положите override.yml туда вручную (вкладка compose)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "\$COMPOSE_DIR/docker-compose.override.yml" <<'OVEOF'
|
||||||
|
${override}OVEOF
|
||||||
|
|
||||||
|
cd "\$COMPOSE_DIR"
|
||||||
|
docker compose up -d --force-recreate backend
|
||||||
|
|
||||||
|
echo "=== UDP \${FLOW_PORT} на хосте (ожидаем \${COLLECTOR_IP}:\${FLOW_PORT} docker-proxy) ==="
|
||||||
|
ss -ulnp | grep -E "\${FLOW_PORT}" || true
|
||||||
|
echo "=== PortBindings mmapp-backend ==="
|
||||||
|
docker inspect -f '{{json .HostConfig.PortBindings}}' mmapp-backend
|
||||||
|
echo "=== handshake (keepalive 25s к JH:13232) ==="
|
||||||
|
wg show wg-flow
|
||||||
|
|
||||||
|
# nft на хосте MM не трогаем. Bind только на COLLECTOR_IP, не 0.0.0.0.
|
||||||
|
# Если backend стартовал до wg-flow: docker compose up -d --force-recreate backend
|
||||||
|
|
||||||
|
echo "Готово. Traefik не трогали. UDP \${FLOW_PORT} только на \${COLLECTOR_IP}, не на 0.0.0.0."
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listTrafficFlowHostFiles(): TrafficFlowHostFile[] {
|
||||||
|
return [
|
||||||
|
{ id: "linux", label: "Linux", filename: "install-wg-flow.sh", code: buildHostLinuxInstallSh() },
|
||||||
|
{ id: "wg-quick", label: "wg-flow.conf", filename: "wg-flow.conf", code: buildHostWgQuickConf() },
|
||||||
|
{ id: "compose", label: "compose", filename: "docker-compose.override.yml", code: buildHostComposeOverride() },
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {
|
||||||
|
rememberServerIfaces,
|
||||||
|
resetIfaceCacheForTests,
|
||||||
|
resolveIfaceName,
|
||||||
|
rosIdToIfIndex,
|
||||||
|
} from "./traffic-flow-ifindex.js"
|
||||||
|
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
|
||||||
|
|
||||||
|
assert.equal(rosIdToIfIndex("*A"), 10)
|
||||||
|
assert.equal(rosIdToIfIndex("*D"), 13)
|
||||||
|
assert.equal(rosIdToIfIndex("*2"), 2)
|
||||||
|
assert.equal(rosIdToIfIndex("*9"), 9)
|
||||||
|
assert.equal(rosIdToIfIndex("0"), 0)
|
||||||
|
assert.equal(rosIdToIfIndex(""), null)
|
||||||
|
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
rememberServerIfaces(7, [
|
||||||
|
{ ".id": "*2", name: "ether1" },
|
||||||
|
{ ".id": "*A", name: "wg-flow" },
|
||||||
|
{ ".id": "*D", name: "bridge" },
|
||||||
|
])
|
||||||
|
assert.equal(resolveIfaceName(7, "2").name, "ether1")
|
||||||
|
assert.equal(resolveIfaceName(7, "10").name, "wg-flow")
|
||||||
|
assert.equal(resolveIfaceName(7, "13").name, "bridge")
|
||||||
|
assert.equal(resolveIfaceName(7, "0").name, "—")
|
||||||
|
assert.equal(resolveIfaceName(7, "ether1").name, "ether1")
|
||||||
|
assert.equal(resolveIfaceName(7, "99").name, "#99")
|
||||||
|
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
rememberServerIfaces(8, [
|
||||||
|
{ ifindex: "10", ".id": "*12", name: "gre1" },
|
||||||
|
])
|
||||||
|
assert.equal(rosIdToIfIndex("*12"), 18)
|
||||||
|
assert.equal(resolveIfaceName(8, "10").name, "gre1")
|
||||||
|
assert.equal(resolveIfaceName(8, "18").name, "gre1")
|
||||||
|
|
||||||
|
assert.equal(applicationName(6, 443), "HTTPS")
|
||||||
|
assert.equal(applicationName(17, 53), "DNS")
|
||||||
|
assert.equal(applicationName(6, 22), "SSH")
|
||||||
|
assert.equal(applicationName(17, 51820), "WireGuard")
|
||||||
|
assert.equal(applicationName(6, 179), "BGP")
|
||||||
|
|
||||||
|
const allow = new Map<number, Set<string>>([[7, new Set(["ether1", "wg-flow"])]])
|
||||||
|
assert.equal(flowRowMatchesFilter({ serverId: 7, inIface: "2" }, "ether1", {}, allow), true)
|
||||||
|
assert.equal(flowRowMatchesFilter({ serverId: 7, inIface: "2" }, "bridge", {}, allow), false)
|
||||||
|
assert.equal(flowRowMatchesFilter({ serverId: 7, inIface: "2" }, "ether1", { iface: "ether1" }, allow), true)
|
||||||
|
assert.equal(flowRowMatchesFilter({ serverId: 7, inIface: "2" }, "ether1", { iface: "wg-flow" }, allow), false)
|
||||||
|
assert.equal(flowRowMatchesFilter({ serverId: 8, inIface: "2" }, "ether1", { serverId: 7 }, null), false)
|
||||||
|
|
||||||
|
console.log("traffic-flow-ifaces.test.ts: ok")
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { db } from "../db/index.js"
|
||||||
|
import { servers } from "../db/schema.js"
|
||||||
|
import { MikrotikClient } from "./mikrotik.js"
|
||||||
|
import {
|
||||||
|
ifaceCacheFresh,
|
||||||
|
rememberServerIfaces,
|
||||||
|
type RosIfaceIndexRow,
|
||||||
|
} from "./traffic-flow-ifindex.js"
|
||||||
|
|
||||||
|
export {
|
||||||
|
ifaceCacheFresh,
|
||||||
|
ifaceCacheHas,
|
||||||
|
rememberServerIfaces,
|
||||||
|
resetIfaceCacheForTests,
|
||||||
|
resolveIfaceName,
|
||||||
|
rosIdToIfIndex,
|
||||||
|
} from "./traffic-flow-ifindex.js"
|
||||||
|
|
||||||
|
const inflight = new Set<number>()
|
||||||
|
|
||||||
|
export async function refreshServerIfaces(serverId: number, force = false): Promise<void> {
|
||||||
|
if (inflight.has(serverId)) return
|
||||||
|
if (!force && ifaceCacheFresh(serverId)) return
|
||||||
|
inflight.add(serverId)
|
||||||
|
try {
|
||||||
|
const row = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||||
|
if (!row) return
|
||||||
|
const client = MikrotikClient.fromServer(row)
|
||||||
|
const ifaces = await client.get<RosIfaceIndexRow[]>("/interface")
|
||||||
|
rememberServerIfaces(serverId, Array.isArray(ifaces) ? ifaces : [])
|
||||||
|
} catch {
|
||||||
|
/* keep previous cache */
|
||||||
|
} finally {
|
||||||
|
inflight.delete(serverId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
export interface RosIfaceIndexRow {
|
||||||
|
".id"?: string
|
||||||
|
name?: string
|
||||||
|
ifindex?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new Map<number, Map<number, string>>()
|
||||||
|
const fetchedAt = new Map<number, number>()
|
||||||
|
|
||||||
|
export const IFACE_CACHE_TTL_MS = 60_000
|
||||||
|
|
||||||
|
/** RouterOS `.id` (`*A`) → SNMP ifIndex (10). */
|
||||||
|
export function rosIdToIfIndex(id: string | undefined | null): number | null {
|
||||||
|
if (!id) return null
|
||||||
|
const raw = String(id).trim()
|
||||||
|
const hex = raw.startsWith("*") ? raw.slice(1) : raw
|
||||||
|
if (!hex || !/^[0-9a-fA-F]+$/.test(hex)) return null
|
||||||
|
const n = parseInt(hex, 16)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rememberServerIfaces(serverId: number, rows: RosIfaceIndexRow[]): void {
|
||||||
|
const map = new Map<number, string>()
|
||||||
|
for (const row of rows) {
|
||||||
|
const name = String(row.name ?? "").trim()
|
||||||
|
if (!name) continue
|
||||||
|
const fromProp = Number.parseInt(String(row.ifindex ?? ""), 10)
|
||||||
|
const fromId = rosIdToIfIndex(row[".id"])
|
||||||
|
if (Number.isFinite(fromProp) && fromProp > 0) map.set(fromProp, name)
|
||||||
|
if (fromId != null && fromId > 0) map.set(fromId, name)
|
||||||
|
}
|
||||||
|
cache.set(serverId, map)
|
||||||
|
fetchedAt.set(serverId, Date.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveIfaceName(serverId: number, indexOrName: string): { name: string; index: string } {
|
||||||
|
const trimmed = String(indexOrName ?? "").trim()
|
||||||
|
if (!trimmed || trimmed === "0") return { name: "—", index: trimmed }
|
||||||
|
if (!/^\d+$/.test(trimmed)) return { name: trimmed, index: "" }
|
||||||
|
const idx = Number(trimmed)
|
||||||
|
const name = cache.get(serverId)?.get(idx)
|
||||||
|
if (name) return { name, index: trimmed }
|
||||||
|
return { name: `#${trimmed}`, index: trimmed }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ifaceCacheHas(serverId: number): boolean {
|
||||||
|
return cache.has(serverId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ifaceCacheFresh(serverId: number, ttlMs = IFACE_CACHE_TTL_MS): boolean {
|
||||||
|
const prev = fetchedAt.get(serverId) ?? 0
|
||||||
|
return Boolean(prev && Date.now() - prev < ttlMs && cache.has(serverId))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetIfaceCacheForTests(): void {
|
||||||
|
cache.clear()
|
||||||
|
fetchedAt.clear()
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
import { createSocket, type Socket } from "node:dgram"
|
||||||
|
import { desc, eq, gte, sql } from "drizzle-orm"
|
||||||
|
import { db } from "../db/index.js"
|
||||||
|
import { flowBuckets, servers } from "../db/schema.js"
|
||||||
|
import type { FlowStatsDto, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
|
||||||
|
import { parseFlowPacket, protoName, type ParsedFlow } from "./traffic-flow-parse.js"
|
||||||
|
import { pickServerIdForExporter } from "./traffic-flow-map-exporter.js"
|
||||||
|
import {
|
||||||
|
getTrafficFlowSettingsRow,
|
||||||
|
listHostPeers,
|
||||||
|
recordFlowListenerError,
|
||||||
|
recordFlowPacket,
|
||||||
|
} from "./traffic-flow-settings.js"
|
||||||
|
import { ifaceCacheFresh, refreshServerIfaces, resolveIfaceName } from "./traffic-flow-ifaces.js"
|
||||||
|
import { applicationName } from "./traffic-flow-apps.js"
|
||||||
|
|
||||||
|
export interface FlowListenerState {
|
||||||
|
bound: boolean
|
||||||
|
address: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingFlowRow {
|
||||||
|
serverId: number
|
||||||
|
bucketAt: string
|
||||||
|
src: string
|
||||||
|
dst: string
|
||||||
|
proto: number
|
||||||
|
srcPort: number
|
||||||
|
dstPort: number
|
||||||
|
bytes: number
|
||||||
|
packets: number
|
||||||
|
inIface: string
|
||||||
|
outIface: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TICK_MS = 2_000
|
||||||
|
const RING_LEN = 60
|
||||||
|
|
||||||
|
let socket: Socket | null = null
|
||||||
|
let state: FlowListenerState = { bound: false, address: null }
|
||||||
|
const pending = new Map<string, {
|
||||||
|
serverId: number
|
||||||
|
bucketAt: string
|
||||||
|
flow: ParsedFlow
|
||||||
|
bytes: number
|
||||||
|
packets: number
|
||||||
|
}>()
|
||||||
|
let flushTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const tickAccum = new Map<string, { inBytes: number; outBytes: number }>()
|
||||||
|
const rings = new Map<string, { inBps: number[]; outBps: number[] }>()
|
||||||
|
|
||||||
|
export function getFlowListenerState(): FlowListenerState {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
function minuteBucketIso(at = Date.now()): string {
|
||||||
|
const d = new Date(at)
|
||||||
|
d.setSeconds(0, 0)
|
||||||
|
return d.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function ringKey(serverId: number, iface: string): string {
|
||||||
|
return `${serverId}\0${iface || "__all__"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function bumpTick(key: string, inBytes: number, outBytes: number): void {
|
||||||
|
const prev = tickAccum.get(key) ?? { inBytes: 0, outBytes: 0 }
|
||||||
|
prev.inBytes += inBytes
|
||||||
|
prev.outBytes += outBytes
|
||||||
|
tickAccum.set(key, prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToTick(serverId: number, inIface: string, outIface: string, bytes: number): void {
|
||||||
|
bumpTick(ringKey(serverId, "__all__"), bytes, 0)
|
||||||
|
if (inIface) bumpTick(ringKey(serverId, inIface), bytes, 0)
|
||||||
|
if (outIface && outIface !== inIface) bumpTick(ringKey(serverId, outIface), 0, bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyRing(): { inBps: number[]; outBps: number[] } {
|
||||||
|
return { inBps: Array(RING_LEN).fill(0), outBps: Array(RING_LEN).fill(0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rollFlowRings(): void {
|
||||||
|
const keys = new Set([...tickAccum.keys(), ...rings.keys()])
|
||||||
|
const sec = TICK_MS / 1000
|
||||||
|
for (const key of keys) {
|
||||||
|
const acc = tickAccum.get(key) ?? { inBytes: 0, outBytes: 0 }
|
||||||
|
tickAccum.delete(key)
|
||||||
|
const inBps = (acc.inBytes * 8) / sec
|
||||||
|
const outBps = (acc.outBytes * 8) / sec
|
||||||
|
let ring = rings.get(key)
|
||||||
|
if (!ring) {
|
||||||
|
ring = emptyRing()
|
||||||
|
rings.set(key, ring)
|
||||||
|
}
|
||||||
|
ring.inBps.push(inBps)
|
||||||
|
ring.inBps.shift()
|
||||||
|
ring.outBps.push(outBps)
|
||||||
|
ring.outBps.shift()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRingMbps(serverId: number, iface = "__all__"): {
|
||||||
|
rx: number[]
|
||||||
|
tx: number[]
|
||||||
|
rxNow: number
|
||||||
|
txNow: number
|
||||||
|
} {
|
||||||
|
const ring = rings.get(ringKey(serverId, iface))
|
||||||
|
const scale = 1_000_000
|
||||||
|
if (!ring) {
|
||||||
|
return { rx: Array(RING_LEN).fill(0), tx: Array(RING_LEN).fill(0), rxNow: 0, txNow: 0 }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
rx: ring.inBps.map((b) => b / scale),
|
||||||
|
tx: ring.outBps.map((b) => b / scale),
|
||||||
|
rxNow: (ring.inBps[RING_LEN - 1] ?? 0) / scale,
|
||||||
|
txNow: (ring.outBps[RING_LEN - 1] ?? 0) / scale,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveServerId(exporterIp: string): number | null {
|
||||||
|
const settings = getTrafficFlowSettingsRow()
|
||||||
|
const rows = db.select({
|
||||||
|
id: servers.id,
|
||||||
|
host: servers.host,
|
||||||
|
mgmtTunnelIp: servers.mgmtTunnelIp,
|
||||||
|
}).from(servers).all()
|
||||||
|
const byTunnelIp = new Map<string, number>()
|
||||||
|
const hostIps = new Map<string, number>()
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.mgmtTunnelIp) byTunnelIp.set(row.mgmtTunnelIp, row.id)
|
||||||
|
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(row.host)) hostIps.set(row.host, row.id)
|
||||||
|
}
|
||||||
|
return pickServerIdForExporter({
|
||||||
|
exporterIp,
|
||||||
|
overlayPrefix: settings.prefix,
|
||||||
|
byTunnelIp,
|
||||||
|
peers: listHostPeers(),
|
||||||
|
hostIps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueFlows(exporterIp: string, flows: ParsedFlow[]): boolean {
|
||||||
|
const serverId = resolveServerId(exporterIp)
|
||||||
|
if (serverId == null) return false
|
||||||
|
const needsRefresh = !ifaceCacheFresh(serverId)
|
||||||
|
|| flows.some((f) => resolveIfaceName(serverId, f.inIface).name.startsWith("#"))
|
||||||
|
if (needsRefresh) void refreshServerIfaces(serverId, true)
|
||||||
|
const bucketAt = minuteBucketIso()
|
||||||
|
for (const flow of flows) {
|
||||||
|
addToTick(serverId, flow.inIface, flow.outIface, flow.bytes)
|
||||||
|
const key = `${serverId}\0${bucketAt}\0${flow.src}\0${flow.dst}\0${flow.proto}\0${flow.srcPort}\0${flow.dstPort}\0${flow.inIface}`
|
||||||
|
const prev = pending.get(key)
|
||||||
|
if (prev) {
|
||||||
|
prev.bytes += flow.bytes
|
||||||
|
prev.packets += flow.packets
|
||||||
|
} else {
|
||||||
|
pending.set(key, {
|
||||||
|
serverId,
|
||||||
|
bucketAt,
|
||||||
|
flow: { ...flow },
|
||||||
|
bytes: flow.bytes,
|
||||||
|
packets: flow.packets,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function peekPendingFlows(): PendingFlowRow[] {
|
||||||
|
return [...pending.values()].map((row) => ({
|
||||||
|
serverId: row.serverId,
|
||||||
|
bucketAt: row.bucketAt,
|
||||||
|
src: row.flow.src || "0.0.0.0",
|
||||||
|
dst: row.flow.dst || "0.0.0.0",
|
||||||
|
proto: row.flow.proto,
|
||||||
|
srcPort: row.flow.srcPort,
|
||||||
|
dstPort: row.flow.dstPort,
|
||||||
|
bytes: row.bytes,
|
||||||
|
packets: row.packets,
|
||||||
|
inIface: row.flow.inIface,
|
||||||
|
outIface: row.flow.outIface,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushPending() {
|
||||||
|
if (pending.size === 0) return
|
||||||
|
const settings = getTrafficFlowSettingsRow()
|
||||||
|
const topN = Math.max(20, settings.topN)
|
||||||
|
const cutoff = new Date(Date.now() - settings.retentionHours * 3600_000).toISOString()
|
||||||
|
const rows = [...pending.values()]
|
||||||
|
pending.clear()
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
try {
|
||||||
|
db.insert(flowBuckets).values({
|
||||||
|
serverId: row.serverId,
|
||||||
|
bucketAt: row.bucketAt,
|
||||||
|
src: row.flow.src || "0.0.0.0",
|
||||||
|
dst: row.flow.dst || "0.0.0.0",
|
||||||
|
proto: row.flow.proto,
|
||||||
|
srcPort: row.flow.srcPort,
|
||||||
|
dstPort: row.flow.dstPort,
|
||||||
|
bytes: row.bytes,
|
||||||
|
packets: row.packets,
|
||||||
|
inIface: row.flow.inIface,
|
||||||
|
}).onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
flowBuckets.serverId,
|
||||||
|
flowBuckets.bucketAt,
|
||||||
|
flowBuckets.src,
|
||||||
|
flowBuckets.dst,
|
||||||
|
flowBuckets.proto,
|
||||||
|
flowBuckets.srcPort,
|
||||||
|
flowBuckets.dstPort,
|
||||||
|
flowBuckets.inIface,
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
bytes: sql`${flowBuckets.bytes} + excluded.bytes`,
|
||||||
|
packets: sql`${flowBuckets.packets} + excluded.packets`,
|
||||||
|
},
|
||||||
|
}).run()
|
||||||
|
} catch {
|
||||||
|
// ignore single-row failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.delete(flowBuckets).where(sql`${flowBuckets.bucketAt} < ${cutoff}`).run()
|
||||||
|
|
||||||
|
const latest = db.select({ bucketAt: flowBuckets.bucketAt }).from(flowBuckets)
|
||||||
|
.orderBy(desc(flowBuckets.bucketAt)).limit(1).all()[0]?.bucketAt
|
||||||
|
if (!latest) return
|
||||||
|
const latestRows = db.select().from(flowBuckets).where(eq(flowBuckets.bucketAt, latest)).all()
|
||||||
|
const byServer = new Map<number, typeof latestRows>()
|
||||||
|
for (const r of latestRows) {
|
||||||
|
const list = byServer.get(r.serverId) ?? []
|
||||||
|
list.push(r)
|
||||||
|
byServer.set(r.serverId, list)
|
||||||
|
}
|
||||||
|
for (const list of byServer.values()) {
|
||||||
|
if (list.length <= topN) continue
|
||||||
|
list.sort((a, b) => b.bytes - a.bytes)
|
||||||
|
for (const d of list.slice(topN)) {
|
||||||
|
db.delete(flowBuckets).where(eq(flowBuckets.id, d.id)).run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTick() {
|
||||||
|
rollFlowRings()
|
||||||
|
flushPending()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMessage(msg: Buffer, rinfo: { address: string }) {
|
||||||
|
try {
|
||||||
|
const flows = parseFlowPacket(msg, rinfo.address)
|
||||||
|
recordFlowPacket(rinfo.address)
|
||||||
|
if (!flows.length) return
|
||||||
|
if (!queueFlows(rinfo.address, flows)) {
|
||||||
|
recordFlowListenerError(
|
||||||
|
`IPFIX от ${rinfo.address}: нет jump-host с адресом wg-flow. Docker SNAT (172.x) при нескольких JH не различим.`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recordFlowListenerError("")
|
||||||
|
} catch (e) {
|
||||||
|
recordFlowListenerError(e instanceof Error ? e.message : String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopTrafficFlowListener() {
|
||||||
|
if (flushTimer) {
|
||||||
|
clearInterval(flushTimer)
|
||||||
|
flushTimer = null
|
||||||
|
}
|
||||||
|
flushPending()
|
||||||
|
if (socket) {
|
||||||
|
try { socket.close() } catch { /* ignore */ }
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
state = { bound: false, address: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startTrafficFlowListener() {
|
||||||
|
stopTrafficFlowListener()
|
||||||
|
const settings = getTrafficFlowSettingsRow()
|
||||||
|
if (!settings.enabled) {
|
||||||
|
state = { bound: false, address: null }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const host = process.env.FLOW_LISTEN_HOST?.trim() || settings.collectorIp || "127.0.0.1"
|
||||||
|
const port = settings.flowListenPort
|
||||||
|
const sock = createSocket("udp4")
|
||||||
|
sock.on("error", (err) => {
|
||||||
|
recordFlowListenerError(err.message)
|
||||||
|
state = { bound: false, address: null }
|
||||||
|
})
|
||||||
|
sock.on("message", onMessage)
|
||||||
|
sock.bind(port, host, () => {
|
||||||
|
state = { bound: true, address: `${host}:${port}` }
|
||||||
|
recordFlowListenerError("")
|
||||||
|
})
|
||||||
|
socket = sock
|
||||||
|
flushTimer = setInterval(onTick, TICK_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listStoredFlowRows(sinceIso: string): PendingFlowRow[] {
|
||||||
|
const stored = db.select().from(flowBuckets).where(gte(flowBuckets.bucketAt, sinceIso)).all()
|
||||||
|
const merged = new Map<string, PendingFlowRow>()
|
||||||
|
for (const r of stored) {
|
||||||
|
const key = `${r.serverId}|${r.bucketAt}|${r.src}|${r.dst}|${r.proto}|${r.srcPort}|${r.dstPort}|${r.inIface}`
|
||||||
|
merged.set(key, {
|
||||||
|
serverId: r.serverId,
|
||||||
|
bucketAt: r.bucketAt,
|
||||||
|
src: r.src,
|
||||||
|
dst: r.dst,
|
||||||
|
proto: r.proto,
|
||||||
|
srcPort: r.srcPort,
|
||||||
|
dstPort: r.dstPort,
|
||||||
|
bytes: r.bytes,
|
||||||
|
packets: r.packets,
|
||||||
|
inIface: r.inIface,
|
||||||
|
outIface: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (const p of peekPendingFlows()) {
|
||||||
|
if (p.bucketAt < sinceIso) continue
|
||||||
|
const key = `${p.serverId}|${p.bucketAt}|${p.src}|${p.dst}|${p.proto}|${p.srcPort}|${p.dstPort}|${p.inIface}`
|
||||||
|
const prev = merged.get(key)
|
||||||
|
if (prev) {
|
||||||
|
prev.bytes += p.bytes
|
||||||
|
prev.packets += p.packets
|
||||||
|
} else {
|
||||||
|
merged.set(key, { ...p })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...merged.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFlowTalkers(minutes = 5): FlowStatsDto {
|
||||||
|
const settings = getTrafficFlowSettingsRow()
|
||||||
|
const rangeStart = new Date(Date.now() - minutes * 60_000).toISOString()
|
||||||
|
const rows = listStoredFlowRows(rangeStart)
|
||||||
|
const serverRows = db.select().from(servers).all()
|
||||||
|
const nameById = new Map(serverRows.map((s) => [s.id, s.name || s.host]))
|
||||||
|
const agg = new Map<string, FlowTalkerDto & { rawBytes: number }>()
|
||||||
|
const protoBytes = new Map<number, number>()
|
||||||
|
const srcs = new Set<string>()
|
||||||
|
const dsts = new Set<string>()
|
||||||
|
const exporters = new Set<number>()
|
||||||
|
let totalBytes = 0
|
||||||
|
for (const r of rows) {
|
||||||
|
const resolved = resolveIfaceName(r.serverId, r.inIface)
|
||||||
|
const key = `${r.serverId}|${r.src}|${r.dst}|${r.proto}|${r.srcPort}|${r.dstPort}|${r.inIface}`
|
||||||
|
const prev = agg.get(key)
|
||||||
|
const bytes = r.bytes
|
||||||
|
totalBytes += bytes
|
||||||
|
srcs.add(r.src)
|
||||||
|
dsts.add(r.dst)
|
||||||
|
exporters.add(r.serverId)
|
||||||
|
protoBytes.set(r.proto, (protoBytes.get(r.proto) ?? 0) + bytes)
|
||||||
|
if (prev) {
|
||||||
|
prev.rawBytes += bytes
|
||||||
|
prev.bytes += bytes
|
||||||
|
prev.packets += r.packets
|
||||||
|
} else {
|
||||||
|
agg.set(key, {
|
||||||
|
serverId: String(r.serverId),
|
||||||
|
serverName: nameById.get(r.serverId) ?? String(r.serverId),
|
||||||
|
src: r.src,
|
||||||
|
dst: r.dst,
|
||||||
|
proto: r.proto,
|
||||||
|
protoName: protoName(r.proto),
|
||||||
|
srcPort: r.srcPort,
|
||||||
|
dstPort: r.dstPort,
|
||||||
|
bytes,
|
||||||
|
packets: r.packets,
|
||||||
|
bps: 0,
|
||||||
|
inIface: resolved.name,
|
||||||
|
inIfaceIndex: resolved.index,
|
||||||
|
application: applicationName(r.proto, r.dstPort, r.srcPort),
|
||||||
|
rawBytes: bytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const windowSec = Math.max(60, minutes * 60)
|
||||||
|
const talkers = [...agg.values()]
|
||||||
|
.map((t) => ({ ...t, bps: (t.rawBytes * 8) / windowSec }))
|
||||||
|
.sort((a, b) => b.bytes - a.bytes)
|
||||||
|
.slice(0, settings.topN)
|
||||||
|
.map(({ rawBytes: _raw, ...rest }) => rest)
|
||||||
|
let topProto = "—"
|
||||||
|
let topProtoBytes = 0
|
||||||
|
for (const [p, b] of protoBytes) {
|
||||||
|
if (b > topProtoBytes) {
|
||||||
|
topProtoBytes = b
|
||||||
|
topProto = protoName(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
exportersOnline: exporters.size,
|
||||||
|
bytesPerMin: minutes > 0 ? totalBytes / minutes : totalBytes,
|
||||||
|
uniqueSrc: srcs.size,
|
||||||
|
uniqueDst: dsts.size,
|
||||||
|
topProto,
|
||||||
|
talkers,
|
||||||
|
lastExporterIp: settings.lastExporterIp ?? null,
|
||||||
|
lastError: settings.lastError || null,
|
||||||
|
packetsReceived: settings.packetsReceived,
|
||||||
|
lastDatagramAt: settings.lastDatagramAt ?? null,
|
||||||
|
listenerBound: state.bound,
|
||||||
|
listenerAddress: state.address,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ingestParsedFlowsForTests(exporterIp: string, flows: ParsedFlow[]) {
|
||||||
|
queueFlows(exporterIp, flows)
|
||||||
|
rollFlowRings()
|
||||||
|
flushPending()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Кладёт потоки в pending без flush в SQLite — для юнит-тестов аналитики. */
|
||||||
|
export function ingestParsedFlowsForServerForTests(serverId: number, flows: ParsedFlow[]) {
|
||||||
|
const bucketAt = minuteBucketIso()
|
||||||
|
for (const flow of flows) {
|
||||||
|
addToTick(serverId, flow.inIface, flow.outIface, flow.bytes)
|
||||||
|
const key = `${serverId}\0${bucketAt}\0${flow.src}\0${flow.dst}\0${flow.proto}\0${flow.srcPort}\0${flow.dstPort}\0${flow.inIface}`
|
||||||
|
const prev = pending.get(key)
|
||||||
|
if (prev) {
|
||||||
|
prev.bytes += flow.bytes
|
||||||
|
prev.packets += flow.packets
|
||||||
|
} else {
|
||||||
|
pending.set(key, {
|
||||||
|
serverId,
|
||||||
|
bucketAt,
|
||||||
|
flow: { ...flow },
|
||||||
|
bytes: flow.bytes,
|
||||||
|
packets: flow.packets,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rollFlowRings()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetFlowRingsForTests() {
|
||||||
|
tickAccum.clear()
|
||||||
|
rings.clear()
|
||||||
|
pending.clear()
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/** IPv4 helpers for RIPEstat prefix cache and EvoBGP CIDR match. */
|
||||||
|
|
||||||
|
export function ipv4ToInt(ip: string): number | null {
|
||||||
|
const parts = String(ip ?? "").trim().split(".")
|
||||||
|
if (parts.length !== 4) return null
|
||||||
|
let n = 0
|
||||||
|
for (const p of parts) {
|
||||||
|
if (!/^\d+$/.test(p)) return null
|
||||||
|
const o = Number(p)
|
||||||
|
if (o < 0 || o > 255) return null
|
||||||
|
n = ((n << 8) >>> 0) + o
|
||||||
|
}
|
||||||
|
return n >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCidrV4(cidr: string): { net: number; mask: number; prefixLen: number } | null {
|
||||||
|
const raw = String(cidr ?? "").trim()
|
||||||
|
const [ip, lenRaw] = raw.split("/")
|
||||||
|
const addr = ipv4ToInt(ip ?? "")
|
||||||
|
const prefixLen = Number.parseInt(lenRaw ?? "", 10)
|
||||||
|
if (addr == null || !Number.isFinite(prefixLen) || prefixLen < 0 || prefixLen > 32) return null
|
||||||
|
const mask = prefixLen === 0 ? 0 : (0xffffffff << (32 - prefixLen)) >>> 0
|
||||||
|
return { net: (addr & mask) >>> 0, mask, prefixLen }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ipInCidrV4(ip: string, cidr: string): boolean {
|
||||||
|
const addr = ipv4ToInt(ip)
|
||||||
|
const parsed = parseCidrV4(cidr)
|
||||||
|
if (addr == null || !parsed) return false
|
||||||
|
return ((addr & parsed.mask) >>> 0) === parsed.net
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNonPublicIp(ip: string): boolean {
|
||||||
|
const trimmed = String(ip ?? "").trim()
|
||||||
|
if (!trimmed) return true
|
||||||
|
if (trimmed.includes(":")) {
|
||||||
|
const lower = trimmed.toLowerCase()
|
||||||
|
return lower === "::1" || lower.startsWith("fe80:") || lower.startsWith("fc") || lower.startsWith("fd") || lower === "::"
|
||||||
|
}
|
||||||
|
const n = ipv4ToInt(trimmed)
|
||||||
|
if (n == null) return true
|
||||||
|
const inRange = (cidr: string) => ipInCidrV4(trimmed, cidr)
|
||||||
|
return (
|
||||||
|
inRange("0.0.0.0/8")
|
||||||
|
|| inRange("10.0.0.0/8")
|
||||||
|
|| inRange("127.0.0.0/8")
|
||||||
|
|| inRange("169.254.0.0/16")
|
||||||
|
|| inRange("172.16.0.0/12")
|
||||||
|
|| inRange("192.168.0.0/16")
|
||||||
|
|| inRange("100.64.0.0/10")
|
||||||
|
|| inRange("224.0.0.0/4")
|
||||||
|
|| inRange("255.255.255.255/32")
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {
|
||||||
|
bareIpv4,
|
||||||
|
ipInCidr,
|
||||||
|
isNatMasqueradeExporter,
|
||||||
|
normalizeExporterIp,
|
||||||
|
pickServerIdForExporter,
|
||||||
|
} from "./traffic-flow-map-exporter.js"
|
||||||
|
|
||||||
|
assert.equal(normalizeExporterIp("::ffff:172.18.0.2"), "172.18.0.2")
|
||||||
|
assert.equal(bareIpv4("10.255.254.3/32"), "10.255.254.3")
|
||||||
|
assert.equal(ipInCidr("10.255.254.3", "10.255.254.0/24"), true)
|
||||||
|
assert.equal(ipInCidr("172.18.0.2", "10.255.254.0/24"), false)
|
||||||
|
assert.equal(isNatMasqueradeExporter("172.18.0.2", "10.255.254.0/24"), true)
|
||||||
|
assert.equal(isNatMasqueradeExporter("10.255.254.3", "10.255.254.0/24"), false)
|
||||||
|
assert.equal(isNatMasqueradeExporter("10.0.0.12", "10.255.254.0/24"), true)
|
||||||
|
|
||||||
|
const byTunnel = new Map([["10.255.254.3", 7]])
|
||||||
|
assert.equal(pickServerIdForExporter({
|
||||||
|
exporterIp: "10.255.254.3",
|
||||||
|
overlayPrefix: "10.255.254.0/24",
|
||||||
|
byTunnelIp: byTunnel,
|
||||||
|
peers: [],
|
||||||
|
hostIps: new Map(),
|
||||||
|
}), 7)
|
||||||
|
|
||||||
|
assert.equal(pickServerIdForExporter({
|
||||||
|
exporterIp: "172.18.0.2",
|
||||||
|
overlayPrefix: "10.255.254.0/24",
|
||||||
|
byTunnelIp: byTunnel,
|
||||||
|
peers: [{ serverId: 7, address: "10.255.254.3", allowedIps: ["10.255.254.3/32"] }],
|
||||||
|
hostIps: new Map(),
|
||||||
|
}), 7)
|
||||||
|
|
||||||
|
assert.equal(pickServerIdForExporter({
|
||||||
|
exporterIp: "172.18.0.2",
|
||||||
|
overlayPrefix: "10.255.254.0/24",
|
||||||
|
byTunnelIp: new Map([["10.255.254.3", 7], ["10.255.254.4", 8]]),
|
||||||
|
peers: [
|
||||||
|
{ serverId: 7, address: "10.255.254.3", allowedIps: ["10.255.254.3/32"] },
|
||||||
|
{ serverId: 8, address: "10.255.254.4", allowedIps: ["10.255.254.4/32"] },
|
||||||
|
],
|
||||||
|
hostIps: new Map(),
|
||||||
|
}), null)
|
||||||
|
|
||||||
|
assert.equal(pickServerIdForExporter({
|
||||||
|
exporterIp: "94.142.140.141",
|
||||||
|
overlayPrefix: "10.255.254.0/24",
|
||||||
|
byTunnelIp: byTunnel,
|
||||||
|
peers: [],
|
||||||
|
hostIps: new Map([["94.142.140.141", 7]]),
|
||||||
|
}), 7)
|
||||||
|
|
||||||
|
console.log("traffic-flow-map-exporter.test.ts: ok")
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
export interface OverlayPeerRef {
|
||||||
|
serverId: number
|
||||||
|
address: string
|
||||||
|
allowedIps: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeExporterIp(ip: string): string {
|
||||||
|
const trimmed = ip.trim()
|
||||||
|
if (trimmed.toLowerCase().startsWith("::ffff:")) return trimmed.slice(7)
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bareIpv4(value: string): string {
|
||||||
|
const raw = normalizeExporterIp(value).split("/")[0]?.trim() ?? ""
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
function ipv4ToInt(ip: string): number | null {
|
||||||
|
const parts = ip.split(".")
|
||||||
|
if (parts.length !== 4) return null
|
||||||
|
const n = parts.map((x) => Number(x))
|
||||||
|
if (n.some((x) => !Number.isInteger(x) || x < 0 || x > 255)) return null
|
||||||
|
return ((n[0]! << 24) | (n[1]! << 16) | (n[2]! << 8) | n[3]!) >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ipInCidr(ip: string, cidr: string): boolean {
|
||||||
|
const host = bareIpv4(ip)
|
||||||
|
const [base, bitsRaw] = cidr.split("/")
|
||||||
|
const bits = Number(bitsRaw ?? 32)
|
||||||
|
const a = ipv4ToInt(host)
|
||||||
|
const b = ipv4ToInt(bareIpv4(base ?? ""))
|
||||||
|
if (a == null || b == null || !Number.isFinite(bits) || bits < 0 || bits > 32) return false
|
||||||
|
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0
|
||||||
|
return (a & mask) === (b & mask)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Docker userland-proxy / bridge SNAT, не адрес из оверлея wg-flow. */
|
||||||
|
export function isNatMasqueradeExporter(ip: string, overlayPrefix: string): boolean {
|
||||||
|
const host = bareIpv4(ip)
|
||||||
|
if (!host) return false
|
||||||
|
if (ipInCidr(host, overlayPrefix)) return false
|
||||||
|
return ipInCidr(host, "10.0.0.0/8")
|
||||||
|
|| ipInCidr(host, "172.16.0.0/12")
|
||||||
|
|| ipInCidr(host, "192.168.0.0/16")
|
||||||
|
|| ipInCidr(host, "127.0.0.0/8")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickServerIdForExporter(opts: {
|
||||||
|
exporterIp: string
|
||||||
|
overlayPrefix: string
|
||||||
|
byTunnelIp: Map<string, number>
|
||||||
|
peers: OverlayPeerRef[]
|
||||||
|
hostIps: Map<string, number>
|
||||||
|
}): number | null {
|
||||||
|
const exporter = bareIpv4(opts.exporterIp)
|
||||||
|
if (!exporter) return null
|
||||||
|
|
||||||
|
const exact = opts.byTunnelIp.get(exporter)
|
||||||
|
if (exact != null) return exact
|
||||||
|
|
||||||
|
for (const [ip, id] of opts.byTunnelIp) {
|
||||||
|
if (bareIpv4(ip) === exporter) return id
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const peer of opts.peers) {
|
||||||
|
if (bareIpv4(peer.address) === exporter) return peer.serverId
|
||||||
|
if (peer.allowedIps.some((cidr) => ipInCidr(exporter, cidr) || bareIpv4(cidr) === exporter)) {
|
||||||
|
return peer.serverId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const byHost = opts.hostIps.get(exporter)
|
||||||
|
if (byHost != null) return byHost
|
||||||
|
|
||||||
|
if (!isNatMasqueradeExporter(exporter, opts.overlayPrefix)) return null
|
||||||
|
|
||||||
|
const tunnelIds = [...new Set(opts.byTunnelIp.values())]
|
||||||
|
if (tunnelIds.length === 1) return tunnelIds[0] ?? null
|
||||||
|
const peerIds = [...new Set(opts.peers.map((p) => p.serverId))]
|
||||||
|
if (peerIds.length === 1) return peerIds[0] ?? null
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { db } from "../db/index.js"
|
||||||
|
import { servers } from "../db/schema.js"
|
||||||
|
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
|
||||||
|
import { encodeRosId, MikrotikClient, MikrotikError } from "./mikrotik.js"
|
||||||
|
import { getEnabledServerById, listWireGuardInterfaces } from "./wireguard-live.js"
|
||||||
|
import {
|
||||||
|
asRosArray,
|
||||||
|
patchRosPath,
|
||||||
|
putIpAddress,
|
||||||
|
putWireguardInterface,
|
||||||
|
putWireguardPeer,
|
||||||
|
rosRowId,
|
||||||
|
toRosBody,
|
||||||
|
} from "./wireguard-ros.js"
|
||||||
|
import {
|
||||||
|
enableTrafficFlowIngest,
|
||||||
|
ensureHostKeys,
|
||||||
|
getTrafficFlowSettingsRow,
|
||||||
|
upsertHostPeer,
|
||||||
|
} from "./traffic-flow-settings.js"
|
||||||
|
import { startTrafficFlowListener } from "./traffic-flow-ingest.js"
|
||||||
|
import { listTrafficFlowHostFiles } from "./traffic-flow-host-files.js"
|
||||||
|
|
||||||
|
const IFACE_NAME = "wg-flow"
|
||||||
|
const JH_LISTEN_PORT = 13232
|
||||||
|
const WG_INPUT_COMMENT = "mm-wg-flow"
|
||||||
|
|
||||||
|
export function allocateOverlayAddress(prefix: string, collectorIp: string, serverId: number, taken: Set<string>): string {
|
||||||
|
const [base] = prefix.split("/")
|
||||||
|
const parts = (base ?? "10.255.254.0").split(".").map((n) => Number.parseInt(n, 10))
|
||||||
|
const a = parts[0] || 10
|
||||||
|
const b = parts[1] || 255
|
||||||
|
const c = parts[2] || 254
|
||||||
|
const preferredLast = 2 + ((serverId - 1) % 250)
|
||||||
|
const candidates = [preferredLast, ...Array.from({ length: 253 }, (_, i) => 2 + ((preferredLast - 2 + i) % 253))]
|
||||||
|
for (const last of candidates) {
|
||||||
|
const ip = `${a}.${b}.${c}.${last}`
|
||||||
|
if (ip === collectorIp) continue
|
||||||
|
if (taken.has(ip)) continue
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
throw new Error("Нет свободных адресов в префиксе wg-flow")
|
||||||
|
}
|
||||||
|
|
||||||
|
function linuxPeerBlock(publicKey: string, address: string, comment: string, endpoint: string): string {
|
||||||
|
return [
|
||||||
|
`[Peer]`,
|
||||||
|
`PublicKey = ${publicKey}`,
|
||||||
|
`AllowedIPs = ${address}/32`,
|
||||||
|
`Endpoint = ${endpoint}:${JH_LISTEN_PORT}`,
|
||||||
|
`PersistentKeepalive = 25`,
|
||||||
|
comment ? `# ${comment}` : "",
|
||||||
|
].filter(Boolean).join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findIface(client: MikrotikClient, name: string): Promise<Record<string, unknown> | undefined> {
|
||||||
|
const list = asRosArray<Record<string, unknown>>(await client.get("/interface/wireguard"))
|
||||||
|
return list.find((i) => String(i.name ?? "") === name)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findPeer(
|
||||||
|
client: MikrotikClient,
|
||||||
|
iface: string,
|
||||||
|
publicKey: string,
|
||||||
|
): Promise<Record<string, unknown> | undefined> {
|
||||||
|
const list = asRosArray<Record<string, unknown>>(await client.get("/interface/wireguard/peers"))
|
||||||
|
return list.find((p) =>
|
||||||
|
String(p.interface ?? "") === iface && String(p["public-key"] ?? "") === publicKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findAddress(client: MikrotikClient, iface: string): Promise<Record<string, unknown> | undefined> {
|
||||||
|
const list = asRosArray<Record<string, unknown>>(await client.get("/ip/address"))
|
||||||
|
return list.find((a) => String(a.interface ?? "") === iface)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findRoute(client: MikrotikClient, dst: string): Promise<Record<string, unknown> | undefined> {
|
||||||
|
const list = asRosArray<Record<string, unknown>>(await client.get("/ip/route"))
|
||||||
|
return list.find((r) => String(r["dst-address"] ?? "") === dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureWgInputAccept(client: MikrotikClient, listenPort: number): Promise<boolean> {
|
||||||
|
const rules = asRosArray<Record<string, unknown>>(await client.get("/ip/firewall/filter"))
|
||||||
|
const existing = rules.find((r) => String(r.comment ?? "") === WG_INPUT_COMMENT)
|
||||||
|
if (existing) return false
|
||||||
|
await client.put("/ip/firewall/filter", toRosBody({
|
||||||
|
chain: "input",
|
||||||
|
protocol: "udp",
|
||||||
|
"dst-port": String(listenPort),
|
||||||
|
action: "accept",
|
||||||
|
comment: WG_INPUT_COMMENT,
|
||||||
|
}))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Официальный авто-source UDP IPFIX, не фильтр 0.0.0.0/0. */
|
||||||
|
export const FLOW_TARGET_SRC_AUTO = "0.0.0.0"
|
||||||
|
|
||||||
|
async function ensureTrafficFlow(
|
||||||
|
client: MikrotikClient,
|
||||||
|
collectorIp: string,
|
||||||
|
port: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const body = toRosBody({
|
||||||
|
enabled: "yes",
|
||||||
|
interfaces: "all",
|
||||||
|
"active-flow-timeout": "1m",
|
||||||
|
"inactive-flow-timeout": "15s",
|
||||||
|
})
|
||||||
|
const rows = asRosArray<Record<string, unknown>>(await client.get("/ip/traffic-flow"))
|
||||||
|
const id = rows[0] ? rosRowId(rows[0]) : ""
|
||||||
|
if (id) {
|
||||||
|
await patchRosPath(client, `/ip/traffic-flow/${encodeRosId(id)}`, body)
|
||||||
|
} else {
|
||||||
|
await client.post("/ip/traffic-flow/set", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = asRosArray<Record<string, unknown>>(await client.get("/ip/traffic-flow/target"))
|
||||||
|
const existing = targets.find((t) => String(t["dst-address"] ?? "") === collectorIp)
|
||||||
|
const targetBody = toRosBody({
|
||||||
|
"dst-address": collectorIp,
|
||||||
|
"src-address": FLOW_TARGET_SRC_AUTO,
|
||||||
|
port: String(port),
|
||||||
|
version: "ipfix",
|
||||||
|
})
|
||||||
|
if (existing) {
|
||||||
|
const targetId = rosRowId(existing)
|
||||||
|
if (targetId) await patchRosPath(client, `/ip/traffic-flow/target/${encodeRosId(targetId)}`, targetBody)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await client.put("/ip/traffic-flow/target", targetBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usablePublicHost(raw: string | undefined): string {
|
||||||
|
if (!raw) return ""
|
||||||
|
const host = raw.split(",")[0]?.trim().replace(/^\[/, "").replace(/\]:\d+$/, "").split(":")[0]?.trim() ?? ""
|
||||||
|
const lower = host.toLowerCase()
|
||||||
|
if (!host) return ""
|
||||||
|
if (lower === "localhost" || lower === "127.0.0.1" || lower === "::1" || lower === "0.0.0.0") return ""
|
||||||
|
if (lower.endsWith(".local") || lower.endsWith(".internal") || lower.endsWith(".lan")) return ""
|
||||||
|
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host)) return ""
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyFlowOverlay(
|
||||||
|
serverIdRaw: string | number,
|
||||||
|
opts?: { publicEndpoint?: string; requestHost?: string },
|
||||||
|
): Promise<TrafficFlowOverlayResult> {
|
||||||
|
const steps: string[] = []
|
||||||
|
const keys = ensureHostKeys()
|
||||||
|
const settings = getTrafficFlowSettingsRow()
|
||||||
|
const hostPublicKey = settings.hostPublicKey || keys.publicKey
|
||||||
|
if (!hostPublicKey) {
|
||||||
|
throw Object.assign(new Error("Не удалось создать ключи хоста MM"), { statusCode: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = getEnabledServerById(String(serverIdRaw))
|
||||||
|
if (!server || !server.enabled) {
|
||||||
|
throw Object.assign(new Error("Сервер не найден или выключен"), { statusCode: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpointHost = (opts?.publicEndpoint?.trim() || server.host.trim()).trim()
|
||||||
|
if (!endpointHost) {
|
||||||
|
throw Object.assign(new Error("Укажите публичный IP или DNS jump-host"), { statusCode: 400 })
|
||||||
|
}
|
||||||
|
const peerEndpoint = `${endpointHost}:${JH_LISTEN_PORT}`
|
||||||
|
|
||||||
|
const taken = new Set(
|
||||||
|
db.select({ ip: servers.mgmtTunnelIp }).from(servers).all()
|
||||||
|
.map((r) => r.ip)
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
const address = server.mgmtTunnelIp || allocateOverlayAddress(settings.prefix, settings.collectorIp, server.id, taken)
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
|
||||||
|
try {
|
||||||
|
let iface = await findIface(client, IFACE_NAME)
|
||||||
|
if (!iface) {
|
||||||
|
await putWireguardInterface(client, {
|
||||||
|
name: IFACE_NAME,
|
||||||
|
"listen-port": String(JH_LISTEN_PORT),
|
||||||
|
mtu: "1420",
|
||||||
|
comment: "MikrotikManager traffic-flow overlay",
|
||||||
|
})
|
||||||
|
steps.push(`Создан интерфейс ${IFACE_NAME}`)
|
||||||
|
iface = await findIface(client, IFACE_NAME)
|
||||||
|
} else {
|
||||||
|
steps.push(`Интерфейс ${IFACE_NAME} уже есть`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addrRow = await findAddress(client, IFACE_NAME)
|
||||||
|
const mask = (settings.prefix.split("/")[1] || "24").replace(/\D/g, "") || "24"
|
||||||
|
const cidr = `${address}/${mask}`
|
||||||
|
if (!addrRow) {
|
||||||
|
await putIpAddress(client, cidr, IFACE_NAME)
|
||||||
|
steps.push(`Адрес ${cidr}`)
|
||||||
|
} else {
|
||||||
|
steps.push(`Адрес на ${IFACE_NAME} уже назначен`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const peer = await findPeer(client, IFACE_NAME, hostPublicKey)
|
||||||
|
const peerBody = {
|
||||||
|
interface: IFACE_NAME,
|
||||||
|
"public-key": hostPublicKey,
|
||||||
|
"allowed-address": `${settings.collectorIp}/32`,
|
||||||
|
comment: "MM traffic-flow collector",
|
||||||
|
name: "mm-collector",
|
||||||
|
}
|
||||||
|
if (!peer) {
|
||||||
|
await putWireguardPeer(client, peerBody)
|
||||||
|
steps.push("Добавлен пир на pubkey хоста MM (сервер, без endpoint)")
|
||||||
|
} else {
|
||||||
|
const id = rosRowId(peer)
|
||||||
|
const hadEndpoint = Boolean(String(peer["endpoint-address"] ?? "").trim())
|
||||||
|
if (hadEndpoint && id) {
|
||||||
|
await client.delete(`/interface/wireguard/peers/${encodeURIComponent(id)}`)
|
||||||
|
await putWireguardPeer(client, peerBody)
|
||||||
|
steps.push("Пир пересоздан как сервер (endpoint снят)")
|
||||||
|
} else if (id) {
|
||||||
|
await patchRosPath(client, `/interface/wireguard/peers/${encodeURIComponent(id)}`, peerBody)
|
||||||
|
steps.push("Пир хоста MM обновлён")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeDst = `${settings.collectorIp}/32`
|
||||||
|
const route = await findRoute(client, routeDst)
|
||||||
|
if (!route) {
|
||||||
|
await client.put("/ip/route", toRosBody({
|
||||||
|
"dst-address": routeDst,
|
||||||
|
gateway: IFACE_NAME,
|
||||||
|
comment: "MM traffic-flow collector",
|
||||||
|
}))
|
||||||
|
steps.push(`Маршрут ${routeDst} через ${IFACE_NAME}`)
|
||||||
|
} else {
|
||||||
|
steps.push("Маршрут до collector уже есть")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await ensureWgInputAccept(client, JH_LISTEN_PORT)) {
|
||||||
|
steps.push(`Firewall input accept UDP ${JH_LISTEN_PORT}`)
|
||||||
|
} else {
|
||||||
|
steps.push("Firewall input WG уже есть")
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureTrafficFlow(client, settings.collectorIp, settings.flowListenPort)
|
||||||
|
steps.push(`Traffic Flow → ${settings.collectorIp}:${settings.flowListenPort} ipfix (src auto)`)
|
||||||
|
|
||||||
|
const listed = await listWireGuardInterfaces({ serverId: String(server.id), includePrivateKey: false })
|
||||||
|
const created = listed.interfaces.find((i) => i.name === IFACE_NAME)
|
||||||
|
const publicKey = created?.publicKey ?? ""
|
||||||
|
if (!publicKey) {
|
||||||
|
throw new Error("Не удалось прочитать public-key интерфейса wg-flow")
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(servers).set({
|
||||||
|
mgmtTunnelIp: address,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}).where(eq(servers.id, server.id)).run()
|
||||||
|
|
||||||
|
upsertHostPeer({
|
||||||
|
serverId: server.id,
|
||||||
|
name: server.name || server.host,
|
||||||
|
publicKey,
|
||||||
|
allowedIps: [`${address}/32`],
|
||||||
|
address,
|
||||||
|
endpoint: peerEndpoint,
|
||||||
|
})
|
||||||
|
|
||||||
|
enableTrafficFlowIngest()
|
||||||
|
startTrafficFlowListener()
|
||||||
|
steps.push("Коллектор IPFIX на MM включён")
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
serverId: server.id,
|
||||||
|
interfaceName: IFACE_NAME,
|
||||||
|
address,
|
||||||
|
publicKey,
|
||||||
|
linuxPeerBlock: linuxPeerBlock(publicKey, address, server.name || server.host, endpointHost),
|
||||||
|
trafficFlow: true,
|
||||||
|
steps,
|
||||||
|
hostFiles: listTrafficFlowHostFiles(),
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
|
||||||
|
const err = Object.assign(new Error(`RouterOS: ${msg}`), { statusCode: 502 })
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { parseFlowPacket, protoName, resetFlowTemplatesForTests } from "./traffic-flow-parse.js"
|
||||||
|
import { allocateOverlayAddress, FLOW_TARGET_SRC_AUTO, usablePublicHost } from "./traffic-flow-overlay.js"
|
||||||
|
|
||||||
|
function netflowV5One(): Buffer {
|
||||||
|
const buf = Buffer.alloc(24 + 48)
|
||||||
|
buf.writeUInt16BE(5, 0)
|
||||||
|
buf.writeUInt16BE(1, 2)
|
||||||
|
buf[24] = 10; buf[25] = 1; buf[26] = 1; buf[27] = 8
|
||||||
|
buf[28] = 8; buf[29] = 8; buf[30] = 8; buf[31] = 8
|
||||||
|
buf.writeUInt16BE(1, 24 + 12)
|
||||||
|
buf.writeUInt32BE(10, 24 + 16)
|
||||||
|
buf.writeUInt32BE(1500, 24 + 20)
|
||||||
|
buf.writeUInt16BE(443, 24 + 32)
|
||||||
|
buf.writeUInt16BE(443, 24 + 34)
|
||||||
|
buf.writeUInt8(6, 24 + 38)
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFlowTemplatesForTests()
|
||||||
|
const flows = parseFlowPacket(netflowV5One(), "10.255.254.5")
|
||||||
|
assert.equal(flows.length, 1)
|
||||||
|
assert.equal(flows[0]?.src, "10.1.1.8")
|
||||||
|
assert.equal(flows[0]?.dst, "8.8.8.8")
|
||||||
|
assert.equal(flows[0]?.proto, 6)
|
||||||
|
assert.equal(flows[0]?.bytes, 1500)
|
||||||
|
assert.equal(flows[0]?.inIface, "1")
|
||||||
|
assert.equal(protoName(6), "TCP")
|
||||||
|
assert.equal(parseFlowPacket(Buffer.from([0, 1]), "1.1.1.1").length, 0)
|
||||||
|
|
||||||
|
const taken = new Set(["10.255.254.2"])
|
||||||
|
assert.equal(allocateOverlayAddress("10.255.254.0/24", "10.255.254.1", 1, taken), "10.255.254.3")
|
||||||
|
assert.equal(allocateOverlayAddress("10.255.254.0/24", "10.255.254.1", 2, new Set()), "10.255.254.3")
|
||||||
|
|
||||||
|
assert.equal(usablePublicHost("localhost:8000"), "")
|
||||||
|
assert.equal(usablePublicHost("127.0.0.1"), "")
|
||||||
|
assert.equal(usablePublicHost("192.168.1.10"), "")
|
||||||
|
assert.equal(usablePublicHost("mm.example.com:443"), "mm.example.com")
|
||||||
|
assert.equal(usablePublicHost("203.0.113.10"), "203.0.113.10")
|
||||||
|
assert.equal(FLOW_TARGET_SRC_AUTO, "0.0.0.0")
|
||||||
|
|
||||||
|
resetFlowTemplatesForTests()
|
||||||
|
{
|
||||||
|
const tpl = Buffer.alloc(16 + 16 + 20)
|
||||||
|
tpl.writeUInt16BE(10, 0)
|
||||||
|
tpl.writeUInt16BE(tpl.length, 2)
|
||||||
|
tpl.writeUInt16BE(2, 16)
|
||||||
|
tpl.writeUInt16BE(16, 18)
|
||||||
|
tpl.writeUInt16BE(256, 20)
|
||||||
|
tpl.writeUInt16BE(2, 22)
|
||||||
|
tpl.writeUInt16BE(8, 24)
|
||||||
|
tpl.writeUInt16BE(4, 26)
|
||||||
|
tpl.writeUInt16BE(12, 28)
|
||||||
|
tpl.writeUInt16BE(4, 30)
|
||||||
|
const data = Buffer.alloc(16 + 12)
|
||||||
|
data.writeUInt16BE(10, 0)
|
||||||
|
data.writeUInt16BE(data.length, 2)
|
||||||
|
data.writeUInt16BE(256, 16)
|
||||||
|
data.writeUInt16BE(12, 18)
|
||||||
|
data[20] = 10; data[21] = 1; data[22] = 1; data[23] = 8
|
||||||
|
data[24] = 8; data[25] = 8; data[26] = 8; data[27] = 8
|
||||||
|
const fromTpl = parseFlowPacket(tpl, "172.18.0.2")
|
||||||
|
assert.equal(fromTpl.length, 0)
|
||||||
|
const fromData = parseFlowPacket(data, "172.18.0.2")
|
||||||
|
assert.equal(fromData.length, 1)
|
||||||
|
assert.equal(fromData[0]?.src, "10.1.1.8")
|
||||||
|
assert.equal(fromData[0]?.dst, "8.8.8.8")
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFlowTemplatesForTests()
|
||||||
|
{
|
||||||
|
const tpl = Buffer.alloc(16 + 24)
|
||||||
|
tpl.writeUInt16BE(10, 0)
|
||||||
|
tpl.writeUInt16BE(tpl.length, 2)
|
||||||
|
tpl.writeUInt16BE(2, 16)
|
||||||
|
tpl.writeUInt16BE(24, 18)
|
||||||
|
tpl.writeUInt16BE(256, 20)
|
||||||
|
tpl.writeUInt16BE(4, 22)
|
||||||
|
tpl.writeUInt16BE(8, 24)
|
||||||
|
tpl.writeUInt16BE(4, 26)
|
||||||
|
tpl.writeUInt16BE(12, 28)
|
||||||
|
tpl.writeUInt16BE(4, 30)
|
||||||
|
tpl.writeUInt16BE(10, 32)
|
||||||
|
tpl.writeUInt16BE(4, 34)
|
||||||
|
tpl.writeUInt16BE(82, 36)
|
||||||
|
tpl.writeUInt16BE(6, 38)
|
||||||
|
const data = Buffer.alloc(16 + 22)
|
||||||
|
data.writeUInt16BE(10, 0)
|
||||||
|
data.writeUInt16BE(data.length, 2)
|
||||||
|
data.writeUInt16BE(256, 16)
|
||||||
|
data.writeUInt16BE(22, 18)
|
||||||
|
data[20] = 10; data[21] = 1; data[22] = 1; data[23] = 8
|
||||||
|
data[24] = 8; data[25] = 8; data[26] = 8; data[27] = 8
|
||||||
|
data.writeUInt32BE(13, 28)
|
||||||
|
data.write("ether1", 32)
|
||||||
|
parseFlowPacket(tpl, "10.255.254.3")
|
||||||
|
const named = parseFlowPacket(data, "10.255.254.3")
|
||||||
|
assert.equal(named.length, 1)
|
||||||
|
assert.equal(named[0]?.inIface, "ether1")
|
||||||
|
assert.equal(named[0]?.src, "10.1.1.8")
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("traffic-flow-parse.test.ts: ok")
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
export interface ParsedFlow {
|
||||||
|
src: string
|
||||||
|
dst: string
|
||||||
|
proto: number
|
||||||
|
srcPort: number
|
||||||
|
dstPort: number
|
||||||
|
bytes: number
|
||||||
|
packets: number
|
||||||
|
inIface: string
|
||||||
|
outIface: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldSpec {
|
||||||
|
type: number
|
||||||
|
length: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Template {
|
||||||
|
fields: FieldSpec[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const templatesByExporter = new Map<string, Map<number, Template>>()
|
||||||
|
|
||||||
|
function ipv4(buf: Buffer, offset: number): string {
|
||||||
|
return `${buf[offset]}.${buf[offset + 1]}.${buf[offset + 2]}.${buf[offset + 3]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function ipv6(buf: Buffer, offset: number): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
for (let i = 0; i < 8; i++) parts.push(buf.readUInt16BE(offset + i * 2).toString(16))
|
||||||
|
return parts.join(":")
|
||||||
|
}
|
||||||
|
|
||||||
|
const VAR_LEN = 0xffff
|
||||||
|
|
||||||
|
function consumeField(
|
||||||
|
buf: Buffer,
|
||||||
|
off: number,
|
||||||
|
length: number,
|
||||||
|
limit: number,
|
||||||
|
): { data: Buffer; next: number } | null {
|
||||||
|
if (length === VAR_LEN) {
|
||||||
|
if (off >= limit) return null
|
||||||
|
const first = buf[off]!
|
||||||
|
if (first < 255) {
|
||||||
|
const end = off + 1 + first
|
||||||
|
if (end > limit) return null
|
||||||
|
return { data: buf.subarray(off + 1, end), next: end }
|
||||||
|
}
|
||||||
|
if (off + 3 > limit) return null
|
||||||
|
const len = buf.readUInt16BE(off + 1)
|
||||||
|
const end = off + 3 + len
|
||||||
|
if (end > limit) return null
|
||||||
|
return { data: buf.subarray(off + 3, end), next: end }
|
||||||
|
}
|
||||||
|
const end = off + length
|
||||||
|
if (end > limit) return null
|
||||||
|
return { data: buf.subarray(off, end), next: end }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixedRecordSize(fields: FieldSpec[]): number | null {
|
||||||
|
let n = 0
|
||||||
|
for (const f of fields) {
|
||||||
|
if (f.length === VAR_LEN) return null
|
||||||
|
n += f.length
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUint(buf: Buffer, offset: number, length: number): number {
|
||||||
|
if (length === 1) return buf.readUInt8(offset)
|
||||||
|
if (length === 2) return buf.readUInt16BE(offset)
|
||||||
|
if (length === 4) return buf.readUInt32BE(offset)
|
||||||
|
if (length === 8) {
|
||||||
|
const big = buf.readBigUInt64BE(offset)
|
||||||
|
const n = Number(big)
|
||||||
|
return Number.isFinite(n) ? n : 0
|
||||||
|
}
|
||||||
|
let v = 0
|
||||||
|
for (let i = 0; i < length; i++) v = (v << 8) + buf[offset + i]
|
||||||
|
return v >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNetflowV5(buf: Buffer): ParsedFlow[] {
|
||||||
|
if (buf.length < 24) return []
|
||||||
|
const count = buf.readUInt16BE(2)
|
||||||
|
const out: ParsedFlow[] = []
|
||||||
|
let off = 24
|
||||||
|
for (let i = 0; i < count && off + 48 <= buf.length; i++) {
|
||||||
|
out.push({
|
||||||
|
src: ipv4(buf, off),
|
||||||
|
dst: ipv4(buf, off + 4),
|
||||||
|
packets: buf.readUInt32BE(off + 16),
|
||||||
|
bytes: buf.readUInt32BE(off + 20),
|
||||||
|
srcPort: buf.readUInt16BE(off + 32),
|
||||||
|
dstPort: buf.readUInt16BE(off + 34),
|
||||||
|
proto: buf.readUInt8(off + 38),
|
||||||
|
inIface: String(buf.readUInt16BE(off + 12)),
|
||||||
|
outIface: String(buf.readUInt16BE(off + 14)),
|
||||||
|
})
|
||||||
|
off += 48
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIpfixTemplates(exporter: string, buf: Buffer, setStart: number, setEnd: number, setId: number) {
|
||||||
|
let off = setStart + 4
|
||||||
|
const map = templatesByExporter.get(exporter) ?? new Map<number, Template>()
|
||||||
|
while (off + 4 <= setEnd) {
|
||||||
|
const templateId = buf.readUInt16BE(off)
|
||||||
|
const fieldCount = buf.readUInt16BE(off + 2)
|
||||||
|
off += 4
|
||||||
|
if (setId === 3) {
|
||||||
|
// options template: skip scope count
|
||||||
|
if (off + 2 > setEnd) break
|
||||||
|
off += 2
|
||||||
|
}
|
||||||
|
const fields: FieldSpec[] = []
|
||||||
|
for (let i = 0; i < fieldCount && off + 4 <= setEnd; i++) {
|
||||||
|
const type = buf.readUInt16BE(off)
|
||||||
|
const length = buf.readUInt16BE(off + 2)
|
||||||
|
off += 4
|
||||||
|
if (type & 0x8000) {
|
||||||
|
if (off + 4 > setEnd) break
|
||||||
|
off += 4
|
||||||
|
}
|
||||||
|
fields.push({ type: type & 0x7fff, length })
|
||||||
|
}
|
||||||
|
if (templateId >= 256) map.set(templateId, { fields })
|
||||||
|
}
|
||||||
|
templatesByExporter.set(exporter, map)
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordFromFields(
|
||||||
|
fields: FieldSpec[],
|
||||||
|
buf: Buffer,
|
||||||
|
offset: number,
|
||||||
|
limit: number,
|
||||||
|
): { flow: ParsedFlow; next: number } | null {
|
||||||
|
let off = offset
|
||||||
|
let src = ""
|
||||||
|
let dst = ""
|
||||||
|
let proto = 0
|
||||||
|
let srcPort = 0
|
||||||
|
let dstPort = 0
|
||||||
|
let bytes = 0
|
||||||
|
let packets = 0
|
||||||
|
let inIface = ""
|
||||||
|
let outIface = ""
|
||||||
|
let ifaceName = ""
|
||||||
|
for (const f of fields) {
|
||||||
|
const field = consumeField(buf, off, f.length, limit)
|
||||||
|
if (!field) return null
|
||||||
|
const { data } = field
|
||||||
|
switch (f.type) {
|
||||||
|
case 8:
|
||||||
|
if (data.length === 4) src = ipv4(data, 0)
|
||||||
|
break
|
||||||
|
case 12:
|
||||||
|
if (data.length === 4) dst = ipv4(data, 0)
|
||||||
|
break
|
||||||
|
case 27:
|
||||||
|
if (data.length === 16 && !src) src = ipv6(data, 0)
|
||||||
|
break
|
||||||
|
case 28:
|
||||||
|
if (data.length === 16 && !dst) dst = ipv6(data, 0)
|
||||||
|
break
|
||||||
|
case 225:
|
||||||
|
if (data.length === 4 && !src) src = ipv4(data, 0)
|
||||||
|
break
|
||||||
|
case 226:
|
||||||
|
if (data.length === 4 && !dst) dst = ipv4(data, 0)
|
||||||
|
break
|
||||||
|
case 4:
|
||||||
|
proto = readUint(data, 0, data.length)
|
||||||
|
break
|
||||||
|
case 7:
|
||||||
|
srcPort = readUint(data, 0, data.length)
|
||||||
|
break
|
||||||
|
case 11:
|
||||||
|
dstPort = readUint(data, 0, data.length)
|
||||||
|
break
|
||||||
|
case 1:
|
||||||
|
bytes = readUint(data, 0, data.length)
|
||||||
|
break
|
||||||
|
case 2:
|
||||||
|
packets = readUint(data, 0, data.length)
|
||||||
|
break
|
||||||
|
case 85:
|
||||||
|
if (!bytes) bytes = readUint(data, 0, data.length)
|
||||||
|
break
|
||||||
|
case 86:
|
||||||
|
if (!packets) packets = readUint(data, 0, data.length)
|
||||||
|
break
|
||||||
|
case 10:
|
||||||
|
inIface = String(readUint(data, 0, data.length))
|
||||||
|
break
|
||||||
|
case 14:
|
||||||
|
outIface = String(readUint(data, 0, data.length))
|
||||||
|
break
|
||||||
|
case 82:
|
||||||
|
ifaceName = data.toString("utf8").replace(/\0/g, "").trim()
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
off = field.next
|
||||||
|
}
|
||||||
|
if (ifaceName) inIface = ifaceName
|
||||||
|
return { flow: { src, dst, proto, srcPort, dstPort, bytes, packets, inIface, outIface }, next: off }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDataRecords(
|
||||||
|
tpl: Template,
|
||||||
|
buf: Buffer,
|
||||||
|
recOff: number,
|
||||||
|
setEnd: number,
|
||||||
|
out: ParsedFlow[],
|
||||||
|
) {
|
||||||
|
const size = fixedRecordSize(tpl.fields)
|
||||||
|
while (recOff + 1 < setEnd) {
|
||||||
|
if (size != null && recOff + size > setEnd) break
|
||||||
|
const parsed = recordFromFields(tpl.fields, buf, recOff, setEnd)
|
||||||
|
if (!parsed) break
|
||||||
|
if (parsed.flow.src || parsed.flow.dst) out.push(parsed.flow)
|
||||||
|
if (parsed.next <= recOff) break
|
||||||
|
recOff = parsed.next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIpfix(buf: Buffer, exporter: string): ParsedFlow[] {
|
||||||
|
if (buf.length < 16) return []
|
||||||
|
const total = buf.readUInt16BE(2)
|
||||||
|
const end = Math.min(buf.length, total)
|
||||||
|
let off = 16
|
||||||
|
const out: ParsedFlow[] = []
|
||||||
|
while (off + 4 <= end) {
|
||||||
|
const setId = buf.readUInt16BE(off)
|
||||||
|
const setLen = buf.readUInt16BE(off + 2)
|
||||||
|
if (setLen < 4 || off + setLen > end) break
|
||||||
|
const setEnd = off + setLen
|
||||||
|
if (setId === 2 || setId === 3) {
|
||||||
|
parseIpfixTemplates(exporter, buf, off, setEnd, setId)
|
||||||
|
} else if (setId >= 256) {
|
||||||
|
const tpl = templatesByExporter.get(exporter)?.get(setId)
|
||||||
|
if (tpl) parseDataRecords(tpl, buf, off + 4, setEnd, out)
|
||||||
|
}
|
||||||
|
off = setEnd
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNetflowV9(buf: Buffer, exporter: string): ParsedFlow[] {
|
||||||
|
if (buf.length < 20) return []
|
||||||
|
const count = buf.readUInt16BE(2)
|
||||||
|
let off = 20
|
||||||
|
const out: ParsedFlow[] = []
|
||||||
|
const map = templatesByExporter.get(exporter) ?? new Map<number, Template>()
|
||||||
|
for (let s = 0; s < count && off + 4 <= buf.length; s++) {
|
||||||
|
const setId = buf.readUInt16BE(off)
|
||||||
|
const setLen = buf.readUInt16BE(off + 2)
|
||||||
|
if (setLen < 4 || off + setLen > buf.length) break
|
||||||
|
const setEnd = off + setLen
|
||||||
|
if (setId === 0) {
|
||||||
|
let tOff = off + 4
|
||||||
|
while (tOff + 4 <= setEnd) {
|
||||||
|
const templateId = buf.readUInt16BE(tOff)
|
||||||
|
const fieldCount = buf.readUInt16BE(tOff + 2)
|
||||||
|
tOff += 4
|
||||||
|
const fields: FieldSpec[] = []
|
||||||
|
for (let i = 0; i < fieldCount && tOff + 4 <= setEnd; i++) {
|
||||||
|
fields.push({ type: buf.readUInt16BE(tOff), length: buf.readUInt16BE(tOff + 2) })
|
||||||
|
tOff += 4
|
||||||
|
}
|
||||||
|
if (templateId >= 256) map.set(templateId, { fields })
|
||||||
|
}
|
||||||
|
templatesByExporter.set(exporter, map)
|
||||||
|
} else if (setId >= 256) {
|
||||||
|
const tpl = map.get(setId)
|
||||||
|
if (tpl) parseDataRecords(tpl, buf, off + 4, setEnd, out)
|
||||||
|
}
|
||||||
|
off = setEnd
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFlowPacket(buf: Buffer, exporterIp: string): ParsedFlow[] {
|
||||||
|
if (buf.length < 2) return []
|
||||||
|
const version = buf.readUInt16BE(0)
|
||||||
|
if (version === 5) return parseNetflowV5(buf)
|
||||||
|
if (version === 9) return parseNetflowV9(buf, exporterIp)
|
||||||
|
if (version === 10) return parseIpfix(buf, exporterIp)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function protoName(proto: number): string {
|
||||||
|
switch (proto) {
|
||||||
|
case 1: return "ICMP"
|
||||||
|
case 6: return "TCP"
|
||||||
|
case 17: return "UDP"
|
||||||
|
case 47: return "GRE"
|
||||||
|
case 50: return "ESP"
|
||||||
|
case 89: return "OSPF"
|
||||||
|
default: return String(proto)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetFlowTemplatesForTests() {
|
||||||
|
templatesByExporter.clear()
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {
|
||||||
|
disableRipeEnqueueForTests,
|
||||||
|
disableRipePersistForTests,
|
||||||
|
enqueueRipeMisses,
|
||||||
|
flushRipeQueueForTests,
|
||||||
|
lookupRipeCached,
|
||||||
|
resetRipeCacheForTests,
|
||||||
|
ripeFetchCountForTests,
|
||||||
|
seedRipeCacheForTests,
|
||||||
|
setRipeFetchForTests,
|
||||||
|
} from "./traffic-flow-ripe.js"
|
||||||
|
|
||||||
|
disableRipePersistForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipeEnqueueForTests()
|
||||||
|
|
||||||
|
assert.equal(lookupRipeCached("10.1.1.8")?.ok, false)
|
||||||
|
assert.equal(lookupRipeCached("192.168.0.1")?.ok, false)
|
||||||
|
assert.equal(lookupRipeCached("100.64.1.2")?.ok, false)
|
||||||
|
assert.equal(ripeFetchCountForTests(), 0)
|
||||||
|
|
||||||
|
seedRipeCacheForTests({
|
||||||
|
prefix: "1.2.3.0/24",
|
||||||
|
asn: 64500,
|
||||||
|
country: "NL",
|
||||||
|
lat: 52.3,
|
||||||
|
lng: 4.9,
|
||||||
|
holder: "TEST",
|
||||||
|
ok: true,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
})
|
||||||
|
assert.equal(lookupRipeCached("1.2.3.10")?.country, "NL")
|
||||||
|
assert.equal(lookupRipeCached("1.2.3.10")?.asn, 64500)
|
||||||
|
assert.equal(ripeFetchCountForTests(), 0)
|
||||||
|
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipePersistForTests()
|
||||||
|
setRipeFetchForTests(async (input) => {
|
||||||
|
const url = String(input)
|
||||||
|
const body = url.includes("network-info")
|
||||||
|
? { data: { prefix: "8.8.8.0/24", asns: ["15169"] } }
|
||||||
|
: url.includes("maxmind-geo-lite")
|
||||||
|
? { data: { located_resources: [{ locations: [{ country: "US", latitude: 37.4, longitude: -122.1 }] }] } }
|
||||||
|
: { data: { holder: "GOOGLE" } }
|
||||||
|
return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } })
|
||||||
|
})
|
||||||
|
enqueueRipeMisses(["8.8.8.8"])
|
||||||
|
await flushRipeQueueForTests()
|
||||||
|
assert.equal(lookupRipeCached("8.8.8.8")?.country, "US")
|
||||||
|
assert.equal(lookupRipeCached("8.8.8.10")?.prefix, "8.8.8.0/24")
|
||||||
|
const afterFirst = ripeFetchCountForTests()
|
||||||
|
assert.ok(afterFirst >= 2)
|
||||||
|
enqueueRipeMisses(["8.8.8.10"])
|
||||||
|
await flushRipeQueueForTests()
|
||||||
|
assert.equal(ripeFetchCountForTests(), afterFirst)
|
||||||
|
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipePersistForTests()
|
||||||
|
setRipeFetchForTests(async () => {
|
||||||
|
throw new Error("timeout")
|
||||||
|
})
|
||||||
|
enqueueRipeMisses(["203.0.113.50"])
|
||||||
|
await flushRipeQueueForTests()
|
||||||
|
const neg = lookupRipeCached("203.0.113.50")
|
||||||
|
assert.equal(neg?.ok, false)
|
||||||
|
const afterNeg = ripeFetchCountForTests()
|
||||||
|
enqueueRipeMisses(["203.0.113.50"])
|
||||||
|
await flushRipeQueueForTests()
|
||||||
|
assert.equal(ripeFetchCountForTests(), afterNeg)
|
||||||
|
|
||||||
|
console.log("traffic-flow-ripe.test.ts: ok")
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import { sqliteDatabase } from "../db/index.js"
|
||||||
|
import { ipInCidrV4, ipv4ToInt, isNonPublicIp, parseCidrV4 } from "./traffic-flow-ip.js"
|
||||||
|
|
||||||
|
export interface FlowIpMeta {
|
||||||
|
prefix: string
|
||||||
|
asn: number
|
||||||
|
country: string
|
||||||
|
lat: number | null
|
||||||
|
lng: number | null
|
||||||
|
holder: string
|
||||||
|
ok: boolean
|
||||||
|
fetchedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const HIT_TTL_MS = 24 * 60 * 60_000
|
||||||
|
const NEG_TTL_MS = 6 * 60 * 60_000
|
||||||
|
const MAX_NEW_PREFIX_PER_MIN = 30
|
||||||
|
const CONCURRENCY = 3
|
||||||
|
const RIPE_BASE = "https://stat.ripe.net/data"
|
||||||
|
const UA = "MikrotikManager-flow/1.0"
|
||||||
|
|
||||||
|
const mem = new Map<string, FlowIpMeta>()
|
||||||
|
const asnHolder = new Map<number, { holder: string; fetchedAt: number }>()
|
||||||
|
const inflight = new Map<string, Promise<FlowIpMeta | null>>()
|
||||||
|
const queue: string[] = []
|
||||||
|
const queued = new Set<string>()
|
||||||
|
const recentFetches: number[] = []
|
||||||
|
|
||||||
|
let persistEnabled = true
|
||||||
|
let enqueueEnabled = true
|
||||||
|
let loaded = false
|
||||||
|
let workerRunning = false
|
||||||
|
let fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis)
|
||||||
|
let fetchCount = 0
|
||||||
|
|
||||||
|
export function disableRipePersistForTests(): void {
|
||||||
|
persistEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disableRipeEnqueueForTests(): void {
|
||||||
|
enqueueEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetRipeCacheForTests(): void {
|
||||||
|
mem.clear()
|
||||||
|
asnHolder.clear()
|
||||||
|
inflight.clear()
|
||||||
|
queue.length = 0
|
||||||
|
queued.clear()
|
||||||
|
recentFetches.length = 0
|
||||||
|
loaded = persistEnabled ? false : true
|
||||||
|
workerRunning = false
|
||||||
|
fetchCount = 0
|
||||||
|
enqueueEnabled = true
|
||||||
|
fetchImpl = globalThis.fetch.bind(globalThis)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seedRipeCacheForTests(entry: FlowIpMeta): void {
|
||||||
|
mem.set(entry.prefix, { ...entry })
|
||||||
|
loaded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRipeFetchForTests(fn: typeof fetch): void {
|
||||||
|
fetchImpl = fn
|
||||||
|
fetchCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ripeFetchCountForTests(): number {
|
||||||
|
return fetchCount
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function flushRipeQueueForTests(timeoutMs = 4000): Promise<void> {
|
||||||
|
const start = Date.now()
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
if (!queue.length && !inflight.size && !workerRunning) return
|
||||||
|
await new Promise((r) => setTimeout(r, 20))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ttlMs(ok: boolean): number {
|
||||||
|
return ok ? HIT_TTL_MS : NEG_TTL_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFresh(entry: FlowIpMeta): boolean {
|
||||||
|
return Date.now() - entry.fetchedAt < ttlMs(entry.ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSqlite(): void {
|
||||||
|
if (loaded || !persistEnabled) {
|
||||||
|
loaded = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loaded = true
|
||||||
|
try {
|
||||||
|
const rows = sqliteDatabase.prepare(`
|
||||||
|
SELECT prefix, asn, country, lat, lng, holder, ok, fetched_at
|
||||||
|
FROM flow_ip_meta
|
||||||
|
`).all() as Array<{
|
||||||
|
prefix: string
|
||||||
|
asn: number | null
|
||||||
|
country: string
|
||||||
|
lat: number | null
|
||||||
|
lng: number | null
|
||||||
|
holder: string
|
||||||
|
ok: number
|
||||||
|
fetched_at: string
|
||||||
|
}>
|
||||||
|
for (const r of rows) {
|
||||||
|
const fetchedAt = Date.parse(r.fetched_at)
|
||||||
|
mem.set(r.prefix, {
|
||||||
|
prefix: r.prefix,
|
||||||
|
asn: Number(r.asn ?? 0) || 0,
|
||||||
|
country: r.country || "—",
|
||||||
|
lat: r.lat == null ? null : Number(r.lat),
|
||||||
|
lng: r.lng == null ? null : Number(r.lng),
|
||||||
|
holder: r.holder || "",
|
||||||
|
ok: r.ok !== 0,
|
||||||
|
fetchedAt: Number.isFinite(fetchedAt) ? fetchedAt : 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const asns = sqliteDatabase.prepare(`SELECT asn, holder, fetched_at FROM flow_asn_meta`).all() as Array<{
|
||||||
|
asn: number
|
||||||
|
holder: string
|
||||||
|
fetched_at: string
|
||||||
|
}>
|
||||||
|
for (const a of asns) {
|
||||||
|
const fetchedAt = Date.parse(a.fetched_at)
|
||||||
|
asnHolder.set(a.asn, { holder: a.holder || "", fetchedAt: Number.isFinite(fetchedAt) ? fetchedAt : 0 })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* table may not exist in isolated tests */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist(entry: FlowIpMeta): void {
|
||||||
|
if (!persistEnabled) return
|
||||||
|
try {
|
||||||
|
sqliteDatabase.prepare(`
|
||||||
|
INSERT INTO flow_ip_meta (prefix, asn, country, lat, lng, holder, ok, fetched_at)
|
||||||
|
VALUES (@prefix, @asn, @country, @lat, @lng, @holder, @ok, @fetchedAt)
|
||||||
|
ON CONFLICT(prefix) DO UPDATE SET
|
||||||
|
asn=excluded.asn, country=excluded.country, lat=excluded.lat, lng=excluded.lng,
|
||||||
|
holder=excluded.holder, ok=excluded.ok, fetched_at=excluded.fetched_at
|
||||||
|
`).run({
|
||||||
|
prefix: entry.prefix,
|
||||||
|
asn: entry.asn,
|
||||||
|
country: entry.country,
|
||||||
|
lat: entry.lat,
|
||||||
|
lng: entry.lng,
|
||||||
|
holder: entry.holder,
|
||||||
|
ok: entry.ok ? 1 : 0,
|
||||||
|
fetchedAt: new Date(entry.fetchedAt).toISOString(),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
/* ignore persist errors */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistAsn(asn: number, holder: string): void {
|
||||||
|
if (!persistEnabled || !asn) return
|
||||||
|
try {
|
||||||
|
sqliteDatabase.prepare(`
|
||||||
|
INSERT INTO flow_asn_meta (asn, holder, fetched_at)
|
||||||
|
VALUES (@asn, @holder, @fetchedAt)
|
||||||
|
ON CONFLICT(asn) DO UPDATE SET holder=excluded.holder, fetched_at=excluded.fetched_at
|
||||||
|
`).run({
|
||||||
|
asn,
|
||||||
|
holder,
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function negative(prefix: string): FlowIpMeta {
|
||||||
|
return {
|
||||||
|
prefix,
|
||||||
|
asn: 0,
|
||||||
|
country: "—",
|
||||||
|
lat: null,
|
||||||
|
lng: null,
|
||||||
|
holder: "",
|
||||||
|
ok: false,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lookupRipeCached(ip: string): FlowIpMeta | null {
|
||||||
|
loadSqlite()
|
||||||
|
const trimmed = String(ip ?? "").trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
if (isNonPublicIp(trimmed)) {
|
||||||
|
return negative(`${trimmed.includes(":") ? trimmed : trimmed}/32`)
|
||||||
|
}
|
||||||
|
let best: FlowIpMeta | null = null
|
||||||
|
let bestLen = -1
|
||||||
|
for (const entry of mem.values()) {
|
||||||
|
if (!isFresh(entry)) continue
|
||||||
|
const parsed = parseCidrV4(entry.prefix)
|
||||||
|
if (!parsed) continue
|
||||||
|
if (!ipInCidrV4(trimmed, entry.prefix)) continue
|
||||||
|
if (parsed.prefixLen > bestLen) {
|
||||||
|
best = entry
|
||||||
|
bestLen = parsed.prefixLen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ripeJson(path: string, resource: string): Promise<unknown> {
|
||||||
|
fetchCount += 1
|
||||||
|
const url = `${RIPE_BASE}/${path}/data.json?resource=${encodeURIComponent(resource)}`
|
||||||
|
const ac = new AbortController()
|
||||||
|
const t = setTimeout(() => ac.abort(), 12_000)
|
||||||
|
try {
|
||||||
|
const res = await fetchImpl(url, {
|
||||||
|
headers: { Accept: "application/json", "User-Agent": UA },
|
||||||
|
signal: ac.signal,
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return await res.json()
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickPrefix(data: unknown): string {
|
||||||
|
const d = data as { data?: { prefix?: string } }
|
||||||
|
return String(d?.data?.prefix ?? "").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickAsns(data: unknown): number {
|
||||||
|
const d = data as { data?: { asns?: unknown } }
|
||||||
|
const raw = d?.data?.asns
|
||||||
|
const first = Array.isArray(raw) ? raw[0] : raw
|
||||||
|
const n = Number.parseInt(String(first ?? "").replace(/^AS/i, ""), 10)
|
||||||
|
return Number.isFinite(n) ? n : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickGeo(data: unknown): { country: string; lat: number | null; lng: number | null } {
|
||||||
|
const d = data as {
|
||||||
|
data?: {
|
||||||
|
located_resources?: Array<{
|
||||||
|
locations?: Array<{ country?: string; latitude?: number; longitude?: number }>
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const loc = d?.data?.located_resources?.[0]?.locations?.[0]
|
||||||
|
const country = String(loc?.country ?? "").trim().toUpperCase()
|
||||||
|
const lat = loc?.latitude == null ? null : Number(loc.latitude)
|
||||||
|
const lng = loc?.longitude == null ? null : Number(loc.longitude)
|
||||||
|
return {
|
||||||
|
country: country || "—",
|
||||||
|
lat: Number.isFinite(lat) ? lat : null,
|
||||||
|
lng: Number.isFinite(lng) ? lng : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickHolder(data: unknown): string {
|
||||||
|
const d = data as { data?: { holder?: string } }
|
||||||
|
return String(d?.data?.holder ?? "").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowNewPrefix(): boolean {
|
||||||
|
const now = Date.now()
|
||||||
|
while (recentFetches.length && now - recentFetches[0]! > 60_000) recentFetches.shift()
|
||||||
|
return recentFetches.length < MAX_NEW_PREFIX_PER_MIN
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveIp(ip: string): Promise<FlowIpMeta | null> {
|
||||||
|
const cached = lookupRipeCached(ip)
|
||||||
|
if (cached) return cached
|
||||||
|
const pending = inflight.get(ip)
|
||||||
|
if (pending) return pending
|
||||||
|
|
||||||
|
const job = (async () => {
|
||||||
|
if (!allowNewPrefix()) return null
|
||||||
|
recentFetches.push(Date.now())
|
||||||
|
try {
|
||||||
|
const net = await ripeJson("network-info", ip)
|
||||||
|
const prefix = pickPrefix(net) || `${ip}/32`
|
||||||
|
const existing = mem.get(prefix)
|
||||||
|
if (existing && isFresh(existing)) return existing
|
||||||
|
const asn = pickAsns(net)
|
||||||
|
let geo = { country: "—", lat: null as number | null, lng: null as number | null }
|
||||||
|
try {
|
||||||
|
geo = pickGeo(await ripeJson("maxmind-geo-lite", prefix))
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
let holder = asnHolder.get(asn)?.holder ?? ""
|
||||||
|
if (asn && (!holder || Date.now() - (asnHolder.get(asn)?.fetchedAt ?? 0) > HIT_TTL_MS)) {
|
||||||
|
try {
|
||||||
|
holder = pickHolder(await ripeJson("as-overview", `AS${asn}`))
|
||||||
|
asnHolder.set(asn, { holder, fetchedAt: Date.now() })
|
||||||
|
persistAsn(asn, holder)
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const entry: FlowIpMeta = {
|
||||||
|
prefix,
|
||||||
|
asn,
|
||||||
|
country: geo.country,
|
||||||
|
lat: geo.lat,
|
||||||
|
lng: geo.lng,
|
||||||
|
holder,
|
||||||
|
ok: Boolean(asn || (geo.country && geo.country !== "—")),
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
}
|
||||||
|
mem.set(prefix, entry)
|
||||||
|
persist(entry)
|
||||||
|
return entry
|
||||||
|
} catch {
|
||||||
|
const prefix = `${ip}/32`
|
||||||
|
const entry = negative(prefix)
|
||||||
|
mem.set(prefix, entry)
|
||||||
|
persist(entry)
|
||||||
|
return entry
|
||||||
|
} finally {
|
||||||
|
inflight.delete(ip)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
inflight.set(ip, job)
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWorker(): Promise<void> {
|
||||||
|
if (workerRunning) return
|
||||||
|
workerRunning = true
|
||||||
|
try {
|
||||||
|
while (queue.length) {
|
||||||
|
const batch: string[] = []
|
||||||
|
while (batch.length < CONCURRENCY && queue.length) {
|
||||||
|
const ip = queue.shift()
|
||||||
|
if (!ip) break
|
||||||
|
queued.delete(ip)
|
||||||
|
if (lookupRipeCached(ip)) continue
|
||||||
|
if (ipv4ToInt(ip) == null && !ip.includes(":")) continue
|
||||||
|
batch.push(ip)
|
||||||
|
}
|
||||||
|
if (!batch.length) {
|
||||||
|
if (!allowNewPrefix()) {
|
||||||
|
await new Promise((r) => setTimeout(r, 1000))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await Promise.all(batch.map((ip) => resolveIp(ip)))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
workerRunning = false
|
||||||
|
if (queue.length) void runWorker()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTTP / SSE never await this — cache miss is filled on a later tick. */
|
||||||
|
export function enqueueRipeMisses(ips: Iterable<string>): void {
|
||||||
|
if (!enqueueEnabled) return
|
||||||
|
loadSqlite()
|
||||||
|
for (const raw of ips) {
|
||||||
|
const ip = String(raw ?? "").trim()
|
||||||
|
if (!ip || isNonPublicIp(ip)) continue
|
||||||
|
if (lookupRipeCached(ip)) continue
|
||||||
|
if (queued.has(ip) || inflight.has(ip)) continue
|
||||||
|
queued.add(ip)
|
||||||
|
queue.push(ip)
|
||||||
|
}
|
||||||
|
if (queue.length) void runWorker()
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { db } from "../db/index.js"
|
||||||
|
import { trafficFlowSettings } from "../db/schema.js"
|
||||||
|
import type { FlowHostPeer, TrafficFlowSettingsDto, TrafficFlowSettingsPatch } from "@mmapp/contracts/traffic-flow"
|
||||||
|
import { generateWireGuardKeyPair } from "./wg-keys.js"
|
||||||
|
|
||||||
|
function nowIso() {
|
||||||
|
return new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePeers(raw: string): FlowHostPeer[] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
if (!Array.isArray(parsed)) return []
|
||||||
|
return parsed.filter((p): p is FlowHostPeer =>
|
||||||
|
p != null && typeof p === "object" && typeof (p as FlowHostPeer).publicKey === "string",
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTrafficFlowSettingsRow() {
|
||||||
|
const row = db.select().from(trafficFlowSettings).where(eq(trafficFlowSettings.id, 1)).limit(1).all()[0]
|
||||||
|
if (row) return row
|
||||||
|
const now = nowIso()
|
||||||
|
db.insert(trafficFlowSettings).values({
|
||||||
|
id: 1,
|
||||||
|
enabled: false,
|
||||||
|
collectorIp: "10.255.254.1",
|
||||||
|
flowListenPort: 4739,
|
||||||
|
wgListenPort: 51821,
|
||||||
|
prefix: "10.255.254.0/24",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}).run()
|
||||||
|
return db.select().from(trafficFlowSettings).where(eq(trafficFlowSettings.id, 1)).limit(1).all()[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toTrafficFlowSettingsDto(
|
||||||
|
listener: { bound: boolean; address: string | null },
|
||||||
|
): TrafficFlowSettingsDto {
|
||||||
|
const row = getTrafficFlowSettingsRow()
|
||||||
|
return {
|
||||||
|
enabled: row.enabled,
|
||||||
|
collectorIp: row.collectorIp,
|
||||||
|
flowListenPort: row.flowListenPort,
|
||||||
|
wgListenPort: row.wgListenPort,
|
||||||
|
prefix: row.prefix,
|
||||||
|
publicEndpoint: row.publicEndpoint,
|
||||||
|
hostPublicKey: row.hostPublicKey,
|
||||||
|
hasHostPrivateKey: Boolean(row.hostPrivateKey),
|
||||||
|
hubServerId: row.hubServerId ?? null,
|
||||||
|
retentionHours: row.retentionHours,
|
||||||
|
topN: row.topN,
|
||||||
|
lastDatagramAt: row.lastDatagramAt ?? null,
|
||||||
|
lastExporterIp: row.lastExporterIp ?? null,
|
||||||
|
lastError: row.lastError || null,
|
||||||
|
packetsReceived: row.packetsReceived,
|
||||||
|
listenerBound: listener.bound,
|
||||||
|
listenerAddress: listener.address,
|
||||||
|
peers: parsePeers(row.peersJson),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTrafficFlowSettings(patch: TrafficFlowSettingsPatch) {
|
||||||
|
const row = getTrafficFlowSettingsRow()
|
||||||
|
db.update(trafficFlowSettings).set({
|
||||||
|
enabled: patch.enabled ?? row.enabled,
|
||||||
|
collectorIp: patch.collectorIp ?? row.collectorIp,
|
||||||
|
flowListenPort: patch.flowListenPort ?? row.flowListenPort,
|
||||||
|
wgListenPort: patch.wgListenPort ?? row.wgListenPort,
|
||||||
|
prefix: patch.prefix ?? row.prefix,
|
||||||
|
publicEndpoint: patch.publicEndpoint ?? row.publicEndpoint,
|
||||||
|
hubServerId: patch.hubServerId === undefined ? row.hubServerId : patch.hubServerId,
|
||||||
|
retentionHours: patch.retentionHours ?? row.retentionHours,
|
||||||
|
topN: patch.topN ?? row.topN,
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
return getTrafficFlowSettingsRow()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureHostKeys(): { publicKey: string; created: boolean } {
|
||||||
|
const row = getTrafficFlowSettingsRow()
|
||||||
|
if (row.hostPublicKey && row.hostPrivateKey) {
|
||||||
|
return { publicKey: row.hostPublicKey, created: false }
|
||||||
|
}
|
||||||
|
const keys = generateWireGuardKeyPair()
|
||||||
|
db.update(trafficFlowSettings).set({
|
||||||
|
hostPublicKey: keys.publicKey,
|
||||||
|
hostPrivateKey: keys.privateKey,
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
return { publicKey: keys.publicKey, created: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertHostPeer(peer: FlowHostPeer) {
|
||||||
|
const row = getTrafficFlowSettingsRow()
|
||||||
|
const peers = parsePeers(row.peersJson).filter((p) => p.serverId !== peer.serverId)
|
||||||
|
peers.push(peer)
|
||||||
|
db.update(trafficFlowSettings).set({
|
||||||
|
peersJson: JSON.stringify(peers),
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordFlowPacket(exporterIp: string) {
|
||||||
|
const row = getTrafficFlowSettingsRow()
|
||||||
|
db.update(trafficFlowSettings).set({
|
||||||
|
lastDatagramAt: nowIso(),
|
||||||
|
lastExporterIp: exporterIp,
|
||||||
|
packetsReceived: row.packetsReceived + 1,
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordFlowListenerError(message: string) {
|
||||||
|
db.update(trafficFlowSettings).set({
|
||||||
|
lastError: message,
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enableTrafficFlowIngest() {
|
||||||
|
db.update(trafficFlowSettings).set({
|
||||||
|
enabled: true,
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listHostPeers(): FlowHostPeer[] {
|
||||||
|
return parsePeers(getTrafficFlowSettingsRow().peersJson)
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {
|
||||||
|
bpsToMbps,
|
||||||
|
bucketAvg,
|
||||||
|
buildTrafficFromSamples,
|
||||||
|
isLoopbackName,
|
||||||
|
mergeBuiltTraffic,
|
||||||
|
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)
|
||||||
|
|
||||||
|
const boundOnly = buildTrafficFromSamples(samples, start, end, ["ether1", "lo"])
|
||||||
|
assert.ok(boundOnly.rxPeak > 0, "bound list includes ether1")
|
||||||
|
assert.ok(boundOnly.rxPeak > built.rxPeak, "lo included when listed")
|
||||||
|
|
||||||
|
const merged = mergeBuiltTraffic([
|
||||||
|
buildTrafficFromSamples(samples, start, end, "ether1"),
|
||||||
|
buildTrafficFromSamples(samples, start, end, "ether1"),
|
||||||
|
])
|
||||||
|
assert.equal(merged.rxNow, built.rxNow * 2)
|
||||||
|
|
||||||
|
const wgSamples: TrafficSampleLike[] = [
|
||||||
|
{ interfaceName: "wg-msk", sampledAt: t0, rxBytes: 2_000_000, txBytes: 1_000_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
{ interfaceName: "wg-msk", sampledAt: t1, rxBytes: 2_000_000 + 1_875_000, txBytes: 1_000_000 + 937_500, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
{ interfaceName: "wg-msk", sampledAt: t2, rxBytes: 50, txBytes: 25, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
]
|
||||||
|
const userAgg = mergeBuiltTraffic([
|
||||||
|
buildTrafficFromSamples(samples, start, end, "ether1"),
|
||||||
|
buildTrafficFromSamples(wgSamples, start, end, "wg-msk"),
|
||||||
|
])
|
||||||
|
const listed = buildTrafficFromSamples([...samples, ...wgSamples], start, end, ["ether1", "wg-msk"])
|
||||||
|
assert.equal(userAgg.rxNow, listed.rxNow, "сумма привязанных ifaces = фильтр по списку имён")
|
||||||
|
assert.ok(userAgg.rxNow > built.rxNow, "агрегация пользователя больше одного iface")
|
||||||
|
|
||||||
|
const peerA: TrafficSampleLike[] = [
|
||||||
|
{ interfaceName: "wg-server", peerPublicKey: "peer-a", sampledAt: t0, rxBytes: 1_000_000, txBytes: 100_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
{ interfaceName: "wg-server", peerPublicKey: "peer-a", sampledAt: t1, rxBytes: 1_000_000 + 3_750_000, txBytes: 100_000 + 375_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
]
|
||||||
|
const peerB: TrafficSampleLike[] = [
|
||||||
|
{ interfaceName: "wg-server", peerPublicKey: "peer-b", sampledAt: t0, rxBytes: 500_000, txBytes: 50_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
{ interfaceName: "wg-server", peerPublicKey: "peer-b", sampledAt: t1, rxBytes: 500_000 + 1_875_000, txBytes: 50_000 + 187_500, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
]
|
||||||
|
const ifaceWg: TrafficSampleLike[] = [
|
||||||
|
{ interfaceName: "wg-server", sampledAt: t0, rxBytes: 10_000_000, txBytes: 2_000_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
{ interfaceName: "wg-server", sampledAt: t1, rxBytes: 10_000_000 + 7_500_000, txBytes: 2_000_000 + 750_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||||
|
]
|
||||||
|
const mixed = [...peerA, ...peerB, ...ifaceWg]
|
||||||
|
const rateA = buildTrafficFromSamples(mixed, start, end, "wg-server", "peer-a")
|
||||||
|
const rateB = buildTrafficFromSamples(mixed, start, end, "wg-server", "peer-b")
|
||||||
|
const rateIface = buildTrafficFromSamples(mixed, start, end, "wg-server")
|
||||||
|
assert.ok(rateA.rxNow > 0 && rateB.rxNow > 0, "скорость по каждому пиру")
|
||||||
|
assert.notEqual(rateA.rxNow, rateB.rxNow, "два пира одного iface — разный rate")
|
||||||
|
assert.ok(rateIface.rxNow > rateA.rxNow, "iface-level не суммирует пиров")
|
||||||
|
assert.equal(
|
||||||
|
buildTrafficFromSamples(mixed, start, end).rxNow,
|
||||||
|
rateIface.rxNow,
|
||||||
|
"режим сервера игнорирует семплы пиров",
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log("traffic-rate tests ok")
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
/** Чистые формулы трафика: дельты счётчиков, корзины series, monitor-traffic. */
|
||||||
|
|
||||||
|
export const SERIES_POINTS = 60
|
||||||
|
|
||||||
|
export interface TrafficSampleLike {
|
||||||
|
interfaceName: string
|
||||||
|
peerPublicKey?: 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 sampleSeriesKey(interfaceName: string, peerPublicKey = ""): string {
|
||||||
|
return `${interfaceName}\0${peerPublicKey}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTrafficFromSamples(
|
||||||
|
rows: TrafficSampleLike[],
|
||||||
|
rangeStartMs: number,
|
||||||
|
rangeEndMs: number,
|
||||||
|
onlyInterface?: string | readonly string[],
|
||||||
|
peerPublicKey?: 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 bySeries = new Map<string, TrafficSampleLike[]>()
|
||||||
|
for (const r of rows) {
|
||||||
|
const peer = r.peerPublicKey ?? ""
|
||||||
|
const arr = bySeries.get(sampleSeriesKey(r.interfaceName, peer)) ?? []
|
||||||
|
arr.push(r)
|
||||||
|
bySeries.set(sampleSeriesKey(r.interfaceName, peer), arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowList = Array.isArray(onlyInterface)
|
||||||
|
? onlyInterface
|
||||||
|
: (typeof onlyInterface === "string" ? [onlyInterface] : null)
|
||||||
|
|
||||||
|
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 [key, arr] of bySeries) {
|
||||||
|
const sep = key.indexOf("\0")
|
||||||
|
const name = sep >= 0 ? key.slice(0, sep) : key
|
||||||
|
const peer = sep >= 0 ? key.slice(sep + 1) : ""
|
||||||
|
if (allowList) {
|
||||||
|
if (!allowList.includes(name)) continue
|
||||||
|
} else if (isLoopbackName(name)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (peerPublicKey === undefined) {
|
||||||
|
if (peer !== "") continue
|
||||||
|
} else if (peer !== peerPublicKey) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
|
||||||
|
const last = sorted[sorted.length - 1]
|
||||||
|
if (!last) continue
|
||||||
|
if (!allowList && (!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 (!allowList && (!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 mergeBuiltTraffic(parts: BuiltTrafficSeries[]): 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 (parts.length === 0) return empty
|
||||||
|
const acc = {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
for (const p of parts) {
|
||||||
|
acc.rxNow += p.rxNow
|
||||||
|
acc.txNow += p.txNow
|
||||||
|
acc.rxPeak += p.rxPeak
|
||||||
|
acc.txPeak += p.txPeak
|
||||||
|
acc.rxTotalGiB += p.rxTotalGiB
|
||||||
|
acc.txTotalGiB += p.txTotalGiB
|
||||||
|
acc.sessions += p.sessions
|
||||||
|
for (let i = 0; i < SERIES_POINTS; i++) {
|
||||||
|
acc.rxSeries[i] += p.rxSeries[i] ?? 0
|
||||||
|
acc.txSeries[i] += p.txSeries[i] ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
acc.rxTotalGiB = Number(acc.rxTotalGiB.toFixed(1))
|
||||||
|
acc.txTotalGiB = Number(acc.txTotalGiB.toFixed(1))
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { db } from "../db/index.js"
|
||||||
|
import { servers } from "../db/schema.js"
|
||||||
|
import { listUsers } from "../modules/users/service/users-service.js"
|
||||||
|
import { readServerSamplesInRange } from "./traffic-collector.js"
|
||||||
|
import {
|
||||||
|
buildTrafficFromSamples,
|
||||||
|
mergeBuiltTraffic,
|
||||||
|
type BuiltTrafficSeries,
|
||||||
|
} from "./traffic-rate.js"
|
||||||
|
|
||||||
|
export interface BoundIfaceTrafficDto {
|
||||||
|
id: string
|
||||||
|
bindingId: string
|
||||||
|
userId: string
|
||||||
|
userLogin: string
|
||||||
|
userName: string
|
||||||
|
interfaceName: string
|
||||||
|
interfaceType: string
|
||||||
|
peerPublicKey: string
|
||||||
|
peerName: string
|
||||||
|
comment: string
|
||||||
|
serverId: string
|
||||||
|
serverName: string
|
||||||
|
serverSite: string
|
||||||
|
serverCountry: string
|
||||||
|
rxNow: number
|
||||||
|
txNow: number
|
||||||
|
rxPeak: number
|
||||||
|
txPeak: number
|
||||||
|
rxTotal: number
|
||||||
|
txTotal: number
|
||||||
|
rxSeries: number[]
|
||||||
|
txSeries: number[]
|
||||||
|
status: "online" | "offline"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserTrafficDto {
|
||||||
|
id: string
|
||||||
|
login: string
|
||||||
|
displayName: string
|
||||||
|
role: string
|
||||||
|
active: boolean
|
||||||
|
interfaces: BoundIfaceTrafficDto[]
|
||||||
|
rxNow: number
|
||||||
|
txNow: number
|
||||||
|
rxPeak: number
|
||||||
|
txPeak: number
|
||||||
|
rxTotal: number
|
||||||
|
txTotal: number
|
||||||
|
rxSeries: number[]
|
||||||
|
txSeries: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function seriesFromBuilt(built: BuiltTrafficSeries) {
|
||||||
|
return {
|
||||||
|
rxNow: built.rxNow,
|
||||||
|
txNow: built.txNow,
|
||||||
|
rxPeak: built.rxPeak,
|
||||||
|
txPeak: built.txPeak,
|
||||||
|
rxTotal: built.rxTotalGiB,
|
||||||
|
txTotal: built.txTotalGiB,
|
||||||
|
rxSeries: built.rxSeries,
|
||||||
|
txSeries: built.txSeries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serverStatus(serverId: number): "online" | "offline" {
|
||||||
|
const row = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
||||||
|
if (!row?.enabled) return "offline"
|
||||||
|
return "online"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUserTrafficList(rangeStartMs: number, rangeEndMs: number): UserTrafficDto[] {
|
||||||
|
const sinceIso = new Date(rangeStartMs).toISOString()
|
||||||
|
const users = listUsers()
|
||||||
|
const sampleCache = new Map<number, ReturnType<typeof readServerSamplesInRange>>()
|
||||||
|
|
||||||
|
return users.map((user) => {
|
||||||
|
const parts: BuiltTrafficSeries[] = []
|
||||||
|
const interfaces: BoundIfaceTrafficDto[] = []
|
||||||
|
|
||||||
|
for (const b of user.bindings) {
|
||||||
|
let rows = sampleCache.get(b.serverId)
|
||||||
|
if (!rows) {
|
||||||
|
rows = readServerSamplesInRange(b.serverId, sinceIso)
|
||||||
|
sampleCache.set(b.serverId, rows)
|
||||||
|
}
|
||||||
|
const peerKey = b.peerPublicKey ?? ""
|
||||||
|
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, b.interfaceName, peerKey)
|
||||||
|
parts.push(built)
|
||||||
|
const last = [...rows.filter((r) =>
|
||||||
|
r.interfaceName === b.interfaceName && (r.peerPublicKey ?? "") === peerKey,
|
||||||
|
)]
|
||||||
|
.sort((a, c) => a.sampledAt.localeCompare(c.sampledAt))
|
||||||
|
.at(-1)
|
||||||
|
const running = Boolean(last?.running) && !last?.disabled
|
||||||
|
interfaces.push({
|
||||||
|
id: `${b.userId}:${b.serverId}:${b.interfaceName}:${peerKey || "_iface"}`,
|
||||||
|
bindingId: b.id,
|
||||||
|
userId: user.id,
|
||||||
|
userLogin: user.login,
|
||||||
|
userName: user.name,
|
||||||
|
interfaceName: b.interfaceName,
|
||||||
|
interfaceType: b.interfaceType,
|
||||||
|
peerPublicKey: peerKey,
|
||||||
|
peerName: b.peerName ?? "",
|
||||||
|
comment: b.comment,
|
||||||
|
serverId: String(b.serverId),
|
||||||
|
serverName: b.serverName,
|
||||||
|
serverSite: b.serverSite,
|
||||||
|
serverCountry: b.serverCountry,
|
||||||
|
...seriesFromBuilt(built),
|
||||||
|
status: running && serverStatus(b.serverId) === "online" ? "online" : "offline",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = mergeBuiltTraffic(parts)
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
login: user.login,
|
||||||
|
displayName: user.name,
|
||||||
|
role: user.role,
|
||||||
|
active: user.active,
|
||||||
|
interfaces,
|
||||||
|
...seriesFromBuilt(merged),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBoundInterfaceTraffic(rangeStartMs: number, rangeEndMs: number): BoundIfaceTrafficDto[] {
|
||||||
|
return buildUserTrafficList(rangeStartMs, rangeEndMs).flatMap((u) => u.interfaces)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { generateKeyPairSync } from "node:crypto"
|
||||||
|
|
||||||
|
/** WireGuard Curve25519 keypair as RouterOS/wg-quick base64 (32 bytes). */
|
||||||
|
export function generateWireGuardKeyPair(): { publicKey: string; privateKey: string } {
|
||||||
|
const { publicKey, privateKey } = generateKeyPairSync("x25519")
|
||||||
|
const pubDer = publicKey.export({ type: "spki", format: "der" })
|
||||||
|
const privDer = privateKey.export({ type: "pkcs8", format: "der" })
|
||||||
|
return {
|
||||||
|
publicKey: Buffer.from(pubDer.subarray(-32)).toString("base64"),
|
||||||
|
privateKey: Buffer.from(privDer.subarray(-32)).toString("base64"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ export type WgParsedConfig = {
|
|||||||
|
|
||||||
export type WgExportIface = {
|
export type WgExportIface = {
|
||||||
name: string
|
name: string
|
||||||
listenPort: number
|
listenPort?: number
|
||||||
mtu: number
|
mtu: number
|
||||||
comment?: string
|
comment?: string
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
@@ -258,7 +258,7 @@ export function generateNativeConf(iface: WgExportIface, opts?: { includePrivate
|
|||||||
lines.push(`# PrivateKey = <заполните приватный ключ с роутера>`)
|
lines.push(`# PrivateKey = <заполните приватный ключ с роутера>`)
|
||||||
}
|
}
|
||||||
if (iface.address) lines.push(`Address = ${iface.address}`)
|
if (iface.address) lines.push(`Address = ${iface.address}`)
|
||||||
lines.push(`ListenPort = ${iface.listenPort}`)
|
if (iface.listenPort) lines.push(`ListenPort = ${iface.listenPort}`)
|
||||||
if (iface.mtu) lines.push(`MTU = ${iface.mtu}`)
|
if (iface.mtu) lines.push(`MTU = ${iface.mtu}`)
|
||||||
lines.push(``)
|
lines.push(``)
|
||||||
|
|
||||||
@@ -312,7 +312,7 @@ export function generateMikrotikRsc(iface: WgExportIface): string {
|
|||||||
lines.push(``)
|
lines.push(``)
|
||||||
lines.push(`/interface wireguard add \\`)
|
lines.push(`/interface wireguard add \\`)
|
||||||
lines.push(` name=${iface.name} \\`)
|
lines.push(` name=${iface.name} \\`)
|
||||||
lines.push(` listen-port=${iface.listenPort} \\`)
|
lines.push(` listen-port=${iface.listenPort ?? 13231} \\`)
|
||||||
lines.push(` mtu=${iface.mtu} \\`)
|
lines.push(` mtu=${iface.mtu} \\`)
|
||||||
if (iface.privateKey) lines.push(` private-key="${iface.privateKey}" \\`)
|
if (iface.privateKey) lines.push(` private-key="${iface.privateKey}" \\`)
|
||||||
if (iface.comment) lines.push(` comment="${iface.comment.replace(/"/g, '\\"')}" \\`)
|
if (iface.comment) lines.push(` comment="${iface.comment.replace(/"/g, '\\"')}" \\`)
|
||||||
|
|||||||
@@ -211,4 +211,51 @@ export function getEnabledServerById(serverId: string | number): ServerRow | nul
|
|||||||
return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0] ?? null
|
return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CatalogWgPeer = {
|
||||||
|
interfaceName: string
|
||||||
|
publicKey: string
|
||||||
|
name: string
|
||||||
|
comment: string
|
||||||
|
allowedIps: string[]
|
||||||
|
latestHandshake?: string
|
||||||
|
disabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const WG_CATALOG_TIMEOUT_MS = 5_000
|
||||||
|
|
||||||
|
export async function listWireGuardPeersForCatalog(serverId: number): Promise<{
|
||||||
|
peers: CatalogWgPeer[]
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
const row = getEnabledServerById(serverId)
|
||||||
|
if (!row) return { peers: [], error: "Сервер не найден" }
|
||||||
|
try {
|
||||||
|
const client = MikrotikClient.fromServer(row)
|
||||||
|
const peersRaw = await Promise.race([
|
||||||
|
client.get<RosWireGuardPeer[]>("/interface/wireguard/peers"),
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error("Таймаут RouterOS")), WG_CATALOG_TIMEOUT_MS)
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
const peers: CatalogWgPeer[] = peersRaw.flatMap((p, idx) => {
|
||||||
|
const mapped = mapPeer(p, idx)
|
||||||
|
const interfaceName = (p.interface ?? "").trim()
|
||||||
|
const publicKey = mapped.publicKey.trim()
|
||||||
|
if (!interfaceName || !publicKey) return []
|
||||||
|
return [{
|
||||||
|
interfaceName,
|
||||||
|
publicKey,
|
||||||
|
name: mapped.name ?? "",
|
||||||
|
comment: mapped.comment ?? "",
|
||||||
|
allowedIps: mapped.allowedIps,
|
||||||
|
latestHandshake: mapped.latestHandshake,
|
||||||
|
disabled: mapped.disabled === true,
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
return { peers }
|
||||||
|
} catch (e) {
|
||||||
|
return { peers: [], error: e instanceof Error ? e.message : String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export { type RosWireGuard, type RosWireGuardPeer }
|
export { type RosWireGuard, type RosWireGuardPeer }
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { MikrotikClient } from "./mikrotik.js"
|
||||||
|
|
||||||
|
/** Общие PUT iface / peer / address для `/wireguard` и traffic-flow overlay. */
|
||||||
|
export 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
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asRosArray<T>(raw: unknown): T[] {
|
||||||
|
if (Array.isArray(raw)) return raw as T[]
|
||||||
|
if (raw && typeof raw === "object") return [raw as T]
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rosRowId(row: Record<string, unknown>): string {
|
||||||
|
return String(row[".id"] ?? row.id ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putWireguardInterface(
|
||||||
|
client: MikrotikClient,
|
||||||
|
fields: Record<string, string | undefined>,
|
||||||
|
): Promise<void> {
|
||||||
|
await client.put("/interface/wireguard", toRosBody(fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putIpAddress(
|
||||||
|
client: MikrotikClient,
|
||||||
|
address: string,
|
||||||
|
iface: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await client.put("/ip/address", { address, interface: iface })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putWireguardPeer(
|
||||||
|
client: MikrotikClient,
|
||||||
|
fields: Record<string, string | undefined>,
|
||||||
|
): Promise<void> {
|
||||||
|
await client.put("/interface/wireguard/peers", toRosBody(fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchRosPath(
|
||||||
|
client: MikrotikClient,
|
||||||
|
path: string,
|
||||||
|
fields: Record<string, string | undefined>,
|
||||||
|
): Promise<void> {
|
||||||
|
await client.patch(path, toRosBody(fields))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
BoxIcon,
|
BoxIcon,
|
||||||
BadgeCheckIcon,
|
BadgeCheckIcon,
|
||||||
|
UsersIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||||
@@ -70,6 +71,7 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
|
|||||||
label: "Управление",
|
label: "Управление",
|
||||||
items: [
|
items: [
|
||||||
{ title: "Серверы", url: "/servers", icon: <ServerIcon /> },
|
{ title: "Серверы", url: "/servers", icon: <ServerIcon /> },
|
||||||
|
{ title: "Пользователи", url: "/users", icon: <UsersIcon /> },
|
||||||
{ title: "Фильтры", url: "/filters", icon: <FilterIcon /> },
|
{ title: "Фильтры", url: "/filters", icon: <FilterIcon /> },
|
||||||
{ title: "Рекурсивные маршруты", url: "/recursive-routes", icon: <RouteIcon /> },
|
{ title: "Рекурсивные маршруты", url: "/recursive-routes", icon: <RouteIcon /> },
|
||||||
{ title: "Firewall", url: "/firewall", icon: <ShieldIcon /> },
|
{ title: "Firewall", url: "/firewall", icon: <ShieldIcon /> },
|
||||||
@@ -102,7 +104,7 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number; wireguard?: number }
|
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number; wireguard?: number; users?: number }
|
||||||
|
|
||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
@@ -161,6 +163,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
if (!liveCounts) return undefined
|
if (!liveCounts) return undefined
|
||||||
|
|
||||||
if (url === "/servers") return formatSidebarBadgeCount(liveCounts.servers)
|
if (url === "/servers") return formatSidebarBadgeCount(liveCounts.servers)
|
||||||
|
if (url === "/users") return formatSidebarBadgeCount(liveCounts.users ?? 0)
|
||||||
if (url === "/filters") return formatSidebarBadgeCount(liveCounts.filterRules)
|
if (url === "/filters") return formatSidebarBadgeCount(liveCounts.filterRules)
|
||||||
if (url === "/uptime") return formatSidebarBadgeCount(liveCounts.monitoringItems)
|
if (url === "/uptime") return formatSidebarBadgeCount(liveCounts.monitoringItems)
|
||||||
if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0)
|
if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
GlobeIcon, NetworkIcon, LayersIcon, TagIcon, ServerIcon, FilterIcon,
|
GlobeIcon, NetworkIcon, LayersIcon, TagIcon, ServerIcon, FilterIcon,
|
||||||
ShieldIcon, ShieldCheckIcon, CableIcon, BoxIcon, BadgeCheckIcon,
|
ShieldIcon, ShieldCheckIcon, CableIcon, BoxIcon, BadgeCheckIcon,
|
||||||
HardDriveIcon, RouteIcon, GitForkIcon, GitMergeIcon, ScanLineIcon,
|
HardDriveIcon, RouteIcon, GitForkIcon, GitMergeIcon, ScanLineIcon,
|
||||||
TerminalIcon, BellIcon, SettingsIcon, DatabaseIcon,
|
TerminalIcon, BellIcon, SettingsIcon, DatabaseIcon, UsersIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
// ─── Command item definition ──────────────────────────────────────────────────
|
// ─── Command item definition ──────────────────────────────────────────────────
|
||||||
@@ -36,6 +36,7 @@ const ALL_ITEMS: CommandItem[] = [
|
|||||||
{ id: "communities", title: "BGP Communities", group: "Данные", url: "/communities", icon: <TagIcon />, keywords: ["community","bgp","теги"] },
|
{ id: "communities", title: "BGP Communities", group: "Данные", url: "/communities", icon: <TagIcon />, keywords: ["community","bgp","теги"] },
|
||||||
// Управление
|
// Управление
|
||||||
{ id: "servers", title: "Серверы", group: "Управление", url: "/servers", icon: <ServerIcon />, keywords: ["router","mikrotik","сервер","routeros"] },
|
{ id: "servers", title: "Серверы", group: "Управление", url: "/servers", icon: <ServerIcon />, keywords: ["router","mikrotik","сервер","routeros"] },
|
||||||
|
{ id: "users", title: "Пользователи", group: "Управление", url: "/users", icon: <UsersIcon />, keywords: ["user","клиент","оператор","привязка","интерфейс"] },
|
||||||
{ id: "filters", title: "Фильтры", group: "Управление", url: "/filters", icon: <FilterIcon />, keywords: ["filter","routing","маршрутизация"] },
|
{ id: "filters", title: "Фильтры", group: "Управление", url: "/filters", icon: <FilterIcon />, keywords: ["filter","routing","маршрутизация"] },
|
||||||
{ id: "recursive-routes", title: "Рекурсивные маршруты", group: "Управление", url: "/recursive-routes", icon: <RouteIcon />, keywords: ["recursive","route","static","маршруты"] },
|
{ id: "recursive-routes", title: "Рекурсивные маршруты", group: "Управление", url: "/recursive-routes", icon: <RouteIcon />, keywords: ["recursive","route","static","маршруты"] },
|
||||||
{ id: "firewall", title: "Firewall", group: "Управление", url: "/firewall", icon: <ShieldIcon />, keywords: ["rules","правила","брандмауэр","acl"] },
|
{ id: "firewall", title: "Firewall", group: "Управление", url: "/firewall", icon: <ShieldIcon />, keywords: ["rules","правила","брандмауэр","acl"] },
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
import type { SchedulerJobStatusDto } from "@/lib/scheduler-settings"
|
import type { SchedulerJobStatusDto } from "@/lib/scheduler-settings"
|
||||||
import { FormToggle } from "@/components/form-kit"
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/reui/badge"
|
||||||
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 { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useMemo } from "react"
|
|
||||||
import type { Server } from "@/lib/data"
|
|
||||||
import { CompactDataGrid, type CompactDataGridColumn } from "@/components/data-grids/compact-data-grid"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
type PermLevel = "none" | "read" | "write"
|
|
||||||
type Role = "admin" | "operator" | "viewer"
|
|
||||||
|
|
||||||
interface SectionPerm {
|
|
||||||
section: string
|
|
||||||
level: PermLevel
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServerPerm {
|
|
||||||
serverId: string
|
|
||||||
level: PermLevel
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AccessSummaryUser {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
avatar: string
|
|
||||||
active: boolean
|
|
||||||
role: Role
|
|
||||||
sections: SectionPerm[]
|
|
||||||
servers: ServerPerm[]
|
|
||||||
}
|
|
||||||
|
|
||||||
function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"size-7 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0",
|
|
||||||
active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{avatar}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SettingsAccessSummaryDataGridProps {
|
|
||||||
users: AccessSummaryUser[]
|
|
||||||
servers: Server[]
|
|
||||||
allSectionsCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettingsAccessSummaryDataGrid({
|
|
||||||
users,
|
|
||||||
servers,
|
|
||||||
allSectionsCount,
|
|
||||||
}: SettingsAccessSummaryDataGridProps) {
|
|
||||||
const columns = useMemo<CompactDataGridColumn<AccessSummaryUser>[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
id: "name",
|
|
||||||
header: "Пользователь",
|
|
||||||
accessorKey: "name",
|
|
||||||
cell: (u) => (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<AvatarCircle avatar={u.avatar} active={u.active} />
|
|
||||||
<span className="text-sm font-medium">{u.name}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "sections",
|
|
||||||
header: "Разделы",
|
|
||||||
enableSorting: false,
|
|
||||||
cell: (u) => {
|
|
||||||
const writeSections =
|
|
||||||
u.role === "admin"
|
|
||||||
? []
|
|
||||||
: u.sections.filter((s) => s.level === "write").map((s) => s.section)
|
|
||||||
const readSections =
|
|
||||||
u.role === "admin"
|
|
||||||
? []
|
|
||||||
: u.sections.filter((s) => s.level === "read").map((s) => s.section)
|
|
||||||
return (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{u.role === "admin" ? (
|
|
||||||
<span className="text-violet-600 dark:text-violet-400 font-medium">
|
|
||||||
Все ({allSectionsCount})
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span>{readSections.length + writeSections.length} из {allSectionsCount}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "servers",
|
|
||||||
header: "Серверы",
|
|
||||||
enableSorting: false,
|
|
||||||
cell: (u) => {
|
|
||||||
const accessServers =
|
|
||||||
u.role === "admin"
|
|
||||||
? servers
|
|
||||||
: servers.filter((s) => u.servers.find((p) => p.serverId === s.id && p.level !== "none"))
|
|
||||||
return (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{u.role === "admin" ? (
|
|
||||||
<span className="text-violet-600 dark:text-violet-400 font-medium">
|
|
||||||
Все ({servers.length})
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span>
|
|
||||||
{accessServers.length} из {servers.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "write",
|
|
||||||
header: "Права записи",
|
|
||||||
enableSorting: false,
|
|
||||||
cell: (u) => {
|
|
||||||
const writeSections =
|
|
||||||
u.role === "admin"
|
|
||||||
? []
|
|
||||||
: u.sections.filter((s) => s.level === "write").map((s) => s.section)
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{u.role === "admin" ? (
|
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20">
|
|
||||||
Полный доступ
|
|
||||||
</span>
|
|
||||||
) : writeSections.length === 0 ? (
|
|
||||||
<span className="text-[10px] text-muted-foreground">Только просмотр</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{writeSections.slice(0, 3).map((s) => (
|
|
||||||
<span
|
|
||||||
key={s}
|
|
||||||
className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20"
|
|
||||||
>
|
|
||||||
{s}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{writeSections.length > 3 && (
|
|
||||||
<span className="text-[10px] text-muted-foreground">+{writeSections.length - 3}</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[allSectionsCount, servers],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CompactDataGrid
|
|
||||||
data={users}
|
|
||||||
columns={columns}
|
|
||||||
emptyTitle="Нет пользователей"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { SettingsAccessSummaryDataGrid, type SettingsAccessSummaryDataGridProps }
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/reui/badge"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
CompactDataGrid,
|
CompactDataGrid,
|
||||||
@@ -35,11 +35,11 @@ function SnapshotOkBadge({
|
|||||||
errLabel?: string
|
errLabel?: string
|
||||||
}) {
|
}) {
|
||||||
return ok ? (
|
return ok ? (
|
||||||
<Badge variant="outline" className="text-[10px] border-emerald-500/40 text-emerald-700 dark:text-emerald-400">
|
<Badge variant="success-outline" size="sm" className="text-[10px]">
|
||||||
{okLabel}
|
{okLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline" className="text-[10px] border-destructive/50 text-destructive">
|
<Badge variant="destructive-outline" size="sm" className="text-[10px]">
|
||||||
{errLabel}
|
{errLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table"
|
||||||
|
import type { FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { fmtRate } from "@/lib/fmt-rate"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function formatBytes(n: number): string {
|
||||||
|
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)} ГБ`
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
|
||||||
|
if (n >= 1000) return `${(n / 1000).toFixed(1)} КБ`
|
||||||
|
return `${n} Б`
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrafficFlowsDataGrid({
|
||||||
|
rows,
|
||||||
|
emptyHint,
|
||||||
|
}: {
|
||||||
|
rows: FlowTalkerDto[]
|
||||||
|
emptyHint?: string
|
||||||
|
}) {
|
||||||
|
const columns = useMemo<ColumnDef<FlowTalkerDto>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "server",
|
||||||
|
accessorKey: "serverName",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">JH</span>,
|
||||||
|
cell: ({ row }) => <span className="text-sm font-medium">{row.original.serverName}</span>,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "src",
|
||||||
|
accessorKey: "src",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Src</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{row.original.src}
|
||||||
|
{row.original.srcPort ? `:${row.original.srcPort}` : ""}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dst",
|
||||||
|
accessorKey: "dst",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Dst</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{row.original.dst}
|
||||||
|
{row.original.dstPort ? `:${row.original.dstPort}` : ""}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "app",
|
||||||
|
accessorFn: (r) => r.application ?? r.protoName,
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">App</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="flex min-w-0 flex-col gap-0.5 text-xs">
|
||||||
|
<span>{row.original.application ?? row.original.protoName}</span>
|
||||||
|
{row.original.category || row.original.service ? (
|
||||||
|
<span className="text-[10px] text-muted-foreground truncate">
|
||||||
|
{[row.original.category, row.original.service].filter(Boolean).join(" · ")}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "proto",
|
||||||
|
accessorKey: "protoName",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Proto</span>,
|
||||||
|
cell: ({ row }) => <span className="text-xs">{row.original.protoName}</span>,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rate",
|
||||||
|
accessorFn: (r) => r.bps,
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Скорость</span>,
|
||||||
|
cell: ({ row }) => <span className="text-xs tabular-nums">{fmtRate(row.original.bps / 1_000_000)}</span>,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bytes",
|
||||||
|
accessorKey: "bytes",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Байты</span>,
|
||||||
|
cell: ({ row }) => <span className="text-xs tabular-nums">{formatBytes(row.original.bytes)}</span>,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "iface",
|
||||||
|
accessorKey: "inIface",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Iface</span>,
|
||||||
|
cell: ({ row }) => <span className="font-mono text-xs text-muted-foreground">{row.original.inIface || "—"}</span>,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: cn(DATA_GRID_CELL_PAD_LAST) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: rows,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getRowId: (row, i) => `${row.serverId}-${row.src}-${row.dst}-${row.proto}-${row.srcPort}-${row.dstPort}-${i}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={rows.length}
|
||||||
|
emptyMessage={
|
||||||
|
emptyHint
|
||||||
|
|| "Пока нет IPFIX. Поднимите wg-flow на хосте MM и подключите jump-host одним кликом."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { TrafficFlowsDataGrid }
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getExpandedRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||||
|
import { UsersExpandedDetail } from "@/components/data-grids/users-expanded-detail"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { ALL_SECTIONS, ROLE_LABEL, type AppUser } from "@/lib/users"
|
||||||
|
import {
|
||||||
|
CableIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
PencilIcon,
|
||||||
|
TrashIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"size-8 rounded-full flex items-center justify-center text-white text-[10px] font-semibold shrink-0 bg-gradient-to-br from-blue-500 to-violet-500",
|
||||||
|
!active && "opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{avatar}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsersDataGridProps {
|
||||||
|
users: AppUser[]
|
||||||
|
serversCount: number
|
||||||
|
allSectionsCount?: number
|
||||||
|
isLoading?: boolean
|
||||||
|
onEdit: (user: AppUser) => void
|
||||||
|
onDelete: (user: AppUser) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsersDataGrid({
|
||||||
|
users,
|
||||||
|
serversCount,
|
||||||
|
allSectionsCount = ALL_SECTIONS.length,
|
||||||
|
isLoading,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: UsersDataGridProps) {
|
||||||
|
const columns = useMemo<ColumnDef<AppUser>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridSortHeader column={column} title="Пользователь" className="ml-1" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const u = row.original
|
||||||
|
const expanded = row.getIsExpanded()
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 min-w-0">
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDownIcon className="size-3.5 mt-2 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRightIcon className="size-3.5 mt-2 shrink-0 text-muted-foreground/40" />
|
||||||
|
)}
|
||||||
|
<AvatarCircle avatar={u.avatar} active={u.active} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{u.name}</p>
|
||||||
|
<p className="text-xs font-mono text-muted-foreground truncate">{u.email || u.login}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Пользователь",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
expandedContent: (row: AppUser) => (
|
||||||
|
<UsersExpandedDetail
|
||||||
|
user={row}
|
||||||
|
serversCount={serversCount}
|
||||||
|
allSectionsCount={allSectionsCount}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "role",
|
||||||
|
accessorKey: "role",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Роль" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge
|
||||||
|
variant={row.original.role === "admin" ? "primary-light" : row.original.role === "operator" ? "info-light" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{ROLE_LABEL[row.original.role]}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Роль",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "last",
|
||||||
|
accessorKey: "last",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Последний вход" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs text-muted-foreground">{row.original.last}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Последний вход",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ifaces",
|
||||||
|
accessorFn: (u) => u.bindings.length,
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейсы" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground tabular-nums">
|
||||||
|
<CableIcon className="size-3.5" />
|
||||||
|
{row.original.bindings.length}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Интерфейсы",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
accessorKey: "active",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[11px] font-medium",
|
||||||
|
row.original.active ? "text-success" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.original.active ? "Активен" : "Заблокирован"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Статус",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
enableSorting: false,
|
||||||
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const u = row.original
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-end gap-0.5"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="size-7 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => onEdit(u)}
|
||||||
|
title="Редактировать"
|
||||||
|
>
|
||||||
|
<PencilIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="size-7 p-0 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => onDelete(u)}
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
<TrashIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Действия",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[allSectionsCount, onDelete, onEdit, serversCount],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: users,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getExpandedRowModel: getExpandedRowModel(),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
getRowCanExpand: () => true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isLoading && users.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<UsersIcon className="size-4" />}
|
||||||
|
title="Нет пользователей"
|
||||||
|
description="Добавьте пользователя, чтобы привязать интерфейсы и учитывать трафик"
|
||||||
|
className="border-0 py-12"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={users.length}
|
||||||
|
isLoading={isLoading}
|
||||||
|
loadingMode="skeleton"
|
||||||
|
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { UsersDataGrid, type UsersDataGridProps }
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
|
import { Flag } from "@/components/flag"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
ALL_SECTIONS,
|
||||||
|
groupBindingsByServer,
|
||||||
|
IFACE_TYPE_LABEL,
|
||||||
|
summarizeUserAccess,
|
||||||
|
type AppUser,
|
||||||
|
type InterfaceType,
|
||||||
|
} from "@/lib/users"
|
||||||
|
import { CableIcon, KeyRoundIcon, NetworkIcon, ShieldIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const TYPE_VARIANT: Record<InterfaceType, "outline" | "info-light" | "success-light" | "secondary"> = {
|
||||||
|
ether: "outline",
|
||||||
|
gre: "info-light",
|
||||||
|
wg: "success-light",
|
||||||
|
other: "secondary",
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_ICON: Record<InterfaceType, { icon: typeof CableIcon; className: string }> = {
|
||||||
|
ether: { icon: CableIcon, className: "text-muted-foreground" },
|
||||||
|
gre: { icon: NetworkIcon, className: "text-info" },
|
||||||
|
wg: { icon: ShieldIcon, className: "text-success" },
|
||||||
|
other: { icon: CableIcon, className: "text-muted-foreground" },
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsersExpandedDetailProps {
|
||||||
|
user: AppUser
|
||||||
|
serversCount: number
|
||||||
|
allSectionsCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsersExpandedDetail({
|
||||||
|
user,
|
||||||
|
serversCount,
|
||||||
|
allSectionsCount = ALL_SECTIONS.length,
|
||||||
|
}: UsersExpandedDetailProps) {
|
||||||
|
const access = summarizeUserAccess(user, serversCount, allSectionsCount)
|
||||||
|
const groups = groupBindingsByServer(user.bindings)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5 px-5 py-5 bg-muted/20">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-xs">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Разделы:{" "}
|
||||||
|
<span className="font-mono text-foreground">
|
||||||
|
{access.sectionsGranted}/{access.sectionsTotal}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Серверы:{" "}
|
||||||
|
<span className="font-mono text-foreground">
|
||||||
|
{access.serversGranted}/{access.serversTotal}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{access.writeKind === "full" ? (
|
||||||
|
<Badge variant="success-light" size="sm">Полный доступ</Badge>
|
||||||
|
) : access.writeKind === "none" ? (
|
||||||
|
<Badge variant="outline" size="sm">Только просмотр</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex flex-wrap items-center gap-1">
|
||||||
|
{access.writeSections.map((section) => (
|
||||||
|
<Badge key={section} variant="info-light" size="sm">{section}</Badge>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">Нет привязанных интерфейсов</p>
|
||||||
|
) : (
|
||||||
|
groups.map((g) => {
|
||||||
|
const items = g.types.flatMap((tg) => tg.items)
|
||||||
|
return (
|
||||||
|
<div key={g.serverId}>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
|
{g.serverName}
|
||||||
|
</p>
|
||||||
|
<Flag code={g.serverCountry} size={12} />
|
||||||
|
<span className="text-[11px] font-mono text-muted-foreground">{g.serverSite}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-2">
|
||||||
|
{items.map((b) => {
|
||||||
|
const meta = TYPE_ICON[b.interfaceType]
|
||||||
|
const Icon = b.interfaceType === "wg" && b.peerPublicKey ? KeyRoundIcon : meta.icon
|
||||||
|
const iconClass = b.interfaceType === "wg" && b.peerPublicKey ? "text-success" : meta.className
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={b.id}
|
||||||
|
className="flex items-start gap-2 rounded-md border px-3 py-2.5"
|
||||||
|
>
|
||||||
|
<IconTile variant="elevated" className={cn("size-10.5 shrink-0", iconClass)}>
|
||||||
|
<Icon />
|
||||||
|
</IconTile>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-mono font-medium leading-tight truncate">
|
||||||
|
{b.interfaceType === "wg" && (b.peerName || b.peerPublicKey)
|
||||||
|
? `${b.peerName || "peer"} · ${b.interfaceName}`
|
||||||
|
: b.interfaceName}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-1 mt-0.5">
|
||||||
|
<Badge variant={TYPE_VARIANT[b.interfaceType]} size="sm">
|
||||||
|
{IFACE_TYPE_LABEL[b.interfaceType]}
|
||||||
|
</Badge>
|
||||||
|
{b.interfaceType === "wg" && !(b.peerPublicKey ?? "") ? (
|
||||||
|
<Badge variant="warning-light" size="sm">весь интерфейс</Badge>
|
||||||
|
) : null}
|
||||||
|
{b.comment ? (
|
||||||
|
<span className="text-[10px] text-muted-foreground leading-tight truncate">
|
||||||
|
{b.comment}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { UsersExpandedDetail, TYPE_VARIANT, type UsersExpandedDetailProps }
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo, type ReactNode } from "react"
|
||||||
import {
|
import {
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import type { WireGuardInterface } from "@/lib/data"
|
import type { WireGuardInterface } from "@/lib/data"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -47,6 +48,8 @@ export interface WgIfaceWithServer extends WireGuardInterface {
|
|||||||
|
|
||||||
interface WireguardDataGridProps {
|
interface WireguardDataGridProps {
|
||||||
interfaces: WgIfaceWithServer[]
|
interfaces: WgIfaceWithServer[]
|
||||||
|
compactServer?: boolean
|
||||||
|
emptyAction?: ReactNode
|
||||||
onExport: (iface: WgIfaceWithServer) => void
|
onExport: (iface: WgIfaceWithServer) => void
|
||||||
onAddPeer?: (iface: WgIfaceWithServer) => void
|
onAddPeer?: (iface: WgIfaceWithServer) => void
|
||||||
onToggle?: (iface: WgIfaceWithServer) => void
|
onToggle?: (iface: WgIfaceWithServer) => void
|
||||||
@@ -57,6 +60,8 @@ interface WireguardDataGridProps {
|
|||||||
|
|
||||||
function WireguardDataGrid({
|
function WireguardDataGrid({
|
||||||
interfaces,
|
interfaces,
|
||||||
|
compactServer = false,
|
||||||
|
emptyAction,
|
||||||
onExport,
|
onExport,
|
||||||
onAddPeer,
|
onAddPeer,
|
||||||
onToggle,
|
onToggle,
|
||||||
@@ -70,7 +75,11 @@ function WireguardDataGrid({
|
|||||||
id: "name",
|
id: "name",
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridSortHeader column={column} title="Интерфейс / Сервер" className="ml-1" />
|
<DataGridSortHeader
|
||||||
|
column={column}
|
||||||
|
title={compactServer ? "Интерфейс" : "Интерфейс / Сервер"}
|
||||||
|
className="ml-1"
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const iface = row.original
|
const iface = row.original
|
||||||
@@ -88,15 +97,17 @@ function WireguardDataGrid({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-2 rounded-full shrink-0",
|
"size-2 rounded-full shrink-0",
|
||||||
iface.status === "up" ? "bg-emerald-500 animate-pulse" : "bg-red-500",
|
iface.status === "up" ? "bg-success animate-pulse" : "bg-destructive",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className="font-mono font-semibold text-sm">{iface.name}</span>
|
<span className="font-mono font-semibold text-sm">{iface.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
|
{!compactServer ? (
|
||||||
<Flag code={iface.serverCountry || "UN"} size={12} />
|
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
|
||||||
{iface.serverName}
|
<Flag code={iface.serverCountry || "UN"} size={12} />
|
||||||
</div>
|
{iface.serverName}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<p className="sr-only">
|
<p className="sr-only">
|
||||||
{onlinePeers}/{iface.peers.length} пиров
|
{onlinePeers}/{iface.peers.length} пиров
|
||||||
</p>
|
</p>
|
||||||
@@ -105,7 +116,7 @@ function WireguardDataGrid({
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
headerTitle: "Интерфейс / Сервер",
|
headerTitle: compactServer ? "Интерфейс" : "Интерфейс / Сервер",
|
||||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
expandedContent: (row: WgIfaceWithServer) => (
|
expandedContent: (row: WgIfaceWithServer) => (
|
||||||
@@ -152,7 +163,7 @@ function WireguardDataGrid({
|
|||||||
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
|
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
|
||||||
return (
|
return (
|
||||||
<span className="font-mono text-sm text-center block">
|
<span className="font-mono text-sm text-center block">
|
||||||
<span className="text-emerald-600 dark:text-emerald-400">{onlinePeers}</span>
|
<span className="text-success">{onlinePeers}</span>
|
||||||
<span className="text-muted-foreground">/{iface.peers.length}</span>
|
<span className="text-muted-foreground">/{iface.peers.length}</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@@ -168,16 +179,13 @@ function WireguardDataGrid({
|
|||||||
accessorKey: "status",
|
accessorKey: "status",
|
||||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span
|
<Badge
|
||||||
className={cn(
|
variant={row.original.status === "up" ? "success-light" : "destructive-light"}
|
||||||
"text-[11px] font-mono px-2 py-0.5 rounded border",
|
size="sm"
|
||||||
row.original.status === "up"
|
className="font-mono"
|
||||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
|
||||||
: "bg-red-500/10 text-red-500 border-red-500/20",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{row.original.status === "up" ? "UP" : "DOWN"}
|
{row.original.status === "up" ? "UP" : "DOWN"}
|
||||||
</span>
|
</Badge>
|
||||||
),
|
),
|
||||||
meta: {
|
meta: {
|
||||||
headerTitle: "Статус",
|
headerTitle: "Статус",
|
||||||
@@ -258,7 +266,7 @@ function WireguardDataGrid({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[onExport, onAddPeer, onToggle, onDelete, onDeletePeer, onExportPeer],
|
[compactServer, onExport, onAddPeer, onToggle, onDelete, onDeletePeer, onExportPeer],
|
||||||
)
|
)
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
@@ -276,7 +284,8 @@ function WireguardDataGrid({
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<ShieldCheckIcon className="size-4" />}
|
icon={<ShieldCheckIcon className="size-4" />}
|
||||||
title="Нет WireGuard интерфейсов"
|
title="Нет WireGuard интерфейсов"
|
||||||
description="Добавьте первый интерфейс или проверьте поиск"
|
description="Добавьте первый интерфейс или сбросьте фильтры"
|
||||||
|
action={emptyAction}
|
||||||
className="border-0 py-16"
|
className="border-0 py-16"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -74,18 +74,18 @@ function WireGuardPeersDetail({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-mono text-[11px] whitespace-nowrap",
|
"font-mono text-[11px] whitespace-nowrap",
|
||||||
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
|
peer.latestHandshake ? "text-success" : "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{peer.latestHandshake ?? "нет рукопожатия"}
|
{peer.latestHandshake ?? "нет рукопожатия"}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
|
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
|
||||||
<span className="flex items-center gap-0.5">
|
<span className="flex items-center gap-0.5">
|
||||||
<ArrowDownIcon className="size-3 text-emerald-500" />
|
<ArrowDownIcon className="size-3 text-success" />
|
||||||
{fmtBytes(peer.transferRx)}
|
{fmtBytes(peer.transferRx)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-0.5">
|
<span className="flex items-center gap-0.5">
|
||||||
<ArrowUpIcon className="size-3 text-blue-400" />
|
<ArrowUpIcon className="size-3 text-info" />
|
||||||
{fmtBytes(peer.transferTx)}
|
{fmtBytes(peer.transferTx)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, type ReactNode } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import type { WireGuardPeer } from "@/lib/data"
|
||||||
|
import { Flag } from "@/components/flag"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { fmtBytes, truncKey } from "@/components/data-grids/wireguard-peers-detail"
|
||||||
|
import {
|
||||||
|
ArrowDownIcon,
|
||||||
|
ArrowUpIcon,
|
||||||
|
CodeXmlIcon,
|
||||||
|
KeyRoundIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
UsersIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
export interface WgPeerRow extends WireGuardPeer {
|
||||||
|
id: string
|
||||||
|
ifaceId: string
|
||||||
|
ifaceName: string
|
||||||
|
serverId: string
|
||||||
|
serverName: string
|
||||||
|
serverCountry: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WireguardPeersGridProps {
|
||||||
|
peers: WgPeerRow[]
|
||||||
|
compactServer?: boolean
|
||||||
|
emptyAction?: ReactNode
|
||||||
|
onDeletePeer?: (row: WgPeerRow) => void
|
||||||
|
onExportPeer?: (row: WgPeerRow) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function WireguardPeersGrid({
|
||||||
|
peers,
|
||||||
|
compactServer = false,
|
||||||
|
emptyAction,
|
||||||
|
onDeletePeer,
|
||||||
|
onExportPeer,
|
||||||
|
}: WireguardPeersGridProps) {
|
||||||
|
const columns = useMemo<ColumnDef<WgPeerRow>[]>(() => {
|
||||||
|
const cols: ColumnDef<WgPeerRow>[] = [
|
||||||
|
{
|
||||||
|
id: "peer",
|
||||||
|
accessorKey: "publicKey",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridSortHeader column={column} title="Пир" className="ml-1" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const peer = row.original
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<KeyRoundIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate font-mono text-xs" title={peer.publicKey}>
|
||||||
|
{peer.name || truncKey(peer.publicKey)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Пир",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "iface",
|
||||||
|
accessorKey: "ifaceName",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-sm">{row.original.ifaceName}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Интерфейс",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!compactServer) {
|
||||||
|
cols.push({
|
||||||
|
id: "server",
|
||||||
|
accessorKey: "serverName",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground">
|
||||||
|
<Flag code={row.original.serverCountry || "UN"} size={12} />
|
||||||
|
{row.original.serverName}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Сервер",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
cols.push(
|
||||||
|
{
|
||||||
|
id: "allowedIps",
|
||||||
|
accessorFn: (row) => row.allowedIps.join(", "),
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Allowed IPs" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="block truncate font-mono text-xs text-muted-foreground">
|
||||||
|
{row.original.allowedIps.join(", ") || "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Allowed IPs",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "handshake",
|
||||||
|
accessorKey: "latestHandshake",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Handshake" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"whitespace-nowrap font-mono text-[11px]",
|
||||||
|
row.original.latestHandshake ? "text-success" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.original.latestHandshake ?? "нет рукопожатия"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Handshake",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "transfer",
|
||||||
|
header: () => (
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">RX / TX</span>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2 whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<ArrowDownIcon className="size-3 text-success" />
|
||||||
|
{fmtBytes(row.original.transferRx)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<ArrowUpIcon className="size-3 text-info" />
|
||||||
|
{fmtBytes(row.original.transferTx)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "RX / TX",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "endpoint",
|
||||||
|
accessorKey: "endpoint",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Endpoint" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-[11px] text-muted-foreground">
|
||||||
|
{row.original.endpoint ?? "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Endpoint",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
size: 72,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const peer = row.original
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-0.5">
|
||||||
|
{onExportPeer ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7"
|
||||||
|
aria-label="Экспорт peer .conf"
|
||||||
|
onClick={() => onExportPeer(peer)}
|
||||||
|
>
|
||||||
|
<CodeXmlIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{onDeletePeer ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 text-destructive"
|
||||||
|
aria-label="Удалить пира"
|
||||||
|
onClick={() => onDeletePeer(peer)}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return cols
|
||||||
|
}, [compactServer, onDeletePeer, onExportPeer])
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: peers,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (peers.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<UsersIcon className="size-4" />}
|
||||||
|
title="Нет пиров"
|
||||||
|
description="Добавьте пира к интерфейсу или сбросьте поиск"
|
||||||
|
action={emptyAction}
|
||||||
|
className="border-0 py-16"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={peers.length}
|
||||||
|
tableClassNames={{
|
||||||
|
headerRow: "border-b border-border",
|
||||||
|
bodyRow: cn("group/row"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { WireguardPeersGrid, type WireguardPeersGridProps }
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/reui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState, type ReactNode } from "react"
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
AlertTitle,
|
||||||
|
} from "@/components/reui/alert"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet"
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
CopyIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
LoaderCircleIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export type CodeExportFormat = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
filename: string
|
||||||
|
code: string
|
||||||
|
/** Empty / unavailable — show warning instead of code */
|
||||||
|
emptyMessage?: string
|
||||||
|
/** Content came from live device fetch */
|
||||||
|
isLive?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadText(filename: string, content: string) {
|
||||||
|
const blob = new Blob([content], { type: "text/plain;charset=utf-8" })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightLine(line: string): string {
|
||||||
|
const trimmed = line.trimStart()
|
||||||
|
if (line.startsWith("#") || trimmed.startsWith(";")) return "text-muted-foreground"
|
||||||
|
if (trimmed.startsWith("[")) return "text-info"
|
||||||
|
if (trimmed.startsWith("/")) return "text-info"
|
||||||
|
if (/^\s+[a-zA-Z]/.test(line) || /^[A-Za-z][\w-]*=/.test(line)) return "text-foreground"
|
||||||
|
return "text-foreground/90"
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeBlock({ code, className }: { code: string; className?: string }) {
|
||||||
|
const lines = code.length ? code.split("\n") : [""]
|
||||||
|
return (
|
||||||
|
<pre className={cn("px-4 py-3.5 text-[12px] font-mono leading-[1.65] whitespace-pre-wrap break-all select-all", className)}>
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<span key={i} className={cn("block", highlightLine(line))}>
|
||||||
|
{line || " "}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeExportSheetProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
title: string
|
||||||
|
description?: ReactNode
|
||||||
|
formats: CodeExportFormat[]
|
||||||
|
/** Initial format id when sheet opens */
|
||||||
|
initialFormatId?: string
|
||||||
|
/** Optional live fetch from device */
|
||||||
|
onLiveFetch?: (formatId: string) => void
|
||||||
|
liveBusy?: boolean
|
||||||
|
/** Short CTA — keep laconic */
|
||||||
|
liveLabel?: string
|
||||||
|
className?: string
|
||||||
|
/** Extra content under format tabs (e.g. meta strip) */
|
||||||
|
beforeCode?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared code export Sheet — ReUI Frame + sticky footer.
|
||||||
|
* Preview DNA: https://reui.io/preview/base/sheet-8 · Frame https://reui.io/docs/components/base/frame
|
||||||
|
* Alert: https://reui.io/docs/components/base/alert
|
||||||
|
*/
|
||||||
|
function CodeExportSheet({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
formats,
|
||||||
|
initialFormatId,
|
||||||
|
onLiveFetch,
|
||||||
|
liveBusy,
|
||||||
|
liveLabel = "С роутера",
|
||||||
|
className,
|
||||||
|
beforeCode,
|
||||||
|
}: CodeExportSheetProps) {
|
||||||
|
const firstId = formats[0]?.id ?? "default"
|
||||||
|
const [tab, setTab] = useState(initialFormatId ?? firstId)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setTab(
|
||||||
|
initialFormatId && formats.some((f) => f.id === initialFormatId)
|
||||||
|
? initialFormatId
|
||||||
|
: firstId,
|
||||||
|
)
|
||||||
|
setCopied(false)
|
||||||
|
}, [open, initialFormatId, firstId, formats])
|
||||||
|
|
||||||
|
const active = useMemo(
|
||||||
|
() => formats.find((f) => f.id === tab) ?? formats[0],
|
||||||
|
[formats, tab],
|
||||||
|
)
|
||||||
|
|
||||||
|
const code = active?.code ?? ""
|
||||||
|
const emptyMessage = active?.emptyMessage
|
||||||
|
const filename = active?.filename ?? "export.txt"
|
||||||
|
const canCopy = Boolean(code) && !emptyMessage
|
||||||
|
const isLive = Boolean(active?.isLive)
|
||||||
|
const showTabs = formats.length > 1
|
||||||
|
|
||||||
|
function handleCopy() {
|
||||||
|
if (!canCopy) return
|
||||||
|
void navigator.clipboard.writeText(code).then(() => {
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 1800)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||||
|
<SheetContent
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-xl",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SheetHeader className="shrink-0 gap-1 border-b px-5 pt-5 pb-4 pr-12">
|
||||||
|
<SheetTitle className="text-base font-semibold tracking-tight">
|
||||||
|
{title}
|
||||||
|
</SheetTitle>
|
||||||
|
{description ? (
|
||||||
|
<SheetDescription className="font-mono text-xs">
|
||||||
|
{description}
|
||||||
|
</SheetDescription>
|
||||||
|
) : null}
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-3 px-5 py-4">
|
||||||
|
{showTabs ? (
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onValueChange={(v) => setTab(String(v))}
|
||||||
|
className="shrink-0 gap-0"
|
||||||
|
>
|
||||||
|
<TabsList className="h-9 w-full">
|
||||||
|
{formats.map((f) => (
|
||||||
|
<TabsTrigger key={f.id} value={f.id} className="flex-1 px-2 text-xs sm:text-sm">
|
||||||
|
{f.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{onLiveFetch || beforeCode ? (
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
{onLiveFetch ? (
|
||||||
|
<Badge
|
||||||
|
variant={isLive ? "success-light" : "secondary"}
|
||||||
|
size="sm"
|
||||||
|
radius="full"
|
||||||
|
>
|
||||||
|
{isLive ? "С роутера" : "Локально"}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{beforeCode}
|
||||||
|
</div>
|
||||||
|
{onLiveFetch ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
disabled={liveBusy}
|
||||||
|
onClick={() => onLiveFetch(tab)}
|
||||||
|
aria-label="Подтянуть конфиг с роутера с private-key"
|
||||||
|
>
|
||||||
|
{liveBusy ? (
|
||||||
|
<LoaderCircleIcon className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCwIcon className="size-3.5" />
|
||||||
|
)}
|
||||||
|
{liveBusy ? "Загрузка…" : liveLabel}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Frame dense className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<FramePanel className="relative flex min-h-0 flex-1 flex-col overflow-hidden p-0">
|
||||||
|
{emptyMessage ? (
|
||||||
|
<div className="flex flex-1 items-center p-4">
|
||||||
|
<Alert variant="warning" className="w-full">
|
||||||
|
<TriangleAlertIcon />
|
||||||
|
<AlertTitle>Нет данных</AlertTitle>
|
||||||
|
<AlertDescription>{emptyMessage}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-full min-h-0 flex-1">
|
||||||
|
<div className="min-h-[min(52vh,22rem)]">
|
||||||
|
<CodeBlock code={code} />
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
|
||||||
|
{onLiveFetch && !isLive ? (
|
||||||
|
<p className="text-muted-foreground shrink-0 text-[11px] leading-snug">
|
||||||
|
Локальный снимок. Подтяните с роутера, если нужен private-key с устройства.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter className="shrink-0 flex-row items-center justify-between gap-3 border-t px-5 py-3.5 sm:flex-row">
|
||||||
|
<SheetClose
|
||||||
|
render={
|
||||||
|
<Button type="button" variant="ghost" className="shrink-0 px-3" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Закрыть
|
||||||
|
</SheetClose>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
disabled={!canCopy}
|
||||||
|
onClick={() => downloadText(filename, code)}
|
||||||
|
>
|
||||||
|
<DownloadIcon className="size-4" />
|
||||||
|
Файл
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="default"
|
||||||
|
disabled={!canCopy}
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="min-w-28"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckIcon className="size-4 text-success" />
|
||||||
|
) : (
|
||||||
|
<CopyIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
{copied ? "Скопировано" : "Копировать"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CodeExportSheet, CodeBlock, downloadText }
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { CodeExportSheet, downloadText } from "./code-export-sheet"
|
||||||
|
export type { CodeExportFormat, CodeExportSheetProps } from "./code-export-sheet"
|
||||||
|
export { KpiStatGrid, KpiStatCardTile, kpiStatItemKey } from "./kpi-stat-grid"
|
||||||
|
export type { KpiStatItem, KpiStatCardData, KpiStatVariant } from "./kpi-stat-grid"
|
||||||
|
export { kpiCols } from "./kpi-cols"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Shared grid column classes for hybrid KPI tiles.
|
||||||
|
* @see https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
|
export function kpiCols(count: number): string {
|
||||||
|
if (count <= 1) return "grid-cols-1"
|
||||||
|
if (count === 2) return "grid-cols-1 @xl:grid-cols-2"
|
||||||
|
if (count === 3) return "grid-cols-1 @3xl:grid-cols-3"
|
||||||
|
if (count === 4) return "grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4"
|
||||||
|
if (count <= 6) return "grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3"
|
||||||
|
return "grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 @6xl:grid-cols-4"
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { KeyboardEvent, ReactNode } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { kpiCols } from "./kpi-cols"
|
||||||
|
|
||||||
|
export type KpiStatVariant = "default" | "warning" | "destructive"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KPI tile — horizontal compact hybrid (icon left + label/Badge + value).
|
||||||
|
* @see https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
|
export type KpiStatItem = {
|
||||||
|
id?: string
|
||||||
|
label: ReactNode
|
||||||
|
value: ReactNode
|
||||||
|
hint?: ReactNode
|
||||||
|
href?: string
|
||||||
|
onSelect?: () => void
|
||||||
|
onClick?: () => void
|
||||||
|
selected?: boolean
|
||||||
|
active?: boolean
|
||||||
|
icon?: ReactNode
|
||||||
|
iconClassName?: string
|
||||||
|
variant?: KpiStatVariant
|
||||||
|
footer?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KpiStatCardData = KpiStatItem & { id: string }
|
||||||
|
|
||||||
|
const DEFAULT_ICON_CLASS = "text-muted-foreground [&_svg]:text-current"
|
||||||
|
|
||||||
|
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
|
||||||
|
default: "text-foreground",
|
||||||
|
warning: "text-warning",
|
||||||
|
destructive: "text-destructive",
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault()
|
||||||
|
onActivate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveActivate(item: KpiStatItem): (() => void) | undefined {
|
||||||
|
return item.onClick ?? item.onSelect
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelected(item: KpiStatItem): boolean {
|
||||||
|
return Boolean(item.selected ?? item.active)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFooter(item: KpiStatItem): ReactNode {
|
||||||
|
if (item.footer) return item.footer
|
||||||
|
if (typeof item.hint === "string") {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
|
||||||
|
{item.hint}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (item.hint) return item.hint
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||||
|
const footer = resolveFooter(item)
|
||||||
|
const valueVariant = item.variant ?? "default"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="@container relative z-10 flex h-full min-w-0 items-start gap-3">
|
||||||
|
{item.icon ? (
|
||||||
|
<IconTile
|
||||||
|
variant="elevated"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("size-10.5 shrink-0", item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</IconTile>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||||
|
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
{footer ? (
|
||||||
|
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 break-all text-2xl leading-none font-bold tabular-nums",
|
||||||
|
VALUE_VARIANT_CLASS[valueVariant],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</div>
|
||||||
|
{footer ? (
|
||||||
|
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function panelClassName(item: KpiStatItem, className?: string) {
|
||||||
|
const onActivate = resolveActivate(item)
|
||||||
|
const clickable = Boolean(item.href || onActivate)
|
||||||
|
const selected = isSelected(item)
|
||||||
|
|
||||||
|
return cn(
|
||||||
|
"relative isolate flex h-full min-w-0 flex-col",
|
||||||
|
clickable &&
|
||||||
|
"hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2",
|
||||||
|
selected && "ring-primary/30 bg-muted/30 ring-1",
|
||||||
|
className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KpiStatCardTile({
|
||||||
|
item,
|
||||||
|
embedded = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
item: KpiStatItem
|
||||||
|
embedded?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const onActivate = resolveActivate(item)
|
||||||
|
const panelClass = panelClassName(item, className)
|
||||||
|
|
||||||
|
let panel: ReactNode
|
||||||
|
|
||||||
|
if (item.href) {
|
||||||
|
panel = (
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<Link href={item.href} className="focus-visible:outline-none">
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</Link>
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
} else if (onActivate) {
|
||||||
|
panel = (
|
||||||
|
<FramePanel
|
||||||
|
className={panelClass}
|
||||||
|
onClick={onActivate}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||||
|
>
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
panel = (
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Frame className="h-full">{panel}</Frame>
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiStatGridSkeleton({ count }: { count: number }) {
|
||||||
|
return (
|
||||||
|
<Frame className="@container w-full">
|
||||||
|
<div className={cn("grid gap-2", kpiCols(count))}>
|
||||||
|
{Array.from({ length: count }).map((_, index) => (
|
||||||
|
<FramePanel key={index} className="flex items-start gap-3">
|
||||||
|
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-4.5 w-14 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-7 w-16" />
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KpiStatGridProps {
|
||||||
|
items?: KpiStatItem[]
|
||||||
|
cards?: KpiStatCardData[]
|
||||||
|
isLoading?: boolean
|
||||||
|
emptyMessage?: ReactNode
|
||||||
|
emptyIcon?: ReactNode
|
||||||
|
className?: string
|
||||||
|
skeletonCount?: number
|
||||||
|
embedded?: boolean
|
||||||
|
"aria-label"?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
|
||||||
|
const onActivate = resolveActivate(item)
|
||||||
|
const panelClass = panelClassName(item)
|
||||||
|
|
||||||
|
if (item.href) {
|
||||||
|
return (
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<Link href={item.href} className="focus-visible:outline-none">
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</Link>
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onActivate) {
|
||||||
|
return (
|
||||||
|
<FramePanel
|
||||||
|
className={panelClass}
|
||||||
|
onClick={onActivate}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||||
|
>
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hybrid KPI — horizontal compact layout (icon left).
|
||||||
|
* Preview: https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
|
export function KpiStatGrid({
|
||||||
|
items,
|
||||||
|
cards,
|
||||||
|
isLoading = false,
|
||||||
|
emptyMessage,
|
||||||
|
emptyIcon,
|
||||||
|
className,
|
||||||
|
skeletonCount = 4,
|
||||||
|
embedded = false,
|
||||||
|
"aria-label": ariaLabel,
|
||||||
|
}: KpiStatGridProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <KpiStatGridSkeleton count={skeletonCount} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = items ?? cards ?? []
|
||||||
|
|
||||||
|
if (list.length === 0 && (emptyMessage || emptyIcon)) {
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className={cn("w-full", className)}>
|
||||||
|
<FramePanel className="flex items-center gap-3 p-4">
|
||||||
|
{emptyIcon}
|
||||||
|
{emptyMessage ? (
|
||||||
|
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
|
||||||
|
) : null}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return (
|
||||||
|
<section aria-label={ariaLabel} className={cn("@container w-full", className)}>
|
||||||
|
<div className={cn("grid gap-2", kpiCols(list.length || 1))}>
|
||||||
|
{list.map((item, index) => (
|
||||||
|
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame className={cn("@container w-full min-w-0", className)} aria-label={ariaLabel}>
|
||||||
|
<div className={cn("grid gap-2", kpiCols(list.length || 1))}>
|
||||||
|
{list.map((item, index) => (
|
||||||
|
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
|
||||||
|
if (item.id) return item.id
|
||||||
|
if (typeof item.label === "string") return item.label
|
||||||
|
return `kpi-${index}`
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react"
|
||||||
|
import type { ServerStatus, ServerType } from "@/lib/data"
|
||||||
|
import { Flag } from "@/components/flag"
|
||||||
|
import { StatusDot } from "@/components/status-dot"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from "@/components/reui/frame"
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemActions,
|
||||||
|
ItemContent,
|
||||||
|
ItemMedia,
|
||||||
|
ItemTitle,
|
||||||
|
} from "@/components/ui/item"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
} from "@/components/ui/input-group"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { LayersIcon, SearchIcon, ServerIcon } from "lucide-react"
|
||||||
|
|
||||||
|
/** Preview: https://reui.io/preview/base/list-9 · https://reui.io/docs/components/base/icon-tile · https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/badge */
|
||||||
|
export const ALL_SERVERS_ID = "all"
|
||||||
|
|
||||||
|
export interface ServerTileItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
country?: string
|
||||||
|
status?: ServerStatus
|
||||||
|
type?: ServerType
|
||||||
|
count?: number
|
||||||
|
enabled?: boolean
|
||||||
|
selectable?: boolean
|
||||||
|
title?: string
|
||||||
|
site?: string
|
||||||
|
host?: string
|
||||||
|
meta?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeBadgeVariant(
|
||||||
|
type: ServerType,
|
||||||
|
): "focus-light" | "info-light" | "success-light" {
|
||||||
|
if (type === "jump-host") return "focus-light"
|
||||||
|
if (type === "home-router") return "success-light"
|
||||||
|
return "info-light"
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeBadgeLabel(type: ServerType): string {
|
||||||
|
if (type === "jump-host") return "JH"
|
||||||
|
if (type === "home-router") return "HR"
|
||||||
|
return "EN"
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerTypeBadge({ type }: { type: ServerType }) {
|
||||||
|
return (
|
||||||
|
<Badge variant={typeBadgeVariant(type)} size="xs" className="font-mono">
|
||||||
|
{typeBadgeLabel(type)}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostnameOf(item: ServerTileItem): string {
|
||||||
|
return item.name || item.host || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesQuery(item: ServerTileItem, q: string): boolean {
|
||||||
|
if (!q) return true
|
||||||
|
const hay = [item.name, item.title ?? "", item.host ?? "", item.site ?? "", item.country ?? ""]
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase()
|
||||||
|
return hay.includes(q)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelectable(item: ServerTileItem): boolean {
|
||||||
|
return item.selectable !== false
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerTileRail({
|
||||||
|
items,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
allCount = 0,
|
||||||
|
showHeader = true,
|
||||||
|
showAll = true,
|
||||||
|
showCount = true,
|
||||||
|
showType = true,
|
||||||
|
headerRight,
|
||||||
|
loading = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
items: ServerTileItem[]
|
||||||
|
selectedId: string
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
allCount?: number
|
||||||
|
showHeader?: boolean
|
||||||
|
showAll?: boolean
|
||||||
|
showCount?: boolean
|
||||||
|
showType?: boolean
|
||||||
|
headerRight?: ReactNode
|
||||||
|
loading?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const filtered = useMemo(
|
||||||
|
() => items.filter((item) => matchesQuery(item, q)),
|
||||||
|
[items, q],
|
||||||
|
)
|
||||||
|
|
||||||
|
const uniqueSites = useMemo(() => {
|
||||||
|
const sites = new Set(
|
||||||
|
filtered.map((item) => item.site?.trim()).filter((site): site is string => Boolean(site)),
|
||||||
|
)
|
||||||
|
return sites.size
|
||||||
|
}, [filtered])
|
||||||
|
|
||||||
|
const groupBySite = uniqueSites >= 2 && !q
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
if (!groupBySite) {
|
||||||
|
return [{ site: "", items: filtered }]
|
||||||
|
}
|
||||||
|
const bySite = new Map<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 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 (
|
||||||
|
<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">
|
||||||
|
<FrameHeader className="flex flex-col gap-2 border-b px-3 py-2">
|
||||||
|
{showHeader ? (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<FrameTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Серверы
|
||||||
|
</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}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupAddon>
|
||||||
|
<SearchIcon className="size-3.5" />
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Поиск…"
|
||||||
|
aria-label="Поиск сервера"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</FrameHeader>
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<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 ? (
|
||||||
|
<ServerTileButton
|
||||||
|
item={allItem}
|
||||||
|
selected={resolvedId === ALL_SERVERS_ID}
|
||||||
|
onSelect={() => onSelect(ALL_SERVERS_ID)}
|
||||||
|
showCount={showCount}
|
||||||
|
showType={false}
|
||||||
|
icon={
|
||||||
|
<IconTile variant="elevated" size="sm" aria-hidden="true">
|
||||||
|
<LayersIcon className="text-muted-foreground" />
|
||||||
|
</IconTile>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group.site || "__flat__"} className="flex flex-col gap-0.5">
|
||||||
|
{groupBySite && group.site ? (
|
||||||
|
<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">
|
||||||
|
{group.site}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<ServerTileButton
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
selected={resolvedId === item.id}
|
||||||
|
onSelect={() => onSelect(item.id)}
|
||||||
|
showCount={showCount}
|
||||||
|
showType={showType}
|
||||||
|
showSite={!groupBySite}
|
||||||
|
icon={
|
||||||
|
<IconTile variant="elevated" size="sm" aria-hidden="true">
|
||||||
|
{item.country ? (
|
||||||
|
<Flag code={item.country} size={16} />
|
||||||
|
) : (
|
||||||
|
<ServerIcon className="text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</IconTile>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && !showAllTile ? (
|
||||||
|
<p className="px-2 py-3 text-center text-xs text-muted-foreground">
|
||||||
|
{loading
|
||||||
|
? "Загрузка серверов…"
|
||||||
|
: items.length === 0
|
||||||
|
? "Нет доступных серверов"
|
||||||
|
: "Ничего не найдено"}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerTileButton({
|
||||||
|
item,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
showCount,
|
||||||
|
showType,
|
||||||
|
showSite = true,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
item: ServerTileItem
|
||||||
|
selected: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
showCount: boolean
|
||||||
|
showType: boolean
|
||||||
|
showSite?: boolean
|
||||||
|
icon: ReactNode
|
||||||
|
}) {
|
||||||
|
const isAll = item.id === ALL_SERVERS_ID
|
||||||
|
const selectable = isSelectable(item)
|
||||||
|
const hostname = isAll ? "Все серверы" : hostnameOf(item)
|
||||||
|
const meta = item.meta ?? (showCount && item.count != null ? String(item.count) : undefined)
|
||||||
|
const showStatus = Boolean(item.status && item.status !== "online")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Item
|
||||||
|
id={`server-tile-${item.id}`}
|
||||||
|
size="xs"
|
||||||
|
variant={selected ? "muted" : "default"}
|
||||||
|
render={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={selected}
|
||||||
|
title={item.title ?? hostname}
|
||||||
|
disabled={!selectable}
|
||||||
|
onClick={onSelect}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"h-11 min-h-11 flex-nowrap rounded-md py-0",
|
||||||
|
selected && "ring-1 ring-border",
|
||||||
|
item.enabled === false && !selected && "opacity-40",
|
||||||
|
!selectable && "cursor-not-allowed opacity-40 hover:bg-transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ItemMedia>{icon}</ItemMedia>
|
||||||
|
<ItemContent className="min-w-0">
|
||||||
|
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
|
||||||
|
{showStatus ? <StatusDot status={item.status!} /> : null}
|
||||||
|
<span className="min-w-0 truncate">{hostname}</span>
|
||||||
|
{!isAll && showSite && item.site ? (
|
||||||
|
<Badge variant="outline" size="xs" className="shrink-0 font-mono uppercase">
|
||||||
|
{item.site}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{!isAll && showType && item.type ? <ServerTypeBadge type={item.type} /> : null}
|
||||||
|
</ItemTitle>
|
||||||
|
</ItemContent>
|
||||||
|
{meta ? (
|
||||||
|
<ItemActions>
|
||||||
|
<Badge
|
||||||
|
variant={selected ? "secondary" : "outline"}
|
||||||
|
size="xs"
|
||||||
|
className="tabular-nums"
|
||||||
|
>
|
||||||
|
{meta}
|
||||||
|
</Badge>
|
||||||
|
</ItemActions>
|
||||||
|
) : null}
|
||||||
|
</Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ServerTileRail, ServerTypeBadge, hostnameOf }
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table"
|
||||||
|
import type { FlowAnalyticsDto, FlowBreakdownRow, FlowEntityCard } from "@mmapp/contracts/traffic-flow"
|
||||||
|
import { ArrowDownIcon, ArrowUpIcon, GitBranchIcon, GlobeIcon, LayersIcon, UsersIcon } from "lucide-react"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
|
import { TrafficRxTxChart } from "@/components/reui-kit/traffic-rx-tx-chart"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { TrafficFlowsDataGrid } from "@/components/data-grids/traffic-flows-data-grid"
|
||||||
|
import { FlowTrafficMap } from "@/components/traffic/flow-traffic-map"
|
||||||
|
import { StatusDot } from "@/components/status-dot"
|
||||||
|
import { Flag } from "@/components/flag"
|
||||||
|
import { fmtRate } from "@/lib/fmt-rate"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function formatBytes(n: number): string {
|
||||||
|
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)} ГБ`
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
|
||||||
|
if (n >= 1000) return `${(n / 1000).toFixed(1)} КБ`
|
||||||
|
return `${n} Б`
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANGE_KEYS = ["5m", "15m", "1h", "4h", "24h"] as const
|
||||||
|
const RANGE_LABELS: Record<string, string> = {
|
||||||
|
"5m": "5м",
|
||||||
|
"15m": "15м",
|
||||||
|
"1h": "1ч",
|
||||||
|
"4h": "4ч",
|
||||||
|
"24h": "24ч",
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniAreaChart({ rx, tx, height = 44 }: { rx: number[]; tx: number[]; height?: number }) {
|
||||||
|
const W = 300
|
||||||
|
const H = height
|
||||||
|
const maxVal = Math.max(...rx, ...tx, 1) * 1.1
|
||||||
|
const xAt = (i: number) => (rx.length <= 1 ? 0 : (i / (rx.length - 1)) * W)
|
||||||
|
const yAt = (v: number) => H - (v / maxVal) * H
|
||||||
|
const area = (arr: number[]) => {
|
||||||
|
const pts = arr.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(" L ")
|
||||||
|
return `M 0,${H} L ${pts} L ${W},${H} Z`
|
||||||
|
}
|
||||||
|
const line = (arr: number[]) => arr.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ")
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} className="w-full h-11" preserveAspectRatio="none">
|
||||||
|
<path d={area(rx)} fill="var(--chart-rx)" fillOpacity="0.15" />
|
||||||
|
<polyline points={line(rx)} fill="none" stroke="var(--chart-rx)" strokeWidth="1.5" />
|
||||||
|
{tx.some((v) => v > 0) ? (
|
||||||
|
<polyline points={line(tx)} fill="none" stroke="var(--chart-tx)" strokeWidth="1.5" />
|
||||||
|
) : null}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlowEntityCardView({
|
||||||
|
card,
|
||||||
|
selected,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
card: FlowEntityCard
|
||||||
|
selected: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"text-left w-full rounded-lg border p-3 transition-colors hover:bg-muted/50",
|
||||||
|
selected ? "border-primary bg-primary/5" : "border-border bg-card",
|
||||||
|
card.status === "offline" && "opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-2">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<StatusDot status={card.status} />
|
||||||
|
<span className="text-xs font-medium truncate">{card.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-mono text-muted-foreground shrink-0 flex items-center gap-1">
|
||||||
|
{card.country !== "UN" ? <Flag code={card.country} /> : null}
|
||||||
|
{card.site}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<MiniAreaChart rx={card.rxSeries} tx={card.txSeries} />
|
||||||
|
<div className="flex justify-between mt-2 gap-2">
|
||||||
|
<div className="flex items-center gap-1 text-[11px]">
|
||||||
|
<ArrowDownIcon className="size-3 text-success" />
|
||||||
|
<span className="font-mono font-medium text-success">{fmtRate(card.rxNow)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-[11px]">
|
||||||
|
<ArrowUpIcon className="size-3 text-info" />
|
||||||
|
<span className="font-mono font-medium text-info">{fmtRate(card.txNow)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-0.5 text-[10px] text-muted-foreground">
|
||||||
|
<GitBranchIcon className="size-3" />{card.sessions}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlowBreakdownGrid({
|
||||||
|
rows,
|
||||||
|
empty,
|
||||||
|
country,
|
||||||
|
}: {
|
||||||
|
rows: FlowBreakdownRow[]
|
||||||
|
empty?: string
|
||||||
|
country?: boolean
|
||||||
|
}) {
|
||||||
|
const columns = useMemo<ColumnDef<FlowBreakdownRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "label",
|
||||||
|
accessorKey: "label",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Имя</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<span className="flex items-center gap-1.5 text-sm font-medium truncate">
|
||||||
|
{country ? <Flag code={row.original.id} /> : null}
|
||||||
|
{row.original.label}
|
||||||
|
</span>
|
||||||
|
<div className="h-1 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary"
|
||||||
|
style={{ width: `${Math.min(100, row.original.percent)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "share",
|
||||||
|
accessorKey: "percent",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Доля</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs tabular-nums">{row.original.percent.toFixed(1)}%</span>
|
||||||
|
),
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rate",
|
||||||
|
accessorFn: (r) => r.bps,
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Скорость</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs tabular-nums">{fmtRate(row.original.bps / 1_000_000)}</span>
|
||||||
|
),
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bytes",
|
||||||
|
accessorKey: "bytes",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Байты</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs tabular-nums">{formatBytes(row.original.bytes)}</span>
|
||||||
|
),
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[country],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: rows,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell table={table} recordCount={rows.length} emptyMessage={empty ?? "Нет данных за период"} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlowAnalyticsDetail({
|
||||||
|
card,
|
||||||
|
analytics,
|
||||||
|
range,
|
||||||
|
onRange,
|
||||||
|
selectedIface,
|
||||||
|
onIface,
|
||||||
|
dedup,
|
||||||
|
onDedup,
|
||||||
|
liveHint,
|
||||||
|
emptyHint,
|
||||||
|
}: {
|
||||||
|
card: FlowEntityCard | null
|
||||||
|
analytics: FlowAnalyticsDto | null
|
||||||
|
range: string
|
||||||
|
onRange: (r: string) => void
|
||||||
|
selectedIface: string
|
||||||
|
onIface: (name: string) => void
|
||||||
|
dedup: boolean
|
||||||
|
onDedup: (value: boolean) => void
|
||||||
|
liveHint?: string
|
||||||
|
emptyHint?: string
|
||||||
|
}) {
|
||||||
|
const [slice, setSlice] = useState("applications")
|
||||||
|
const [mapCountry, setMapCountry] = useState<string | null>(null)
|
||||||
|
const rxNow = analytics ? analytics.bpsNow / 1_000_000 : (card?.rxNow ?? 0)
|
||||||
|
const bytes = analytics?.bytes ?? card?.bytes ?? 0
|
||||||
|
const sessionRows = (analytics?.conversationsList ?? []).filter((row) =>
|
||||||
|
mapCountry ? row.dstCountry === mapCountry : true,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!card) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground py-8 text-center">
|
||||||
|
Выберите сервер или клиента слева
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-start justify-between mb-3 gap-3">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||||
|
<StatusDot status={card.status} />
|
||||||
|
<h2 className="text-base font-semibold truncate">{card.name}</h2>
|
||||||
|
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded flex items-center gap-1">
|
||||||
|
{card.country !== "UN" ? <Flag code={card.country} /> : null}
|
||||||
|
{card.site}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="flow-dedup"
|
||||||
|
checked={dedup}
|
||||||
|
onCheckedChange={onDedup}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="flow-dedup" className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
Без дублей
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{RANGE_KEYS.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRange(r)}
|
||||||
|
className={cn(
|
||||||
|
"h-7 px-2 text-xs rounded border transition-colors",
|
||||||
|
range === r
|
||||||
|
? "border-primary bg-primary/10 text-primary font-medium"
|
||||||
|
: "border-border text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{RANGE_LABELS[r]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{analytics?.ifaces && analytics.ifaces.length > 0 ? (
|
||||||
|
<div className="mb-3 pb-3 border-b">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-[11px] text-muted-foreground mr-1">Интерфейс:</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onIface("__all__")}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-[10px] font-medium transition-all",
|
||||||
|
selectedIface === "__all__"
|
||||||
|
? "bg-foreground text-background border-foreground"
|
||||||
|
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Все
|
||||||
|
</button>
|
||||||
|
{analytics.ifaces.map((iface) => {
|
||||||
|
const active = selectedIface === iface.name
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${iface.name}:${iface.index}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onIface(iface.name)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[10px] font-medium transition-all",
|
||||||
|
active
|
||||||
|
? "bg-foreground text-background border-foreground"
|
||||||
|
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{iface.name}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1.5">по iface, без дедупа</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<TrafficRxTxChart
|
||||||
|
rx={analytics?.rxSeries ?? card.rxSeries}
|
||||||
|
tx={analytics?.txSeries ?? card.txSeries}
|
||||||
|
range={range}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-4 border-t">
|
||||||
|
<KpiStatGrid
|
||||||
|
aria-label="Скорость потоков"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: "bps-now",
|
||||||
|
label: "Скорость сейчас",
|
||||||
|
value: fmtRate(rxNow),
|
||||||
|
hint: liveHint,
|
||||||
|
icon: <ArrowDownIcon className="size-4" />,
|
||||||
|
iconClassName: "text-success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bytes",
|
||||||
|
label: "Байт за период",
|
||||||
|
value: formatBytes(bytes),
|
||||||
|
icon: <ArrowUpIcon className="size-4" />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "flows",
|
||||||
|
label: "Сессии",
|
||||||
|
value: String(analytics?.conversations ?? card.sessions),
|
||||||
|
hint: analytics?.conversationsRaw != null && analytics.conversationsRaw !== analytics.conversations
|
||||||
|
? `до дедупа ${analytics.conversationsRaw}`
|
||||||
|
: undefined,
|
||||||
|
icon: <GitBranchIcon className="size-4" />,
|
||||||
|
iconClassName: "text-warning",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "uniq",
|
||||||
|
label: "Уник. src / dst",
|
||||||
|
value: `${analytics?.uniqueSrc ?? 0} / ${analytics?.uniqueDst ?? 0}`,
|
||||||
|
icon: <UsersIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "category",
|
||||||
|
label: "Топ категория",
|
||||||
|
value: analytics?.topCategory ?? "—",
|
||||||
|
icon: <LayersIcon className="size-4" />,
|
||||||
|
iconClassName: "text-primary",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-4 border-t flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs text-muted-foreground">Аналитика потребления</p>
|
||||||
|
{analytics?.live ? <Badge variant="success-light" size="sm">live</Badge> : null}
|
||||||
|
</div>
|
||||||
|
<Tabs value={slice} onValueChange={(v) => setSlice(String(v))} className="gap-3">
|
||||||
|
<TabsList variant="line" className="flex flex-wrap h-auto">
|
||||||
|
<TabsTrigger value="applications">Приложения</TabsTrigger>
|
||||||
|
<TabsTrigger value="categories">Категории</TabsTrigger>
|
||||||
|
<TabsTrigger value="services">Сервисы</TabsTrigger>
|
||||||
|
<TabsTrigger value="asns">ASN</TabsTrigger>
|
||||||
|
<TabsTrigger value="countries">Страны</TabsTrigger>
|
||||||
|
<TabsTrigger value="map">Карта</TabsTrigger>
|
||||||
|
<TabsTrigger value="protocols">Протоколы</TabsTrigger>
|
||||||
|
<TabsTrigger value="sources">Источники</TabsTrigger>
|
||||||
|
<TabsTrigger value="destinations">Назначения</TabsTrigger>
|
||||||
|
<TabsTrigger value="sessions">Сессии</TabsTrigger>
|
||||||
|
<TabsTrigger value="interfaces">Интерфейсы</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="applications">
|
||||||
|
<FlowBreakdownGrid rows={analytics?.applications ?? []} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="categories">
|
||||||
|
<FlowBreakdownGrid rows={analytics?.categories ?? []} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="services">
|
||||||
|
<FlowBreakdownGrid rows={analytics?.services ?? []} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="asns">
|
||||||
|
<FlowBreakdownGrid rows={analytics?.asns ?? []} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="countries">
|
||||||
|
<FlowBreakdownGrid rows={analytics?.countries ?? []} country />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="map">
|
||||||
|
<FlowTrafficMap
|
||||||
|
edges={analytics?.mapEdges ?? []}
|
||||||
|
onSelectCountry={(iso) => {
|
||||||
|
setMapCountry(iso)
|
||||||
|
setSlice("sessions")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="protocols">
|
||||||
|
<FlowBreakdownGrid rows={analytics?.protocols ?? []} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="sources">
|
||||||
|
<FlowBreakdownGrid rows={analytics?.sources ?? []} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="destinations">
|
||||||
|
<FlowBreakdownGrid rows={analytics?.destinations ?? []} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="sessions">
|
||||||
|
{mapCountry ? (
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<GlobeIcon className="size-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">фильтр страны {mapCountry}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-primary"
|
||||||
|
onClick={() => setMapCountry(null)}
|
||||||
|
>
|
||||||
|
сбросить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<TrafficFlowsDataGrid
|
||||||
|
rows={sessionRows}
|
||||||
|
emptyHint={emptyHint}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="interfaces">
|
||||||
|
<FlowBreakdownGrid
|
||||||
|
rows={analytics?.interfaces ?? []}
|
||||||
|
empty="Нет данных по интерфейсам"
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { FormField } from "@/components/form-kit"
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||||
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { CodeBlock, downloadText } from "@/components/reui-kit/code-export-sheet"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
import {
|
||||||
|
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||||
|
SheetDescription, SheetFooter, SheetClose,
|
||||||
|
} from "@/components/ui/sheet"
|
||||||
|
import { applyTrafficFlowOverlay } from "@/shared/api/traffic-flow"
|
||||||
|
import type { ServerRead } from "@mmapp/contracts/servers"
|
||||||
|
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
|
||||||
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
CopyIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
InfoIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
function FlowOverlaySheet({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
servers,
|
||||||
|
backendUrl,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (v: boolean) => void
|
||||||
|
servers: ServerRead[]
|
||||||
|
backendUrl: string
|
||||||
|
onDone?: (result: TrafficFlowOverlayResult) => void
|
||||||
|
}) {
|
||||||
|
const jumpHosts = useMemo(
|
||||||
|
() => servers.filter((s) => s.enabled && s.type === "jump-host"),
|
||||||
|
[servers],
|
||||||
|
)
|
||||||
|
const [serverId, setServerId] = useState("")
|
||||||
|
const [endpoint, setEndpoint] = useState("")
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [result, setResult] = useState<TrafficFlowOverlayResult | null>(null)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [tab, setTab] = useState("linux")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setResult(null)
|
||||||
|
setCopied(false)
|
||||||
|
setTab("linux")
|
||||||
|
const first = jumpHosts[0]
|
||||||
|
const nextId = first ? String(first.id) : ""
|
||||||
|
setServerId(nextId)
|
||||||
|
setEndpoint(first?.host ?? "")
|
||||||
|
}, [open, jumpHosts])
|
||||||
|
|
||||||
|
function handleServerChange(id: string) {
|
||||||
|
setServerId(id)
|
||||||
|
const selected = jumpHosts.find((s) => String(s.id) === id)
|
||||||
|
if (selected) setEndpoint(selected.host)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formats = result?.hostFiles ?? []
|
||||||
|
const active = formats.find((f) => f.id === tab) ?? formats[0]
|
||||||
|
const selectedHost = jumpHosts.find((s) => String(s.id) === serverId)
|
||||||
|
const selectedLabel = selectedHost
|
||||||
|
? `${selectedHost.name || selectedHost.host} (${selectedHost.host})`
|
||||||
|
: "Выберите сервер…"
|
||||||
|
const canSubmit = Boolean(serverId && endpoint.trim()) && !busy
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!serverId || !endpoint.trim()) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const res = await applyTrafficFlowOverlay(backendUrl, serverId, endpoint.trim())
|
||||||
|
setResult(res)
|
||||||
|
setTab(res.hostFiles[0]?.id ?? "linux")
|
||||||
|
toast.success(`wg-flow на ${res.address}`)
|
||||||
|
onDone?.(res)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Не удалось подключить JH")
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCopy() {
|
||||||
|
const code = active?.code ?? ""
|
||||||
|
if (!code) return
|
||||||
|
void navigator.clipboard.writeText(code).then(() => {
|
||||||
|
setCopied(true)
|
||||||
|
toast.success("Скопировано")
|
||||||
|
setTimeout(() => setCopied(false), 1800)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent
|
||||||
|
side="right"
|
||||||
|
className="flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl"
|
||||||
|
>
|
||||||
|
<SheetHeader className="shrink-0 gap-1 border-b px-5 pt-5 pb-4 pr-12">
|
||||||
|
<SheetTitle className="text-base font-semibold tracking-tight">
|
||||||
|
Подключить jump-host
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
Создаст wg-flow на MikroTik (сервер, listen 13232) и выдаст готовый bash для Linux-хоста Docker MM (клиент).
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
|
||||||
|
<FormField label="Jump-host" required>
|
||||||
|
<Select
|
||||||
|
value={serverId || undefined}
|
||||||
|
onValueChange={(v) => handleServerChange(String(v ?? ""))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 w-full min-w-0">
|
||||||
|
<SelectValue>{selectedLabel}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent align="start" className="min-w-(--anchor-width)">
|
||||||
|
{jumpHosts.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={String(s.id)}>
|
||||||
|
{s.name || s.host} ({s.host})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
label="Публичный IP или DNS jump-host"
|
||||||
|
required
|
||||||
|
hint="Куда хост MM (wg-quick) стучится по UDP 13232. Не контейнер backend."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
className="font-mono"
|
||||||
|
value={endpoint}
|
||||||
|
onChange={(e) => setEndpoint(e.target.value)}
|
||||||
|
placeholder="jh.example.com"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{result ? (
|
||||||
|
<div className="flex min-h-0 flex-col gap-4">
|
||||||
|
<Alert variant="success">
|
||||||
|
<InfoIcon />
|
||||||
|
<AlertTitle>Linux-хост /opt/cdn-mm</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Скопируйте вкладку Linux и выполните от root. WG — клиент к JH:13232; контейнер слушает только WG-IP:4739, не 0.0.0.0. Ключ не кладите в git.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<ul className="flex flex-col gap-1.5">
|
||||||
|
{result.steps.map((s) => (
|
||||||
|
<li key={s} className="flex items-start gap-2 text-sm">
|
||||||
|
<CheckIcon className="mt-0.5 size-3.5 shrink-0 text-success" />
|
||||||
|
<span>{s}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{formats.length > 0 && active ? (
|
||||||
|
<div className="flex min-h-0 flex-col gap-3">
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setTab(String(v))
|
||||||
|
setCopied(false)
|
||||||
|
}}
|
||||||
|
className="shrink-0 gap-0"
|
||||||
|
>
|
||||||
|
<TabsList className="h-9 w-full">
|
||||||
|
{formats.map((f) => (
|
||||||
|
<TabsTrigger key={f.id} value={f.id} className="flex-1 px-2 text-xs sm:text-sm">
|
||||||
|
{f.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
<Frame dense className="flex min-h-0 flex-col overflow-hidden">
|
||||||
|
<FramePanel className="relative flex min-h-0 flex-col overflow-hidden p-0">
|
||||||
|
<ScrollArea className="h-full min-h-0 max-h-[min(52vh,22rem)]">
|
||||||
|
<CodeBlock code={active.code} className="whitespace-pre break-normal" />
|
||||||
|
</ScrollArea>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => downloadText(active.filename, active.code)}
|
||||||
|
>
|
||||||
|
<DownloadIcon className="size-3.5" />
|
||||||
|
Файл
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" onClick={handleCopy}>
|
||||||
|
{copied ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
|
||||||
|
{copied ? "Скопировано" : "Копировать"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<SheetFooter className="shrink-0 flex-row items-center justify-between gap-3 border-t px-5 py-3.5 sm:flex-row">
|
||||||
|
<SheetClose render={<Button type="button" variant="outline" className="shrink-0" />}>
|
||||||
|
Закрыть
|
||||||
|
</SheetClose>
|
||||||
|
<Button disabled={!canSubmit} onClick={() => { void handleSubmit() }}>
|
||||||
|
{busy ? "Подключение…" : "Подключить"}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { FlowOverlaySheet }
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table"
|
||||||
|
import type { FlowMapEdge } from "@mmapp/contracts/traffic-flow"
|
||||||
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { Flag } from "@/components/flag"
|
||||||
|
import { fmtRate } from "@/lib/fmt-rate"
|
||||||
|
|
||||||
|
const W = 640
|
||||||
|
const H = 280
|
||||||
|
const STROKES = [
|
||||||
|
"var(--chart-1)",
|
||||||
|
"var(--chart-2)",
|
||||||
|
"var(--chart-3)",
|
||||||
|
"var(--color-success)",
|
||||||
|
"var(--chart-5)",
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatBytes(n: number): string {
|
||||||
|
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)} ГБ`
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
|
||||||
|
if (n >= 1000) return `${(n / 1000).toFixed(1)} КБ`
|
||||||
|
return `${n} Б`
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlowTrafficMap({
|
||||||
|
edges,
|
||||||
|
onSelectCountry,
|
||||||
|
}: {
|
||||||
|
edges: FlowMapEdge[]
|
||||||
|
onSelectCountry?: (country: string) => void
|
||||||
|
}) {
|
||||||
|
const [hover, setHover] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const layout = useMemo(() => {
|
||||||
|
const sources = [...new Map(edges.map((e) => [e.fromId, e])).values()]
|
||||||
|
const dests = [...new Map(edges.map((e) => [e.toCountry, e])).values()]
|
||||||
|
const srcY = (i: number) => sources.length <= 1 ? H / 2 : 36 + (i * (H - 72)) / Math.max(1, sources.length - 1)
|
||||||
|
const dstY = (i: number) => dests.length <= 1 ? H / 2 : 36 + (i * (H - 72)) / Math.max(1, dests.length - 1)
|
||||||
|
const srcPos = new Map(sources.map((s, i) => [s.fromId, { x: 88, y: srcY(i), label: s.fromLabel, country: s.fromCountry }]))
|
||||||
|
const dstPos = new Map(dests.map((d, i) => [d.toCountry, { x: 552, y: dstY(i), country: d.toCountry }]))
|
||||||
|
const maxBytes = Math.max(...edges.map((e) => e.bytes), 1)
|
||||||
|
return { srcPos, dstPos, maxBytes }
|
||||||
|
}, [edges])
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<FlowMapEdge>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "from",
|
||||||
|
accessorKey: "fromLabel",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Источник</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="flex items-center gap-1.5 text-sm">
|
||||||
|
{row.original.fromCountry && row.original.fromCountry !== "UN"
|
||||||
|
? <Flag code={row.original.fromCountry} />
|
||||||
|
: null}
|
||||||
|
{row.original.fromLabel}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "to",
|
||||||
|
accessorKey: "toCountry",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Назначение</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="flex items-center gap-1.5 text-sm">
|
||||||
|
<Flag code={row.original.toCountry} />
|
||||||
|
{row.original.toCountry}
|
||||||
|
{row.original.toAsn ? <span className="font-mono text-[10px] text-muted-foreground">AS{row.original.toAsn}</span> : null}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cat",
|
||||||
|
accessorKey: "category",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Категория</span>,
|
||||||
|
cell: ({ row }) => <span className="text-xs">{row.original.category}</span>,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rate",
|
||||||
|
accessorFn: (r) => r.bps,
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Скорость</span>,
|
||||||
|
cell: ({ row }) => <span className="text-xs tabular-nums">{fmtRate(row.original.bps / 1_000_000)}</span>,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bytes",
|
||||||
|
accessorKey: "bytes",
|
||||||
|
header: () => <span className="text-xs font-medium text-muted-foreground">Байты</span>,
|
||||||
|
cell: ({ row }) => <span className="text-xs tabular-nums">{formatBytes(row.original.bytes)}</span>,
|
||||||
|
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: edges,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getRowId: (row, i) => `${row.fromId}-${row.toCountry}-${i}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!edges.length) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground py-8 text-center">
|
||||||
|
Страны подтянутся из кэша RIPEstat
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Frame>
|
||||||
|
<FramePanel className="p-3">
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} className="w-full h-[220px]" role="img" aria-label="Карта потоков откуда куда">
|
||||||
|
{edges.map((e, i) => {
|
||||||
|
const from = layout.srcPos.get(e.fromId)
|
||||||
|
const to = layout.dstPos.get(e.toCountry)
|
||||||
|
if (!from || !to) return null
|
||||||
|
const id = `${e.fromId}-${e.toCountry}`
|
||||||
|
const midX = (from.x + to.x) / 2
|
||||||
|
const d = `M ${from.x} ${from.y} C ${midX} ${from.y}, ${midX} ${to.y}, ${to.x} ${to.y}`
|
||||||
|
const sw = 1.25 + 7 * (e.bytes / layout.maxBytes)
|
||||||
|
const active = hover === id
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
key={id}
|
||||||
|
d={d}
|
||||||
|
fill="none"
|
||||||
|
stroke={STROKES[i % STROKES.length]}
|
||||||
|
strokeWidth={active ? sw + 1.5 : sw}
|
||||||
|
strokeOpacity={active ? 1 : 0.72}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onMouseEnter={() => setHover(id)}
|
||||||
|
onMouseLeave={() => setHover(null)}
|
||||||
|
onClick={() => onSelectCountry?.(e.toCountry)}
|
||||||
|
>
|
||||||
|
<title>
|
||||||
|
{`${e.fromLabel} → ${e.toCountry} · ${e.category} · ${formatBytes(e.bytes)}`}
|
||||||
|
</title>
|
||||||
|
</path>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{[...layout.srcPos.values()].map((n) => (
|
||||||
|
<g key={`s-${n.label}`}>
|
||||||
|
<circle cx={n.x} cy={n.y} r="7" className="fill-primary" />
|
||||||
|
<text x={n.x - 14} y={n.y + 4} textAnchor="end" className="fill-foreground text-[11px]">
|
||||||
|
{n.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{[...layout.dstPos.values()].map((n) => (
|
||||||
|
<g key={`d-${n.country}`}>
|
||||||
|
<circle cx={n.x} cy={n.y} r="7" className="fill-chart-2" />
|
||||||
|
<text x={n.x + 14} y={n.y + 4} className="fill-foreground text-[11px]">
|
||||||
|
{n.country}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
{hover ? (
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
|
Нажмите дугу, чтобы отфильтровать сессии по стране назначения
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
<DataGridShell table={table} recordCount={edges.length} emptyMessage="Нет рёбер с известной страной" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { FlowTrafficMap }
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { FormField, FormToggle } from "@/components/form-kit"
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { CodeExportSheet, type CodeExportFormat } from "@/components/reui-kit/code-export-sheet"
|
||||||
|
import type { TrafficFlowSettingsDto } from "@mmapp/contracts/traffic-flow"
|
||||||
|
import {
|
||||||
|
generateTrafficFlowKeys,
|
||||||
|
getTrafficFlowHostFiles,
|
||||||
|
getTrafficFlowSettings,
|
||||||
|
putTrafficFlowSettings,
|
||||||
|
} from "@/shared/api/traffic-flow"
|
||||||
|
import { KeyRoundIcon, DownloadIcon, InfoIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const HOST_STEPS = [
|
||||||
|
"На хосте Docker (не в контейнере mmapp-backend): apt install wireguard (или эквивалент).",
|
||||||
|
"Скачайте wg-flow.conf и положите в /etc/wireguard/wg-flow.conf.",
|
||||||
|
"wg-quick up wg-flow (или systemctl enable --now wg-quick@wg-flow).",
|
||||||
|
"Firewall: разрешите UDP listen WireGuard. UDP 4739 наружу не открывайте.",
|
||||||
|
"В docker-compose у backend раскомментируйте bind IPFIX только на адресе wg-flow.",
|
||||||
|
"Проверка: wg show · ss -ulnp | grep 4739 · в этой панели — last datagram.",
|
||||||
|
]
|
||||||
|
|
||||||
|
function NetflowSettingsPanel({
|
||||||
|
backendUrl,
|
||||||
|
enabled,
|
||||||
|
}: {
|
||||||
|
backendUrl: string
|
||||||
|
enabled: boolean
|
||||||
|
}) {
|
||||||
|
const [settings, setSettings] = useState<TrafficFlowSettingsDto | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [exportOpen, setExportOpen] = useState(false)
|
||||||
|
const [formats, setFormats] = useState<CodeExportFormat[]>([])
|
||||||
|
const [collectorIp, setCollectorIp] = useState("10.255.254.1")
|
||||||
|
const [flowPort, setFlowPort] = useState("4739")
|
||||||
|
const [wgPort, setWgPort] = useState("51821")
|
||||||
|
const [prefix, setPrefix] = useState("10.255.254.0/24")
|
||||||
|
const [endpoint, setEndpoint] = useState("")
|
||||||
|
const [retention, setRetention] = useState("24")
|
||||||
|
const [topN, setTopN] = useState("200")
|
||||||
|
const [ingestOn, setIngestOn] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!enabled) return
|
||||||
|
const s = await getTrafficFlowSettings(backendUrl)
|
||||||
|
setSettings(s)
|
||||||
|
setCollectorIp(s.collectorIp)
|
||||||
|
setFlowPort(String(s.flowListenPort))
|
||||||
|
setWgPort(String(s.wgListenPort))
|
||||||
|
setPrefix(s.prefix)
|
||||||
|
setEndpoint(s.publicEndpoint)
|
||||||
|
setRetention(String(s.retentionHours))
|
||||||
|
setTopN(String(s.topN))
|
||||||
|
setIngestOn(s.enabled)
|
||||||
|
}, [backendUrl, enabled])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load().catch((e: unknown) => {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Не удалось загрузить NetFlow")
|
||||||
|
})
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const res = await putTrafficFlowSettings(backendUrl, {
|
||||||
|
enabled: ingestOn,
|
||||||
|
collectorIp,
|
||||||
|
flowListenPort: Number.parseInt(flowPort, 10) || 4739,
|
||||||
|
wgListenPort: Number.parseInt(wgPort, 10) || 51821,
|
||||||
|
prefix,
|
||||||
|
publicEndpoint: endpoint,
|
||||||
|
retentionHours: Number.parseInt(retention, 10) || 24,
|
||||||
|
topN: Number.parseInt(topN, 10) || 200,
|
||||||
|
})
|
||||||
|
setSettings(res.settings)
|
||||||
|
toast.success("Настройки NetFlow сохранены")
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Не удалось сохранить")
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleKeys() {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const res = await generateTrafficFlowKeys(backendUrl)
|
||||||
|
setSettings(res.settings)
|
||||||
|
toast.success(res.created ? "Ключи хоста созданы" : "Ключи уже есть")
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Не удалось сгенерировать ключи")
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const res = await getTrafficFlowHostFiles(backendUrl)
|
||||||
|
setFormats(res.files.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
label: f.label,
|
||||||
|
filename: f.filename,
|
||||||
|
code: f.code,
|
||||||
|
})))
|
||||||
|
setExportOpen(true)
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Не удалось получить файлы")
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<OpsPanel
|
||||||
|
title="Traffic Flow / NetFlow (IPFIX)"
|
||||||
|
description="Дополнение к сбору счётчиков REST. Приём только через WireGuard на хосте Docker MM. Preview: https://reui.io/preview/base/settings-16"
|
||||||
|
headerRight={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{settings?.listenerBound ? (
|
||||||
|
<Badge variant="success">listener {settings.listenerAddress}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">listener выкл</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
contentClassName="px-5 py-4 flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
<Alert>
|
||||||
|
<InfoIcon />
|
||||||
|
<AlertTitle>Ключи и UDP 4739</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Приватный ключ хранится в SQLite панели, не коммитьте его. Порт IPFIX публикуйте только на адресе wg-flow, не на 0.0.0.0.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FormToggle checked={ingestOn} onChange={setIngestOn} />
|
||||||
|
<span className="text-sm">Принимать IPFIX</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<FormField label="Collector IP" hint="Адрес в туннеле, куда JH шлёт flow">
|
||||||
|
<Input className="font-mono" value={collectorIp} onChange={(e) => setCollectorIp(e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Префикс overlay">
|
||||||
|
<Input className="font-mono" value={prefix} onChange={(e) => setPrefix(e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="UDP IPFIX">
|
||||||
|
<Input className="font-mono" value={flowPort} onChange={(e) => setFlowPort(e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="WG listen">
|
||||||
|
<Input className="font-mono" value={wgPort} onChange={(e) => setWgPort(e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Публичный endpoint хоста MM" hint="IP или DNS, который видят JH" required>
|
||||||
|
<Input className="font-mono" value={endpoint} onChange={(e) => setEndpoint(e.target.value)} placeholder="203.0.113.10" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Public key хоста">
|
||||||
|
<Input className="font-mono text-xs" readOnly value={settings?.hostPublicKey || "— сгенерируйте ключи —"} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Хранение (часов)">
|
||||||
|
<Input value={retention} onChange={(e) => setRetention(e.target.value)} inputMode="numeric" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Top-N разговоров">
|
||||||
|
<Input value={topN} onChange={(e) => setTopN(e.target.value)} inputMode="numeric" />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Last datagram:{" "}
|
||||||
|
{settings?.lastDatagramAt
|
||||||
|
? new Date(settings.lastDatagramAt).toLocaleString("ru-RU")
|
||||||
|
: "—"}
|
||||||
|
{settings?.lastExporterIp ? ` · ${settings.lastExporterIp}` : ""}
|
||||||
|
{settings?.lastError ? ` · ${settings.lastError}` : ""}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="rounded-md border px-4 py-3 flex flex-col gap-2">
|
||||||
|
<p className="text-sm font-medium">Туннель на сервере Docker MM</p>
|
||||||
|
<ol className="text-xs text-muted-foreground flex flex-col gap-1.5 list-decimal pl-4">
|
||||||
|
{HOST_STEPS.map((s) => (
|
||||||
|
<li key={s}>{s}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button size="sm" disabled={busy} onClick={() => { void handleSave() }}>
|
||||||
|
Сохранить NetFlow
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" disabled={busy} onClick={() => { void handleKeys() }}>
|
||||||
|
<KeyRoundIcon className="size-4" />
|
||||||
|
Ключи хоста
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" disabled={busy} onClick={() => { void handleExport() }}>
|
||||||
|
<DownloadIcon className="size-4" />
|
||||||
|
wg-quick / compose / firewall
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</OpsPanel>
|
||||||
|
|
||||||
|
<CodeExportSheet
|
||||||
|
open={exportOpen}
|
||||||
|
onClose={() => setExportOpen(false)}
|
||||||
|
title="Файлы для хоста Docker MM"
|
||||||
|
description="wg-quick, фрагмент compose и firewall. Хост, не контейнер backend."
|
||||||
|
formats={formats}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { NetflowSettingsPanel }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user