Compare commits

...
3 Commits
Author SHA1 Message Date
DenozordecandCursor 9c0ee7940e feat(bgp, vxlan, ospf): enhance server data handling and introduce new routes
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 1m40s
Docker images / frontend-image (push) Successful in 2m50s
Docker images / updater-image (push) Successful in 42s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
- Added support for fetching and displaying server data in BGP, VXLAN, and OSPF pages, improving the overall user experience.
- Introduced new backend routes for OSPF and VXLAN, allowing for better data management and retrieval.
- Implemented mapping functions for backend server data to frontend types, ensuring consistency across components.
- Enhanced the sidebar to display counts for BGP sessions, VXLAN tunnels, and containers, providing users with quick insights into their network status.
- Updated tests to cover new functionalities and ensure reliability.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 11:18:04 +07:00
DenozordecandCursor 5750590b68 feat(traffic-flow): add rebuild facts endpoint and enhance traffic flow analytics
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m6s
Docker images / frontend-image (push) Successful in 3m15s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m50s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 9s
- Introduced a new endpoint `/traffic/flow/rebuild-facts` to rebuild flow facts from buckets, improving data accuracy and management.
- Updated traffic flow analytics to utilize the new `resolveInternetDest` function for better destination resolution.
- Enhanced tests for traffic flow IP handling and added new utility functions for managing internet destinations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 10:32:51 +07:00
DenozordecandCursor 3c42c114f5 refactor(statistics): improve component structure and enhance state management
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Successful in 2m17s
Docker images / frontend-image (push) Successful in 4m11s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m18s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 8s
- Introduced Suspense for lazy loading in StatisticsPage to optimize rendering.
- Refactored StatisticsPage to separate inner logic into StatisticsPageInner for better readability.
- Updated PeriodSelector to utilize useCallback for handling state changes, improving performance and clarity.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 09:38:31 +07:00
42 changed files with 1973 additions and 231 deletions
+130 -42
View File
@@ -1,6 +1,6 @@
"use client"
import { Fragment, useState, useMemo, useEffect } from "react"
import { useState, useMemo, useEffect } from "react"
import { PageHeader } from "@/components/page-header"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid"
@@ -25,6 +25,9 @@ import {
} from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { servers as mockServers, type Server } from "@/lib/data"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
// ─── types ────────────────────────────────────────────────────────────────────
@@ -233,6 +236,37 @@ interface BackendBgpSession {
capabilities: string[]; lastError: string | null
}
interface BackendServer {
id: number
name: string
host: string
type?: Server["type"]
site?: string
country: string
asn?: string
enabled: boolean
status?: Server["status"]
latency?: number | null
}
function mapBackendServer(s: BackendServer): Server {
return {
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: "—",
site: s.site ?? "",
country: s.country || "UN",
asn: s.asn ?? "",
type: s.type ?? "exit-node",
enabled: s.enabled,
status: s.status ?? "online",
latency: s.latency ?? null,
sessions: 0,
}
}
function backendToFrontend(b: BackendBgpSession): BgpSession {
return {
id: `${b.serverId}-${b.id}`,
@@ -623,27 +657,40 @@ const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [
export default function BgpPage() {
const [activeTab, setActiveTab] = useState<BgpTab>("sessions")
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [liveSessions, setLiveSessions] = useState<BgpSession[]>([])
const [liveServers, setLiveServers] = useState<Server[]>([])
const [loading, setLoading] = useState(false)
const [fetchedAt, setFetchedAt] = useState<Date | null>(null)
const [liveError, setLiveError] = useState<string | null>(null)
const [fetchTick, setFetchTick] = useState(0)
useEffect(() => {
if (!isLive) return
if (!isLive) {
queueMicrotask(() => {
setLiveSessions([])
setLiveServers([])
setLiveError(null)
})
return
}
let cancelled = false
queueMicrotask(() => {
if (cancelled) return
setLoading(true)
setLiveError(null)
void requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions")
.then(data => {
void Promise.all([
requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions"),
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
])
.then(([data, servers]) => {
if (cancelled) return
setLiveSessions(data.map(backendToFrontend))
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer))
setFetchedAt(new Date())
setLoading(false)
})
@@ -656,50 +703,87 @@ export default function BgpPage() {
return () => { cancelled = true }
}, [isLive, backendUrl, fetchTick])
// Use live or mock data for all tabs and KPI
const sessions = isLive ? liveSessions : SESSIONS
const allSessions = isLive ? liveSessions : SESSIONS
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
const effectiveServerId =
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
? selectedServerId
: ALL_SERVERS_ID
const sessions = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return allSessions
return allSessions.filter((s) => s.serverId === effectiveServerId)
}, [allSessions, effectiveServerId])
const railItems = useMemo<ServerTileItem[]>(() => {
const counts = new Map<string, number>()
for (const s of allSessions) {
counts.set(s.serverId, (counts.get(s.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, allSessions])
const established = sessions.filter(s => s.state === "Established").length
const notEstab = sessions.length - established
const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0)
const serverCount = useMemo(
() => new Set(liveSessions.map(s => s.serverId)).size,
[liveSessions],
() => new Set(sessions.map(s => s.serverId)).size,
[sessions],
)
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "BGP" }]}
actions={
<>
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
</>
}
/>
{/* tab bar */}
<div className="border-b bg-background shrink-0">
<div className="flex items-center px-6">
{TABS.map(t => (
<button key={t.id} onClick={() => setActiveTab(t.id)}
className={cn(
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === t.id
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
)}>
{t.icon}{t.label}
</button>
))}
<ServerRailLayout
items={railItems}
selectedId={effectiveServerId}
onSelect={setSelectedServerId}
showAll
allCount={displayServers.length}
loading={isLive && loading && displayServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "BGP" }]}
actions={
<>
<ServerRailMobileButton />
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
</>
}
/>
}
banner={
<div className="border-b bg-background shrink-0">
<div className="flex items-center px-6">
{TABS.map(t => (
<button key={t.id} onClick={() => setActiveTab(t.id)}
className={cn(
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === t.id
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
)}>
{t.icon}{t.label}
</button>
))}
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
}
>
<div className="flex flex-col gap-5">
{/* data source banner */}
@@ -729,11 +813,16 @@ export default function BgpPage() {
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
</Alert>
)}
{isLive && !loading && liveSessions.length === 0 && !liveError && fetchedAt && (
{isLive && !loading && allSessions.length === 0 && !liveError && fetchedAt && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
BGP не настроен ни на одном сервере
</div>
)}
{isLive && !loading && allSessions.length > 0 && sessions.length === 0 && !liveError && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
На выбранном сервере нет BGP-сессий
</div>
)}
{mode === "mock" && (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
Моковые данные
@@ -794,7 +883,6 @@ export default function BgpPage() {
{activeTab === "analytics" && <AnalyticsTab sessions={sessions} />}
</div>
</div>
</div>
</ServerRailLayout>
)
}
+253 -70
View File
@@ -1,15 +1,17 @@
"use client"
import { useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { routerContainers, servers } from "@/lib/data"
import type { RouterContainer } from "@/lib/data"
import { routerContainers as mockContainers, servers as mockServers } from "@/lib/data"
import type { RouterContainer, Server } from "@/lib/data"
import { Flag } from "@/components/flag"
import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { Button } from "@/components/ui/button"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator,
@@ -18,18 +20,47 @@ import {
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
CodeXmlIcon, ActivityIcon, ServerIcon,
TerminalIcon, AlertCircleIcon,
TerminalIcon, AlertCircleIcon, RefreshCwIcon,
} from "lucide-react"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
// ─── helpers ──────────────────────────────────────────────────────────────────
interface BackendServer {
id: number
name: string
host: string
type?: Server["type"]
site?: string
country: string
asn?: string
enabled: boolean
status?: Server["status"]
latency?: number | null
}
function serverFor(id: string) {
return servers.find((s) => s.id === id)
interface ContainersApiResponse {
containers: RouterContainer[]
}
function mapBackendServer(s: BackendServer): Server {
return {
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: "—",
site: s.site ?? "",
country: s.country || "UN",
asn: s.asn ?? "",
type: s.type ?? "exit-node",
enabled: s.enabled,
status: s.status ?? "online",
latency: s.latency ?? null,
sessions: 0,
}
}
function statusConfig(status: RouterContainer["status"]) {
@@ -52,10 +83,8 @@ function statusConfig(status: RouterContainer["status"]) {
}[status]
}
// ─── RSC generator ────────────────────────────────────────────────────────────
function generateContainerRsc(c: RouterContainer): string {
const srv = serverFor(c.serverId)
function generateContainerRsc(c: RouterContainer, serverById: Record<string, Server>): string {
const srv = serverById[c.serverId]
const lines: string[] = []
lines.push(`# RouterOS Container — ${c.name}`)
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
@@ -63,13 +92,11 @@ function generateContainerRsc(c: RouterContainer): string {
lines.push(`# RouterOS 7.4+ · /container`)
lines.push(``)
// interface
for (const iface of c.interfaces) {
lines.push(`/interface/veth/add name=${iface} address=172.17.0.2/24 gateway=172.17.0.1`)
}
lines.push(``)
// envs
if (c.envs.length > 0) {
lines.push(`/container/envs/add name=${c.name}-envs \\`)
for (const { key, value } of c.envs) {
@@ -78,7 +105,6 @@ function generateContainerRsc(c: RouterContainer): string {
lines.push(``)
}
// mounts
for (const m of c.mounts) {
lines.push(`/container/mounts/add name=${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)} \\`)
if (m.src) lines.push(` src=${m.src} \\`)
@@ -86,7 +112,6 @@ function generateContainerRsc(c: RouterContainer): string {
lines.push(``)
}
// container
lines.push(`/container/add \\`)
lines.push(` remote-image=${c.image}:${c.tag} \\`)
lines.push(` interface=${c.interfaces[0] ?? "veth-container"} \\`)
@@ -100,12 +125,18 @@ function generateContainerRsc(c: RouterContainer): string {
return lines.join("\n")
}
// ─── Export Sheet ─────────────────────────────────────────────────────────────
function ExportSheet({ open, container, onClose }: {
open: boolean; container: RouterContainer | null; onClose: () => void
function ExportSheet({
open, container, onClose, serverById,
}: {
open: boolean
container: RouterContainer | null
onClose: () => void
serverById: Record<string, Server>
}) {
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container])
const code = useMemo(
() => (container ? generateContainerRsc(container, serverById) : ""),
[container, serverById],
)
return (
<CodeExportSheet
@@ -125,17 +156,29 @@ function ExportSheet({ open, container, onClose }: {
)
}
// ─── Container card ───────────────────────────────────────────────────────────
function ContainerCard({
container,
server,
live,
busy,
onExport,
onStart,
onStop,
onRestart,
onRemove,
}: {
container: RouterContainer
server?: Server
live: boolean
busy: boolean
onExport: () => void
onStart: () => void
onStop: () => void
onRestart: () => void
onRemove: () => void
}) {
const srv = serverFor(container.serverId)
const cfg = statusConfig(container.status)
const canMutate = live && Boolean(container.rosId)
return (
<Frame dense className="w-full overflow-hidden">
@@ -150,29 +193,36 @@ function ContainerCard({
</div>
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7 shrink-0">
<Button variant="ghost" size="icon" className="size-7 shrink-0" disabled={busy}>
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
{container.status === "running" ? (
<DropdownMenuItem><StopCircleIcon className="size-4 text-amber-500" />Остановить</DropdownMenuItem>
<DropdownMenuItem disabled={!canMutate} onClick={onStop}>
<StopCircleIcon className="size-4 text-amber-500" />Остановить
</DropdownMenuItem>
) : (
<DropdownMenuItem><PlayIcon className="size-4 text-emerald-500" />Запустить</DropdownMenuItem>
<DropdownMenuItem disabled={!canMutate} onClick={onStart}>
<PlayIcon className="size-4 text-emerald-500" />Запустить
</DropdownMenuItem>
)}
<DropdownMenuItem><TerminalIcon className="size-4" />Логи</DropdownMenuItem>
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
<DropdownMenuItem disabled><TerminalIcon className="size-4" />Логи</DropdownMenuItem>
<DropdownMenuItem disabled><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem><PowerIcon className="size-4" />Перезапустить</DropdownMenuItem>
<DropdownMenuItem disabled={!canMutate} onClick={onRestart}>
<PowerIcon className="size-4" />Перезапустить
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
<DropdownMenuItem variant="destructive" disabled={!canMutate} onClick={onRemove}>
<Trash2Icon className="size-4" />Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="px-4 py-3 flex flex-col gap-3">
{/* image */}
<div className="flex items-center gap-2">
<BoxIcon className="size-3.5 text-muted-foreground shrink-0" />
<span className="font-mono text-xs text-foreground/80">
@@ -180,17 +230,15 @@ function ContainerCard({
</span>
</div>
{/* server */}
{srv && (
{server && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<ServerIcon className="size-3.5 shrink-0" />
<Flag code={srv.country} size={12} />
<span className="font-mono">{srv.name}</span>
<Flag code={server.country} size={12} />
<span className="font-mono">{server.name}</span>
</div>
)}
{/* uptime + stats */}
{container.status === "running" && (
{container.status === "running" && (container.uptime || container.cpu !== undefined || container.memMb !== undefined) && (
<div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-2.5">
{container.uptime && (
<div className="flex items-center gap-1">
@@ -216,7 +264,6 @@ function ContainerCard({
</div>
)}
{/* interfaces */}
{container.interfaces.length > 0 && (
<div className="flex flex-wrap gap-1">
{container.interfaces.map((i) => (
@@ -227,7 +274,6 @@ function ContainerCard({
</div>
)}
{/* mounts */}
{container.mounts.length > 0 && (
<div className="flex flex-col gap-1">
{container.mounts.map((m, idx) => (
@@ -249,50 +295,183 @@ function ContainerCard({
)
}
// ════════════════════════════════════════════════════════════════════════════
export default function ContainersPage() {
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<RouterContainer["status"] | "all">("all")
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<RouterContainer["status"] | "all">("all")
const [exportContainer, setExportContainer] = useState<RouterContainer | null>(null)
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [liveContainers, setLiveContainers] = useState<RouterContainer[]>([])
const [liveServers, setLiveServers] = useState<Server[]>([])
const [loading, setLoading] = useState(false)
const [busyId, setBusyId] = useState<string | null>(null)
const [liveError, setLiveError] = useState<string | null>(null)
const loadLive = useCallback(async () => {
if (!isLive) return
setLoading(true)
setLiveError(null)
try {
const [cRes, sRes] = await Promise.all([
requestJson<ContainersApiResponse>(backendUrl, "/api/containers"),
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
])
setLiveContainers(cRes.containers ?? [])
setLiveServers(sRes.filter((s) => s.enabled).map(mapBackendServer))
} catch (e) {
setLiveError(e instanceof Error ? e.message : "Ошибка загрузки")
setLiveContainers([])
} finally {
setLoading(false)
}
}, [isLive, backendUrl])
useEffect(() => {
if (!isLive) {
queueMicrotask(() => {
setLiveContainers([])
setLiveServers([])
setLiveError(null)
})
return
}
queueMicrotask(() => {
void loadLive()
})
}, [isLive, loadLive])
const displayContainers = isLive ? liveContainers : mockContainers
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
const effectiveServerId =
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
? selectedServerId
: ALL_SERVERS_ID
const scoped = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return displayContainers
return displayContainers.filter((c) => c.serverId === effectiveServerId)
}, [displayContainers, effectiveServerId])
const serverById = useMemo(
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
[displayServers],
)
const railItems = 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(displayContainers.filter((c) => c.serverId === s.id).length),
}))
), [displayServers, displayContainers])
const filtered = useMemo(() => {
return routerContainers.filter((c) => {
return scoped.filter((c) => {
if (statusFilter !== "all" && c.status !== statusFilter) return false
if (!search) return true
const q = search.toLowerCase()
return (
c.name.toLowerCase().includes(q) ||
c.image.toLowerCase().includes(q) ||
(serverFor(c.serverId)?.name.toLowerCase().includes(q) ?? false)
(serverById[c.serverId]?.name.toLowerCase().includes(q) ?? false)
)
})
}, [search, statusFilter])
}, [search, statusFilter, scoped, serverById])
const running = routerContainers.filter((c) => c.status === "running").length
const stopped = routerContainers.filter((c) => c.status === "stopped").length
const errors = routerContainers.filter((c) => c.status === "error").length
const running = scoped.filter((c) => c.status === "running").length
const stopped = scoped.filter((c) => c.status === "stopped").length
const errors = scoped.filter((c) => c.status === "error").length
async function mutate(c: RouterContainer, action: "start" | "stop" | "restart" | "remove") {
if (!isLive || !c.rosId) {
toast.info("Действие доступно только в live-режиме")
return
}
if (action === "remove" && !window.confirm(`Удалить контейнер ${c.name}?`)) return
setBusyId(c.id)
try {
await requestJson(backendUrl, `/api/servers/${c.serverId}/containers/${action}`, {
method: "POST",
body: JSON.stringify({ rosId: c.rosId }),
})
const labels = { start: "запущен", stop: "остановлен", restart: "перезапущен", remove: "удалён" }
toast.success(`${c.name}: ${labels[action]}`)
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка RouterOS")
} finally {
setBusyId(null)
}
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]}
actions={
<Button size="sm">
<BoxIcon className="size-4" />Новый контейнер
</Button>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<>
<ServerRailLayout
items={railItems}
selectedId={effectiveServerId}
onSelect={setSelectedServerId}
showAll
allCount={displayServers.length}
loading={isLive && loading && displayServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]}
actions={
<>
<ServerRailMobileButton />
<Button
variant="outline"
size="sm"
onClick={() => { void loadLive() }}
disabled={!isLive || loading}
>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
<Button size="sm">
<BoxIcon className="size-4" />Новый контейнер
</Button>
</>
}
/>
}
>
<div className="flex flex-col gap-5">
{isLive && liveError && (
<Alert variant="warning" className="py-2">
<AlertCircleIcon />
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
</Alert>
)}
{isLive && !loading && displayContainers.length === 0 && !liveError && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
Контейнеры не найдены. Нужен пакет container (RouterOS 7.4+).
</div>
)}
{mode === "mock" && (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
Моковые данные
</span>
)}
<KpiStatGrid
aria-label="Сводка контейнеров"
items={[
{
id: "all",
label: "Всего",
value: routerContainers.length,
value: scoped.length,
icon: <BoxIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
@@ -321,7 +500,6 @@ export default function ContainersPage() {
]}
/>
{/* 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">
<BoxIcon className="size-5 text-violet-500 shrink-0 mt-0.5" />
<div>
@@ -333,7 +511,6 @@ export default function ContainersPage() {
</div>
</div>
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
@@ -363,7 +540,6 @@ export default function ContainersPage() {
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} контейнеров</span>
</div>
{/* Grid */}
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<BoxIcon className="size-10 mb-3 opacity-20" />
@@ -376,13 +552,19 @@ export default function ContainersPage() {
<ContainerCard
key={c.id}
container={c}
server={serverById[c.serverId]}
live={isLive}
busy={busyId === c.id}
onExport={() => setExportContainer(c)}
onStart={() => { void mutate(c, "start") }}
onStop={() => { void mutate(c, "stop") }}
onRestart={() => { void mutate(c, "restart") }}
onRemove={() => { void mutate(c, "remove") }}
/>
))}
</div>
)}
{/* RouterOS reference */}
<OpsPanel title="RouterOS 7.4+ · /container — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
@@ -442,13 +624,14 @@ export default function ContainersPage() {
</OpsPanel>
</div>
</div>
</ServerRailLayout>
<ExportSheet
open={!!exportContainer}
container={exportContainer}
onClose={() => setExportContainer(null)}
serverById={serverById}
/>
</div>
</>
)
}
+29 -3
View File
@@ -122,11 +122,17 @@ interface BackendBfdSession {
packetsRx: number; packetsTx: number; stateChanges: number
}
interface BackendOspfRoute {
id: string; serverId: number; serverName: string; serverSite: string
destination: string; type: OspfRoute["type"]; cost: number; nextHop: string; via: string; area: string
}
interface BackendOspfAll {
neighbors: BackendNeighbor[]
interfaces: BackendInterface[]
instances: BackendInstance[]
bfdSessions: BackendBfdSession[]
routes?: BackendOspfRoute[]
}
function isRefInterfaceName(name: string): boolean {
@@ -213,6 +219,22 @@ function backendToBfdSession(b: BackendBfdSession): BfdSession {
}
}
function backendToRoute(b: BackendOspfRoute): OspfRoute {
const allowed: OspfRoute["type"][] = ["O", "O IA", "O E1", "O E2"]
const type = allowed.includes(b.type) ? b.type : "O"
return {
id: `${b.serverId}-${b.id}`,
destination: b.destination,
type,
cost: b.cost,
nextHop: b.nextHop,
via: b.via,
serverId: String(b.serverId),
serverLabel: b.serverName,
area: b.area || "—",
}
}
// ─── mock data ────────────────────────────────────────────────────────────────
const COST_STEP = 10
@@ -1172,7 +1194,7 @@ export default function OspfPage() {
}, [isLive, backendUrl, fetchTick])
// Derive frontend types from backend data or use mocks
const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions } = useMemo(() => {
const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes } = useMemo(() => {
if (isLive && liveData) {
// Build interface→cost map for neighbor cost lookup
const ifaceMap = new Map<string, number>()
@@ -1185,6 +1207,7 @@ export default function OspfPage() {
.filter((item) => !isRefInterfaceName(item.interfaceName))
const neighbors = liveData.neighbors.map(b => backendToNeighbor(b, ifaceMap))
const bfdSessions = (liveData.bfdSessions ?? []).map(backendToBfdSession)
const routes = (liveData.routes ?? []).map(backendToRoute)
// Build routerIds from instances
const routerIds: Record<string, string> = {}
@@ -1195,7 +1218,7 @@ export default function OspfPage() {
}
const { nodes: graphNodes, edges: graphEdges } = buildLiveGraph(neighbors)
return { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions }
return { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes }
}
if (isLive) {
return {
@@ -1205,6 +1228,7 @@ export default function OspfPage() {
graphEdges: [],
routerIds: {},
bfdSessions: [],
routes: [],
}
}
return {
@@ -1214,6 +1238,7 @@ export default function OspfPage() {
graphEdges: MOCK_GRAPH_EDGES,
routerIds: MOCK_ROUTER_IDS,
bfdSessions: MOCK_BFD,
routes: MOCK_ROUTES,
}
}, [isLive, liveData])
@@ -1257,6 +1282,7 @@ export default function OspfPage() {
const displayItems = filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId)
const displayNeighbors = filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
const displayBfdSessions = filterServerId === ALL_SERVERS_ID ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId)
const displayRoutes = filterServerId === ALL_SERVERS_ID ? routes : routes.filter(r => r.serverId === filterServerId)
const ospfRailItems = useMemo<ServerTileItem[]>(() => (
ospfServers.map((s) => {
@@ -1398,7 +1424,7 @@ export default function OspfPage() {
routerIds={routerIds}
/>
)}
{activeTab === "routes" && <RoutesTab routes={isLive ? [] : MOCK_ROUTES} />}
{activeTab === "routes" && <RoutesTab routes={displayRoutes} />}
{activeTab === "bfd" && <BfdTab sessions={displayBfdSessions} />}
</div>
+14 -6
View File
@@ -1,7 +1,7 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, useCallback, useEffect, useMemo, useState } from "react"
import { useSearchParams } from "next/navigation"
import {
ActivityIcon,
DatabaseIcon,
@@ -254,8 +254,7 @@ function chipList(slices: CubeSlices): { key: string; label: string }[] {
return chips
}
export default function StatisticsPage() {
const router = useRouter()
function StatisticsPageInner() {
const searchParams = useSearchParams()
const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = mode === "live"
@@ -282,9 +281,10 @@ export default function StatisticsPage() {
else sp.delete(k)
}
const qs = sp.toString()
router.replace(qs ? `/statistics?${qs}` : "/statistics")
if (qs === searchParams.toString()) return
window.history.replaceState(null, "", qs ? `/statistics?${qs}` : "/statistics")
},
[router, searchParams],
[searchParams],
)
const setRange = useCallback(
@@ -548,3 +548,11 @@ export default function StatisticsPage() {
</div>
)
}
export default function StatisticsPage() {
return (
<Suspense fallback={null}>
<StatisticsPageInner />
</Suspense>
)
}
+190 -45
View File
@@ -1,30 +1,63 @@
"use client"
import { useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { vxlanTunnels, servers } from "@/lib/data"
import type { VxlanTunnel } from "@/lib/data"
import { vxlanTunnels as mockVxlanTunnels, servers as mockServers } from "@/lib/data"
import type { Server, VxlanTunnel } from "@/lib/data"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
import { Button } from "@/components/ui/button"
import { Alert, AlertDescription } from "@/components/ui/alert"
import {
NetworkIcon, PlusIcon, CodeXmlIcon, LayersIcon,
NetworkIcon, PlusIcon, LayersIcon, RefreshCwIcon, AlertCircleIcon,
} from "lucide-react"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { cn } from "@/lib/utils"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
// ─── helpers ──────────────────────────────────────────────────────────────────
function serverFor(id: string) {
return servers.find((s) => s.id === id)
interface BackendServer {
id: number
name: string
host: string
type?: Server["type"]
site?: string
country: string
asn?: string
enabled: boolean
status?: Server["status"]
latency?: number | null
}
// ─── RSC generator ───────────────────────────────────────────────────────────
interface VxlanApiResponse {
tunnels: VxlanTunnel[]
}
function generateVxlanRsc(t: VxlanTunnel): string {
const srv = serverFor(t.serverId)
function mapBackendServer(s: BackendServer): Server {
return {
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: "—",
site: s.site ?? "",
country: s.country || "UN",
asn: s.asn ?? "",
type: s.type ?? "exit-node",
enabled: s.enabled,
status: s.status ?? "online",
latency: s.latency ?? null,
sessions: 0,
}
}
function generateVxlanRsc(t: VxlanTunnel, serverById: Record<string, Server>): string {
const srv = serverById[t.serverId]
const lines: string[] = []
lines.push(`# VXLAN — ${t.name} · VNI ${t.vni}`)
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
@@ -42,7 +75,6 @@ function generateVxlanRsc(t: VxlanTunnel): string {
if (!t.enabled) lines.push(` disabled=yes \\`)
lines.push(``)
// FDB entries for remote VTEPs
for (const vtep of t.remoteVteps) {
lines.push(`/interface/vxlan/vteps/add \\`)
lines.push(` interface=${t.name} \\`)
@@ -50,7 +82,6 @@ function generateVxlanRsc(t: VxlanTunnel): string {
lines.push(``)
}
// Bridge
lines.push(`# Добавить в bridge:`)
lines.push(`/interface/bridge/port/add \\`)
lines.push(` bridge=bridge-overlay \\`)
@@ -59,12 +90,18 @@ function generateVxlanRsc(t: VxlanTunnel): string {
return lines.join("\n")
}
// ─── Export Sheet ─────────────────────────────────────────────────────────────
function ExportSheet({ open, tunnel, onClose }: {
open: boolean; tunnel: VxlanTunnel | null; onClose: () => void
function ExportSheet({
open, tunnel, onClose, serverById,
}: {
open: boolean
tunnel: VxlanTunnel | null
onClose: () => void
serverById: Record<string, Server>
}) {
const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel])
const code = useMemo(
() => (tunnel ? generateVxlanRsc(tunnel, serverById) : ""),
[tunnel, serverById],
)
return (
<CodeExportSheet
@@ -84,46 +121,156 @@ function ExportSheet({ open, tunnel, onClose }: {
)
}
// ─── Export Sheet ─────────────────────────────────────────────────────────────
export default function VxlanPage() {
const [search, setSearch] = useState("")
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [search, setSearch] = useState("")
const [exportTunnel, setExportTunnel] = useState<VxlanTunnel | null>(null)
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [liveTunnels, setLiveTunnels] = useState<VxlanTunnel[]>([])
const [liveServers, setLiveServers] = useState<Server[]>([])
const [loading, setLoading] = useState(false)
const [liveError, setLiveError] = useState<string | null>(null)
const loadLive = useCallback(async () => {
if (!isLive) return
setLoading(true)
setLiveError(null)
try {
const [tunnelsRes, serversRes] = await Promise.all([
requestJson<VxlanApiResponse>(backendUrl, "/api/vxlan"),
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
])
setLiveTunnels(tunnelsRes.tunnels ?? [])
setLiveServers(serversRes.filter((s) => s.enabled).map(mapBackendServer))
} catch (e) {
setLiveError(e instanceof Error ? e.message : "Ошибка загрузки")
setLiveTunnels([])
} finally {
setLoading(false)
}
}, [isLive, backendUrl])
useEffect(() => {
if (!isLive) {
queueMicrotask(() => {
setLiveTunnels([])
setLiveServers([])
setLiveError(null)
})
return
}
queueMicrotask(() => {
void loadLive()
})
}, [isLive, loadLive])
const displayTunnels = isLive ? liveTunnels : mockVxlanTunnels
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
const effectiveServerId =
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
? selectedServerId
: ALL_SERVERS_ID
const scopedTunnels = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return displayTunnels
return displayTunnels.filter((t) => t.serverId === effectiveServerId)
}, [displayTunnels, effectiveServerId])
const serverById = useMemo(
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
[displayServers],
)
const railItems = 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 filtered = useMemo(() => {
if (!search) return vxlanTunnels
if (!search) return scopedTunnels
const q = search.toLowerCase()
return vxlanTunnels.filter((t) =>
return scopedTunnels.filter((t) =>
t.name.includes(q) ||
String(t.vni).includes(q) ||
t.vtepIp.includes(q) ||
(serverFor(t.serverId)?.name.toLowerCase().includes(q) ?? false)
(serverById[t.serverId]?.name.toLowerCase().includes(q) ?? false),
)
}, [search])
}, [search, scopedTunnels, serverById])
const upCount = vxlanTunnels.filter((t) => t.status === "up").length
const vnis = new Set(vxlanTunnels.map((t) => t.vni)).size
const upCount = scopedTunnels.filter((t) => t.status === "up").length
const vnis = new Set(scopedTunnels.map((t) => t.vni)).size
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "VXLAN" }]}
actions={
<Button size="sm">
<PlusIcon className="size-4" />Новый VXLAN
</Button>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<>
<ServerRailLayout
items={railItems}
selectedId={effectiveServerId}
onSelect={setSelectedServerId}
showAll
allCount={displayServers.length}
loading={isLive && loading && displayServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "VXLAN" }]}
actions={
<>
<ServerRailMobileButton />
<Button
variant="outline"
size="sm"
onClick={() => { void loadLive() }}
disabled={!isLive || loading}
>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
<Button size="sm">
<PlusIcon className="size-4" />Новый VXLAN
</Button>
</>
}
/>
}
>
<div className="flex flex-col gap-5">
{isLive && liveError && (
<Alert variant="warning" className="py-2">
<AlertCircleIcon />
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
</Alert>
)}
{isLive && !loading && displayTunnels.length === 0 && !liveError && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
На опрошенных серверах нет VXLAN-интерфейсов
</div>
)}
{mode === "mock" && (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
Моковые данные
</span>
)}
<KpiStatGrid
aria-label="Сводка VXLAN"
items={[
{
id: "tunnels",
label: "Туннелей",
value: vxlanTunnels.length,
value: scopedTunnels.length,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
@@ -144,14 +291,13 @@ export default function VxlanPage() {
{
id: "servers",
label: "Серверов",
value: new Set(vxlanTunnels.map((t) => t.serverId)).size,
value: new Set(scopedTunnels.map((t) => t.serverId)).size,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{/* Info banner */}
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
<NetworkIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
<div>
@@ -163,7 +309,6 @@ export default function VxlanPage() {
</div>
</div>
{/* Table */}
<DataPageCard>
<DataPageToolbar
search={search}
@@ -173,12 +318,11 @@ export default function VxlanPage() {
/>
<VxlanDataGrid
tunnels={filtered}
servers={servers}
servers={displayServers}
onExport={setExportTunnel}
/>
</DataPageCard>
{/* Reference */}
<OpsPanel title="RouterOS 7 · /interface/vxlan — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
@@ -232,13 +376,14 @@ export default function VxlanPage() {
</OpsPanel>
</div>
</div>
</ServerRailLayout>
<ExportSheet
open={!!exportTunnel}
tunnel={exportTunnel}
onClose={() => setExportTunnel(null)}
serverById={serverById}
/>
</div>
</>
)
}
+4 -2
View File
@@ -12,14 +12,16 @@
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"db:migrate-from-sqlite": "tsx src/scripts/migrate-sqlite-to-pg.ts",
"facts:rebuild": "tsx src/scripts/rebuild-flow-facts.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: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-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/statistics-aggregate.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-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts",
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts",
"test:pg": "tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts",
"test:backups": "tsx src/services/s3-backup-client.test.ts",
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups",
"test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts",
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps",
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts"
},
"dependencies": {
+4
View File
@@ -29,6 +29,8 @@ import certificatesRoutes from "./routes/certificates.js"
import systemDatabaseRoutes from "./routes/system-database.js"
import eventsRoutes from "./routes/events.js"
import wireguardRoutes from "./routes/wireguard.js"
import vxlanRoutes from "./routes/vxlan.js"
import containersRoutes from "./routes/containers.js"
import firewallRoutes from "./routes/firewall.js"
import usersRoutes from "./routes/users.js"
import statisticsRoutes from "./routes/statistics.js"
@@ -134,6 +136,8 @@ export async function buildApp(opts?: {
await app.register(systemDatabaseRoutes, { prefix: "/api" })
await app.register(eventsRoutes, { prefix: "/api" })
await app.register(wireguardRoutes, { prefix: "/api" })
await app.register(vxlanRoutes, { prefix: "/api" })
await app.register(containersRoutes, { prefix: "/api" })
await app.register(firewallRoutes, { prefix: "/api" })
await app.register(usersRoutes, { prefix: "/api" })
await app.register(statisticsRoutes, { prefix: "/api" })
+58
View File
@@ -0,0 +1,58 @@
import { z } from "zod"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import {
getEnabledServerById,
listContainers,
listContainersForServer,
removeContainer,
restartContainer,
startContainer,
stopContainer,
} from "../services/containers-live.js"
import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js"
const RosIdBodySchema = z.object({
rosId: z.string().min(1),
})
type RosIdBody = z.infer<typeof RosIdBodySchema>
type MutateFn = typeof startContainer
const containersRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/containers", async (_req, reply) => {
const containers = await listContainers()
return reply.send({ containers })
})
app.get("/servers/:id/containers", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const server = await getEnabledServerById(params.id)
if (!server) return reply.status(404).send({ error: "Server not found" })
const containers = await listContainersForServer(server)
return reply.send({ containers })
})
function registerMutate(path: string, fn: MutateFn) {
app.post(path, { schema: { params: ServerIdParamSchema, body: RosIdBodySchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const body = req.body as RosIdBody
const server = await getEnabledServerById(params.id)
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
await fn(server, body.rosId)
return reply.send({ ok: true })
} catch (err) {
return reply.status(502).send({
error: err instanceof Error ? err.message : "Ошибка RouterOS",
})
}
})
}
registerMutate("/servers/:id/containers/start", startContainer)
registerMutate("/servers/:id/containers/stop", stopContainer)
registerMutate("/servers/:id/containers/restart", restartContainer)
registerMutate("/servers/:id/containers/remove", removeContainer)
}
export default containersRoutes
+35 -7
View File
@@ -6,9 +6,10 @@ import { MikrotikClient } from "../services/mikrotik.js"
import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js"
import type {
RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance,
RosBfdSession,
OspfNeighborRead, OspfInterfaceRead, OspfInstanceRead, BfdSessionRead,
RosBfdSession, RosIpRoute,
OspfNeighborRead, OspfInterfaceRead, OspfInstanceRead, OspfRouteRead, BfdSessionRead,
} from "../types/server.js"
import { parseOspfGateway, parseOspfRouteType } from "../services/ospf-route-parse.js"
import { z } from "zod"
type ServerRow = typeof servers.$inferSelect
@@ -74,14 +75,15 @@ function parseAddrIface(addr: string): { ip: string; iface: string } {
/** Fetch all OSPF + BFD data for one server */
async function fetchServerOspf(server: ServerRow) {
const client = MikrotikClient.fromServer(server)
const [neighbors, areas, ifaceTemplates, instances, bfdSessions] = await Promise.all([
const [neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes] = await Promise.all([
client.getOspfNeighbors(),
client.getOspfAreas(),
client.getOspfInterfaceTemplates(),
client.getOspfInstances(),
client.getBfdSessions().catch(() => [] as RosBfdSession[]), // BFD is optional
client.getIpRoutes().catch(() => [] as RosIpRoute[]),
])
return { neighbors, areas, ifaceTemplates, instances, bfdSessions }
return { neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes }
}
// ── BFD parser ────────────────────────────────────────────────────────────────
@@ -200,6 +202,29 @@ function parseInstances(
}))
}
function parseOspfRoutes(server: ServerRow, routes: RosIpRoute[]): OspfRouteRead[] {
const out: OspfRouteRead[] = []
for (const [idx, r] of routes.entries()) {
const type = parseOspfRouteType(r)
if (!type) continue
const { nextHop, via } = parseOspfGateway(r)
const metric = parseInt(r["ospf-metric"] ?? r.distance ?? "0") || 0
out.push({
id: r[".id"] ?? String(idx),
serverId: server.id,
serverName: server.name || server.host,
serverSite: server.site,
destination: r["dst-address"] ?? "",
type,
cost: metric,
nextHop,
via,
area: r["ospf-area"] ?? "",
})
}
return out
}
function calcRouteScore(pingMs: number, dlMbps: number, ulMbps: number, pingWeight: number) {
const pingScore = Math.max(0, 100 - pingMs * 0.6)
const speedScore = Math.min(100, (dlMbps + ulMbps) / 18)
@@ -584,16 +609,17 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
const perServer = await Promise.all(
allServers.map(async (server) => {
try {
const { neighbors, areas, ifaceTemplates, instances, bfdSessions } = await fetchServerOspf(server)
const { neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes } = await fetchServerOspf(server)
const areaMap = buildAreaMap(areas)
return {
neighbors: parseNeighbors(server, neighbors, areaMap),
interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap),
instances: parseInstances(server, instances),
bfdSessions: parseBfdSessions(server, bfdSessions),
routes: parseOspfRoutes(server, ipRoutes),
}
} catch {
return { neighbors: [], interfaces: [], instances: [], bfdSessions: [] }
return { neighbors: [], interfaces: [], instances: [], bfdSessions: [], routes: [] }
}
}),
)
@@ -603,6 +629,7 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
interfaces: perServer.flatMap(r => r.interfaces),
instances: perServer.flatMap(r => r.instances),
bfdSessions: perServer.flatMap(r => r.bfdSessions),
routes: perServer.flatMap(r => r.routes),
})
})
@@ -634,13 +661,14 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const { neighbors, areas, ifaceTemplates, instances, bfdSessions } = await fetchServerOspf(server)
const { neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes } = await fetchServerOspf(server)
const areaMap = buildAreaMap(areas)
return reply.send({
neighbors: parseNeighbors(server, neighbors, areaMap),
interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap),
instances: parseInstances(server, instances),
bfdSessions: parseBfdSessions(server, bfdSessions),
routes: parseOspfRoutes(server, ipRoutes),
areas: areas.map(a => ({ name: a.name, areaId: a["area-id"] ?? "0.0.0.0", type: a.type, disabled: a.disabled === "true", inactive: a.inactive === "true", instance: a.instance })),
})
} catch (err) {
+10 -1
View File
@@ -2,6 +2,9 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { count } from "drizzle-orm"
import { listCertificatesFromServers } from "../services/certificates-service.js"
import { countWireGuardInterfaces } from "../services/wireguard-live.js"
import { countVxlanTunnels } from "../services/vxlan-live.js"
import { countContainers } from "../services/containers-live.js"
import { countBgpSessions } from "../services/bgp-peers-live.js"
import { db } from "../db/index.js"
import {
filterRules,
@@ -24,9 +27,12 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
const uptimeProbesTotal = await tableCount(uptimeProbes)
const uptimeSpeedProbesTotal = await tableCount(uptimeSpeedProbes)
const recursiveRoutesTotal = await tableCount(recursiveRoutes)
const [certRes, wireguardTotal] = await Promise.all([
const [certRes, wireguardTotal, bgpTotal, vxlanTotal, containersTotal] = await Promise.all([
listCertificatesFromServers(),
countWireGuardInterfaces().catch(() => 0),
countBgpSessions().catch(() => 0),
countVxlanTunnels().catch(() => 0),
countContainers().catch(() => 0),
])
const certificatesTotal = certRes.certificates.length
const usersTotal = (await listUsers()).length
@@ -41,6 +47,9 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
certificates: certificatesTotal,
wireguard: wireguardTotal,
users: usersTotal,
bgpSessions: bgpTotal,
vxlan: vxlanTotal,
containers: containersTotal,
})
})
}
+22
View File
@@ -27,6 +27,7 @@ import {
import { buildFlowMapHops } from "../services/traffic-flow-map-hops.js"
import { applyFlowOverlay } from "../services/traffic-flow-overlay.js"
import { listTrafficFlowHostFiles } from "../services/traffic-flow-host-files.js"
import { rebuildFlowFactsFromBuckets } from "../services/traffic-flow-facts-rebuild.js"
import { appendEvent } from "../modules/events/service/events-service.js"
const LIVE_TICK_MS = 2000
@@ -203,6 +204,27 @@ const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
}
})
app.post("/traffic/flow/rebuild-facts", async (_req, reply) => {
try {
const result = await rebuildFlowFactsFromBuckets()
await appendEvent({
level: "info",
eventType: "traffic.flow.rebuild_facts",
sourceModule: "traffic",
title: "Пересчитан куб NetFlow",
message: `Факты ${result.facts} из ${result.buckets} сессий, дней ${result.days.length}`,
entityType: "traffic_flow",
entityId: "rebuild-facts",
payload: { buckets: result.buckets, facts: result.facts, days: result.days },
})
return reply.send(result)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const status = message.includes("уже выполняется") ? 409 : 500
return reply.status(status).send({ error: message })
}
})
app.post("/traffic/flow/overlay", applyOverlayHandler)
app.post("/traffic/flow-overlay", applyOverlayHandler)
+23
View File
@@ -0,0 +1,23 @@
import { eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { listVxlanTunnels, listVxlanTunnelsForServer } from "../services/vxlan-live.js"
import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js"
const vxlanRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/vxlan", async (_req, reply) => {
const tunnels = await listVxlanTunnels()
return reply.send({ tunnels })
})
app.get("/servers/:id/vxlan", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const server = (await db.select().from(servers).where(eq(servers.id, params.id)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
const tunnels = await listVxlanTunnelsForServer(server)
return reply.send({ tunnels })
})
}
export default vxlanRoutes
@@ -0,0 +1,8 @@
import { initDatabase } from "../db/bootstrap.js"
import { closePool } from "../db/index.js"
import { rebuildFlowFactsFromBuckets } from "../services/traffic-flow-facts-rebuild.js"
await initDatabase()
const result = await rebuildFlowFactsFromBuckets()
console.log(JSON.stringify(result, null, 2))
await closePool()
+13
View File
@@ -28,3 +28,16 @@ export async function fetchBgpSessionsForAlerts(): Promise<BgpSessionRead[]> {
)
return results.flat()
}
export async function countBgpSessions(): Promise<number> {
try {
const result = await Promise.race([
fetchBgpSessionsForAlerts(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8_000)),
])
if (!result) return 0
return result.length
} catch {
return 0
}
}
@@ -0,0 +1,46 @@
import assert from "node:assert/strict"
import { mapContainerRow } from "./containers-live.js"
const server = {
id: 3,
name: "mt-spb",
host: "10.0.1.1",
} as Parameters<typeof mapContainerRow>[0]
const row = mapContainerRow(
server,
{
".id": "*A",
name: "adguard",
"remote-image": "adguard/adguardhome:latest",
interface: "veth-adguard",
envlist: "adguard-envs",
mounts: "agh-conf,agh-work",
status: "running",
"start-on-boot": "true",
comment: "DNS",
},
[
{ name: "adguard-envs", key: "FOO", value: "bar" },
{ name: "other", key: "SKIP", value: "x" },
],
[
{ name: "agh-conf", dst: "/opt/conf", src: "/disk1/conf" },
{ name: "agh-work", dst: "/opt/work" },
],
0,
)
assert.equal(row.rosId, "*A")
assert.equal(row.image, "adguard/adguardhome")
assert.equal(row.tag, "latest")
assert.equal(row.status, "running")
assert.deepEqual(row.interfaces, ["veth-adguard"])
assert.deepEqual(row.envs, [{ key: "FOO", value: "bar" }])
assert.deepEqual(row.mounts, [
{ dst: "/opt/conf", src: "/disk1/conf" },
{ dst: "/opt/work", src: undefined },
])
assert.equal(row.startOnBoot, true)
console.log("containers-live.test.ts: ok")
+215
View File
@@ -0,0 +1,215 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient, MikrotikError } from "./mikrotik.js"
type ServerRow = typeof servers.$inferSelect
interface RosContainer {
".id"?: string
name?: string
tag?: string
"remote-image"?: string
interface?: string
envlist?: string
mounts?: string
cmd?: string
"start-on-boot"?: string
comment?: string
status?: string
"memory-high"?: string
cpu?: string
}
interface RosContainerEnv {
name?: string
key?: string
value?: string
}
interface RosContainerMount {
name?: string
src?: string
dst?: string
}
export type ContainerLiveStatus = "running" | "stopped" | "error"
export type ContainerLive = {
id: string
rosId: string
name: string
serverId: string
image: string
tag: string
status: ContainerLiveStatus
envs: { key: string; value: string }[]
mounts: { dst: string; src?: string }[]
interfaces: string[]
cmd?: string
startOnBoot: boolean
comment: string
uptime?: string
cpu?: number
memMb?: number
}
function rosYes(v: string | undefined): boolean {
return v === "true" || v === "yes"
}
function mapStatus(raw: string | undefined): ContainerLiveStatus {
const s = (raw ?? "").toLowerCase()
if (s === "running") return "running"
if (s === "error" || s === "failed") return "error"
return "stopped"
}
function splitCsv(v: string | undefined): string[] {
return (v ?? "")
.split(",")
.map((x) => x.trim())
.filter(Boolean)
}
function parseImageTag(c: RosContainer): { image: string; tag: string } {
const remote = (c["remote-image"] ?? "").trim()
if (remote) {
const idx = remote.lastIndexOf(":")
if (idx > 0 && !remote.slice(idx + 1).includes("/")) {
return { image: remote.slice(0, idx), tag: remote.slice(idx + 1) }
}
return { image: remote, tag: (c.tag ?? "latest").trim() || "latest" }
}
return { image: (c.name ?? "").trim(), tag: (c.tag ?? "latest").trim() || "latest" }
}
function isMissingPackage(err: unknown): boolean {
if (err instanceof MikrotikError) {
if (err.statusCode === 404) return true
const body = err.body.toLowerCase()
return body.includes("no such command") || body.includes("not found") || body.includes("unknown")
}
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase()
return msg.includes("no such command") || msg.includes("404")
}
export function mapContainerRow(
server: ServerRow,
c: RosContainer,
envs: RosContainerEnv[],
mounts: RosContainerMount[],
idx: number,
): ContainerLive {
const rosId = String(c[".id"] ?? `c-${idx}`)
const name = (c.name ?? "").trim() || `container-${idx + 1}`
const { image, tag } = parseImageTag(c)
const envlist = (c.envlist ?? "").trim()
const mountNames = new Set(splitCsv(c.mounts))
const envRows = envlist
? envs.filter((e) => (e.name ?? "").trim() === envlist && (e.key ?? "").trim())
: []
const mountRows = mounts.filter((m) => mountNames.has((m.name ?? "").trim()) && (m.dst ?? "").trim())
const cpuRaw = Number.parseInt(c.cpu ?? "", 10)
const memRaw = Number.parseInt(c["memory-high"] ?? "", 10)
return {
id: `${server.id}-${rosId}`,
rosId,
name,
serverId: String(server.id),
image,
tag,
status: mapStatus(c.status),
envs: envRows.map((e) => ({ key: e.key ?? "", value: e.value ?? "" })),
mounts: mountRows.map((m) => ({ dst: m.dst ?? "", src: m.src || undefined })),
interfaces: splitCsv(c.interface),
cmd: (c.cmd ?? "").trim() || undefined,
startOnBoot: rosYes(c["start-on-boot"]),
comment: c.comment ?? "",
cpu: Number.isFinite(cpuRaw) ? cpuRaw : undefined,
memMb: Number.isFinite(memRaw) ? Math.round(memRaw / (1024 * 1024)) || undefined : undefined,
}
}
async function fetchContainersForServer(server: ServerRow): Promise<ContainerLive[]> {
const client = MikrotikClient.fromServer(server)
try {
const [raw, envsRaw, mountsRaw] = await Promise.all([
client.get<RosContainer[]>("/container"),
client.get<RosContainerEnv[]>("/container/envs").catch(() => [] as RosContainerEnv[]),
client.get<RosContainerMount[]>("/container/mounts").catch(() => [] as RosContainerMount[]),
])
const list = Array.isArray(raw) ? raw : []
const envs = Array.isArray(envsRaw) ? envsRaw : []
const mounts = Array.isArray(mountsRaw) ? mountsRaw : []
return list.map((c, idx) => mapContainerRow(server, c, envs, mounts, idx))
} catch (err) {
if (isMissingPackage(err)) return []
throw err
}
}
export async function listContainers(): Promise<ContainerLive[]> {
const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results = await Promise.all(
enabledServers.map(async (server) => {
try {
return await fetchContainersForServer(server)
} catch {
return [] as ContainerLive[]
}
}),
)
return results.flat()
}
export async function listContainersForServer(server: ServerRow): Promise<ContainerLive[]> {
try {
return await fetchContainersForServer(server)
} catch {
return []
}
}
export async function countContainers(): Promise<number> {
try {
const result = await Promise.race([
listContainers(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8_000)),
])
if (!result) return 0
return result.length
} catch {
return 0
}
}
function encodeRosId(rosId: string): string {
return encodeURIComponent(rosId)
}
export async function startContainer(server: ServerRow, rosId: string): Promise<void> {
const client = MikrotikClient.fromServer(server)
await client.post("/container/start", { ".id": rosId })
}
export async function stopContainer(server: ServerRow, rosId: string): Promise<void> {
const client = MikrotikClient.fromServer(server)
await client.post("/container/stop", { ".id": rosId })
}
export async function restartContainer(server: ServerRow, rosId: string): Promise<void> {
await stopContainer(server, rosId)
await startContainer(server, rosId)
}
export async function removeContainer(server: ServerRow, rosId: string): Promise<void> {
const client = MikrotikClient.fromServer(server)
await client.delete(`/container/${encodeRosId(rosId)}`)
}
export async function getEnabledServerById(serverId: string | number) {
const id = typeof serverId === "number" ? serverId : Number.parseInt(String(serverId), 10)
if (!Number.isFinite(id)) return null
return (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0] ?? null
}
@@ -0,0 +1,22 @@
import assert from "node:assert/strict"
import type { RosIpRoute } from "../types/server.js"
import { parseOspfGateway, parseOspfRouteType } from "./ospf-route-parse.js"
function route(partial: Partial<RosIpRoute>): RosIpRoute {
return { ".id": "*1", "dst-address": "10.0.0.0/8", ...partial }
}
assert.equal(parseOspfRouteType(route({ static: "true" })), null)
assert.equal(parseOspfRouteType(route({ bgp: "true" })), null)
assert.equal(parseOspfRouteType(route({ ospf: "true" })), "O")
assert.equal(parseOspfRouteType(route({ "ospf-type": "intra-area" })), "O")
assert.equal(parseOspfRouteType(route({ ospf: "true", "ospf-type": "inter-area" })), "O IA")
assert.equal(parseOspfRouteType(route({ ospf: "true", "ospf-type": "ext-type-1" })), "O E1")
assert.equal(parseOspfRouteType(route({ ospf: "true", "ospf-type": "type-2" })), "O E2")
assert.deepEqual(parseOspfGateway(route({ gateway: "10.200.0.1%gre-msk-spb" })), {
nextHop: "10.200.0.1",
via: "gre-msk-spb",
})
console.log("ospf-route-parse.test.ts: ok")
+28
View File
@@ -0,0 +1,28 @@
import type { RosIpRoute } from "../types/server.js"
export type OspfRouteKind = "O" | "O IA" | "O E1" | "O E2"
/** RouterOS /ip/route → тип OSPF-маршрута UI, либо null если маршрут не OSPF. */
export function parseOspfRouteType(r: RosIpRoute): OspfRouteKind | null {
const ospfFlag = r.ospf === "true" || r.ospf === "yes"
const raw = `${r["ospf-type"] ?? ""} ${r.type ?? ""}`.toLowerCase()
const looksOspf = ospfFlag || raw.includes("ospf") || Boolean(r["ospf-type"])
if (!looksOspf) return null
if (raw.includes("inter")) return "O IA"
if (raw.includes("e1") || raw.includes("type-1") || raw.includes("ext-1") || raw.includes("nssa-ext-type-1")) {
return "O E1"
}
if (raw.includes("e2") || raw.includes("type-2") || raw.includes("ext-2") || raw.includes("nssa-ext-type-2")) {
return "O E2"
}
return "O"
}
export function parseOspfGateway(r: RosIpRoute): { nextHop: string; via: string } {
const gw = (r.gateway ?? r["immediate-gw"] ?? "").trim()
const [ip, iface = ""] = gw.split("%")
return {
nextHop: ip || gw || "—",
via: iface || (r.interface ?? "—"),
}
}
@@ -122,6 +122,8 @@ try {
const unique = await getStatistics({ from: "2026-09-01", to: "2026-09-30", planes: "unique" })
assert.equal(unique.grain, "day")
assert.equal(unique.kpis.bytes, 1000)
const uniqueAsnSum = unique.asns.reduce((s, r) => s + r.bytes, 0)
assert.equal(uniqueAsnSum, unique.kpis.bytes, "unique KPI = SUM dest ASN")
assert.equal(unique.kpis.users, 1)
assert.ok(unique.countries.some((r) => r.id === "US"))
assert.ok(unique.users.some((r) => r.id === "u-stats-1"))
+16 -9
View File
@@ -27,11 +27,9 @@ import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-setting
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
import { dedupFlowRowsMaxBytes, flowTupleKey } from "./traffic-flow-dedup.js"
import { enqueueRipeMisses } from "./traffic-flow-ripe.js"
import { resolveFlowIp } from "./traffic-flow-geoip.js"
import { classifyFlowDst, refreshFlowCatalogInBackground } from "./traffic-flow-classify.js"
import { isIsoCountry } from "./traffic-flow-brands.js"
import { classifyFlowPlane, flowBps, shouldKeepPlane } from "./traffic-flow-planes.js"
import { pickInternetPeer } from "./traffic-flow-ip.js"
import { resolveInternetDest } from "./traffic-flow-dest.js"
import { refreshFlowCatalogInBackground } from "./traffic-flow-classify.js"
import {
enGreIfaceNames,
getServerCatalog,
@@ -253,11 +251,20 @@ async function buildFlowAnalyticsUncached(q: FlowAnalyticsQuery): Promise<FlowAn
totalPackets += r.packets
srcs.add(r.src)
dsts.add(r.dst)
const peer = pickInternetPeer(r.src, r.dst, r.srcPort, r.dstPort)
peers.add(peer)
const destMeta = resolveInternetDest({
src: r.src,
dst: r.dst,
proto: r.proto,
srcPort: r.srcPort,
dstPort: r.dstPort,
serverId: r.serverId,
inIface: resolved.name,
topo,
})
if (destMeta.dest) peers.add(destMeta.dest)
const app = applicationName(r.proto, r.dstPort, r.srcPort)
const ripe = resolveFlowIp(peer)
const classified = classifyFlowDst(peer, r.proto, r.dstPort, r.srcPort, ripe)
const ripe = destMeta.ripe
const classified = destMeta.classified
bump(applications, app, r.bytes, r.packets)
bump(protocols, protoName(r.proto), r.bytes, r.packets)
bump(sources, r.src, r.bytes, r.packets)
@@ -269,7 +276,7 @@ async function buildFlowAnalyticsUncached(q: FlowAnalyticsQuery): Promise<FlowAn
const asnLabel = ripe.holder ? `AS${ripe.asn} ${ripe.holder}` : `AS${ripe.asn}`
bump(asns, asnId, r.bytes, r.packets, asnLabel)
}
const dstCountry = ripe?.ok && isIsoCountry(ripe.country) ? ripe.country : ""
const dstCountry = destMeta.country && destMeta.country !== "unknown" ? destMeta.country : ""
if (dstCountry) {
bump(countries, dstCountry, r.bytes, r.packets)
}
@@ -0,0 +1,109 @@
import assert from "node:assert/strict"
import {
ingestParsedFlowsForServerForTests,
resetEngineForTests,
} from "./traffic-flow-engine.js"
import { factsSnapshotForTests } from "./traffic-flow-facts.js"
import { disableCatalogFetchForTests, resetFlowCatalogForTests } from "./traffic-flow-classify.js"
import {
disableRipeEnqueueForTests,
disableRipePersistForTests,
resetRipeCacheForTests,
seedRipeCacheForTests,
} from "./traffic-flow-ripe.js"
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
import { rememberServerIfaces, resetIfaceCacheForTests } from "./traffic-flow-ifindex.js"
disableCatalogFetchForTests()
resetFlowCatalogForTests()
disableRipePersistForTests()
disableRipeEnqueueForTests()
resetRipeCacheForTests()
resetEngineForTests()
resetIfaceCacheForTests()
seedRipeCacheForTests({
prefix: "8.8.8.0/24",
asn: 15169,
country: "US",
lat: null,
lng: null,
holder: "GOOGLE",
ok: true,
fetchedAt: Date.now(),
})
seedRipeCacheForTests({
prefix: "95.167.0.0/16",
asn: 12389,
country: "RU",
lat: null,
lng: null,
holder: "ROSTELECOM-AS",
ok: true,
fetchedAt: Date.now(),
})
const topo: FlowTopology = {
clientIfaces: new Map([[1, new Set(["gre-client"])]]),
clientByIface: new Map([["1|gre-client", {
userId: "u-rost",
login: "alice",
name: "Alice",
serverId: 1,
interfaceName: "gre-client",
}]]),
enNodes: [{ id: 2, name: "en", hosts: ["198.51.100.1"] }],
enHosts: new Set(["198.51.100.1"]),
jhHosts: new Set(["203.0.113.10"]),
wanIfaces: new Map([[1, new Set(["ether1"])]]),
plane: {
clientIfaceNames: new Set(["gre-client"]),
enHosts: new Set(["198.51.100.1"]),
jhHosts: new Set(["203.0.113.10"]),
},
}
seedFlowTopologyForTests(topo)
rememberServerIfaces(1, [{ name: "gre-client", ifindex: "2" }])
ingestParsedFlowsForServerForTests(1, [
{
src: "95.167.1.10",
dst: "10.200.100.53",
proto: 6,
srcPort: 51234,
dstPort: 443,
bytes: 100,
packets: 2,
inIface: "gre-client",
outIface: "ether1",
},
{
src: "95.167.1.10",
dst: "8.8.8.8",
proto: 6,
srcPort: 51234,
dstPort: 443,
bytes: 50,
packets: 1,
inIface: "gre-client",
outIface: "ether1",
},
])
const facts = factsSnapshotForTests()
const total = facts.reduce((s, r) => s + r.bytes, 0)
const asnBytes = facts.reduce((s, r) => s + r.bytes, 0)
assert.equal(total, 150)
assert.equal(asnBytes, 150, "unique bytes = SUM dest ASN")
assert.equal(facts.some((r) => r.asn === 12389), false, "ASN клиента не в кубе")
const google = facts.find((r) => r.asn === 15169)
assert.ok(google)
assert.equal(google.bytes, 50)
const other = facts.filter((r) => r.asn === 0).reduce((s, r) => s + r.bytes, 0)
assert.equal(other, 100)
resetEngineForTests()
seedFlowTopologyForTests(null)
resetRipeCacheForTests()
resetIfaceCacheForTests()
console.log("traffic-flow-dest.test.ts: ok")
+60
View File
@@ -0,0 +1,60 @@
import { isIsoCountry } from "./traffic-flow-brands.js"
import { classifyFlowDst, type FlowClassification } from "./traffic-flow-classify.js"
import { resolveFlowIp } from "./traffic-flow-geoip.js"
import { canonicalFactIface } from "./traffic-flow-ifindex.js"
import { pickInternetDest, type InternetDestCtx } from "./traffic-flow-ip.js"
import type { FlowIpMeta } from "./traffic-flow-ripe.js"
import {
flowOursHosts,
resolveClient,
type FlowTopology,
} from "./traffic-flow-topology.js"
export interface InternetDestMeta {
dest: string
ripe: FlowIpMeta | null
classified: FlowClassification
country: string
asn: number
}
export function destCtxForIface(
topo: FlowTopology | null | undefined,
serverId: number,
inIface: string,
): InternetDestCtx {
const name = canonicalFactIface(serverId, inIface) || String(inIface ?? "").trim()
return {
ours: flowOursHosts(topo),
boundClient: Boolean(topo && name && resolveClient(topo, serverId, name)),
}
}
export function resolveInternetDest(opts: {
src: string
dst: string
proto: number
srcPort: number
dstPort: number
serverId: number
inIface: string
topo?: FlowTopology | null
}): InternetDestMeta {
const dest = pickInternetDest(
opts.src,
opts.dst,
opts.srcPort,
opts.dstPort,
destCtxForIface(opts.topo, opts.serverId, opts.inIface),
)
const ripe = dest ? resolveFlowIp(dest) : null
const classified = classifyFlowDst(dest || opts.dst, opts.proto, opts.dstPort, opts.srcPort, ripe)
if (!dest) {
return { dest: "", ripe: null, classified, country: "", asn: 0 }
}
const country = ripe?.ok && isIsoCountry(ripe.country)
? ripe.country
: (ripe?.ok ? "" : "unknown")
const asn = ripe?.ok && ripe.asn ? ripe.asn : 0
return { dest, ripe, classified, country, asn }
}
+31 -20
View File
@@ -4,15 +4,12 @@ import { normalizeParsedFlow, parseFlowPacket, protoName, type ParsedFlow, type
import { classifyFlowPlaneLite } from "./traffic-flow-planes.js"
import { pickServerIdForExporter, type OverlayPeerRef } from "./traffic-flow-map-exporter.js"
import { applicationName } from "./traffic-flow-apps.js"
import { classifyFlowDst } from "./traffic-flow-classify.js"
import { enqueueRipeMisses, pruneRipeSqlite } from "./traffic-flow-ripe.js"
import { resolveFlowIp } from "./traffic-flow-geoip.js"
import { invalidateTrafficFlowSettingsCache } from "./traffic-flow-settings.js"
import { isIsoCountry } from "./traffic-flow-brands.js"
import { maybeRefreshIfaces } from "./traffic-flow-ifaces.js"
import { canonicalFactIface } from "./traffic-flow-ifindex.js"
import { shouldWriteFlowFact } from "./traffic-flow-facts-filter.js"
import { pickInternetPeer } from "./traffic-flow-ip.js"
import { resolveInternetDest } from "./traffic-flow-dest.js"
import {
getServerCatalog,
loadFlowTopology,
@@ -354,15 +351,22 @@ export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): vo
const flow = normalizeParsedFlow(raw)
addToTick(serverId, flow, flow.bytes)
bumpRollup(serverId, bucketAt, flow, flow.bytes, flow.packets)
const peer = pickInternetPeer(flow.src, flow.dst, flow.srcPort, flow.dstPort)
const ripe = resolveFlowIp(peer)
if (peer && !ripe) ripeMisses.push(peer)
const classified = classifyFlowDst(peer, flow.proto, flow.dstPort, flow.srcPort, ripe)
const destMeta = resolveInternetDest({
src: flow.src,
dst: flow.dst,
proto: flow.proto,
srcPort: flow.srcPort,
dstPort: flow.dstPort,
serverId,
inIface: flow.inIface,
topo,
})
const ripe = destMeta.ripe
if (destMeta.dest && !ripe) ripeMisses.push(destMeta.dest)
const classified = destMeta.classified
const app = applicationName(flow.proto, flow.dstPort, flow.srcPort)
const country = ripe?.ok && isIsoCountry(ripe.country)
? ripe.country
: (ripe?.ok ? "" : "unknown")
const asnKey = ripe?.ok && ripe.asn ? String(ripe.asn) : "unknown"
const country = destMeta.country
const asnKey = destMeta.asn ? String(destMeta.asn) : "unknown"
bumpDim(serverId, bucketAt, "proto", protoName(flow.proto), flow.bytes, flow.packets)
bumpDim(serverId, bucketAt, "app", app, flow.bytes, flow.packets)
bumpDim(serverId, bucketAt, "iface", flow.inIface || "__unknown__", flow.bytes, flow.packets)
@@ -388,7 +392,7 @@ export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): vo
iface: canonicalFactIface(serverId, flow.inIface),
country: country || "XX",
service: classified.service,
asn: ripe?.ok && ripe.asn ? ripe.asn : 0,
asn: destMeta.asn,
bytes: flow.bytes,
packets: flow.packets,
})
@@ -867,10 +871,11 @@ async function upsertFlowBuckets(rows: PendingFlowRow[]): Promise<number> {
}
}
export async function flushPending(opts?: { force?: boolean }): Promise<void> {
export async function flushPending(opts?: { force?: boolean; prune?: boolean }): Promise<void> {
pruneRecent()
rollFlowRings()
const force = Boolean(opts?.force)
const doPrune = opts?.prune !== false
const hasWork = pending.size > 0 || minuteRollup.size > 0 || minuteDims.size > 0 || factsPendingSize() > 0
const due = persistDue(force, hasWork)
try {
@@ -880,7 +885,7 @@ export async function flushPending(opts?: { force?: boolean }): Promise<void> {
}
if (!hasWork) {
if (force) {
if (force && doPrune) {
try {
await pruneStored()
} catch {
@@ -934,10 +939,12 @@ export async function flushPending(opts?: { force?: boolean }): Promise<void> {
} catch {
/* statistics cube best-effort */
}
try {
await pruneStored()
} catch {
/* prune best-effort */
if (doPrune) {
try {
await pruneStored()
} catch {
/* prune best-effort */
}
}
}
@@ -945,8 +952,12 @@ export function lastFlushUsedTransactionForTests(): boolean {
return lastFlushUsedTransaction
}
export async function flushEngineNow(opts?: { prune?: boolean }): Promise<void> {
await flushPending({ force: true, prune: opts?.prune })
}
export async function flushPendingForTests(): Promise<void> {
await flushPending({ force: true })
await flushEngineNow()
}
export function onEngineTick(): void {
@@ -0,0 +1,138 @@
import assert from "node:assert/strict"
import { dbQuery } from "../db/index.js"
import { withPgOrSkip } from "../test/pg.js"
import { ensurePartitionFor } from "../db/partitions.js"
import { pool } from "../db/index.js"
import { invalidateFlowCatalogCache } from "./traffic-flow-topology.js"
import {
disableRipeEnqueueForTests,
disableRipePersistForTests,
resetRipeCacheForTests,
seedRipeCacheForTests,
} from "./traffic-flow-ripe.js"
import { rememberServerIfaces, resetIfaceCacheForTests } from "./traffic-flow-ifindex.js"
import { resetEngineForTests } from "./traffic-flow-engine.js"
import { disableCatalogFetchForTests, resetFlowCatalogForTests } from "./traffic-flow-classify.js"
if (!(await withPgOrSkip())) {
console.log("traffic-flow-facts-rebuild.test.ts: skip")
process.exit(0)
}
const nServers = (await dbQuery<{ n: number }>(`SELECT COUNT(*)::int AS n FROM servers`)).rows[0]?.n ?? 0
if (nServers > 10) {
console.warn("traffic-flow-facts-rebuild.test.ts: skip (не пустая БД)")
process.exit(0)
}
disableCatalogFetchForTests()
resetFlowCatalogForTests()
disableRipePersistForTests()
disableRipeEnqueueForTests()
resetRipeCacheForTests()
resetEngineForTests()
resetIfaceCacheForTests()
seedRipeCacheForTests({
prefix: "8.8.8.0/24",
asn: 15169,
country: "US",
lat: null,
lng: null,
holder: "GOOGLE",
ok: true,
fetchedAt: Date.now(),
})
seedRipeCacheForTests({
prefix: "95.167.0.0/16",
asn: 12389,
country: "RU",
lat: null,
lng: null,
holder: "ROSTELECOM-AS",
ok: true,
fetchedAt: Date.now(),
})
const inserted = await dbQuery<{ id: number }>(`
INSERT INTO servers (name, host, type, wan_uplinks)
VALUES ('rebuild-facts-jh', '203.0.113.10', 'jump-host', '[{"iface":"ether1"}]'::jsonb)
RETURNING id
`)
const serverId = inserted.rows[0]?.id
if (serverId == null) throw new Error("no server")
const ts = new Date()
await ensurePartitionFor(pool, "flow_buckets", "day", ts)
await ensurePartitionFor(pool, "flow_hour_facts", "day", ts)
await ensurePartitionFor(pool, "flow_daily_facts", "month", ts)
await dbQuery(`DELETE FROM flow_buckets WHERE server_id = $1`, [serverId])
await dbQuery(`DELETE FROM flow_hour_facts WHERE server_id = $1`, [serverId])
await dbQuery(`DELETE FROM flow_daily_facts WHERE server_id = $1`, [serverId])
await dbQuery(`DELETE FROM user_interface_bindings WHERE server_id = $1`, [serverId])
await dbQuery(`DELETE FROM app_users WHERE id = 'u-rebuild-1'`)
await dbQuery(`
INSERT INTO app_users (id, name, login, role, active)
VALUES ('u-rebuild-1', 'Клиент', 'rebuild-user', 'viewer', TRUE)
ON CONFLICT (id) DO NOTHING
`)
await dbQuery(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
VALUES ('bind-rebuild-1', 'u-rebuild-1', $1, 'gre-client', 'gre')
`, [serverId])
await dbQuery(`
INSERT INTO server_snapshots (server_id, polled_at, status, raw_interfaces)
VALUES ($1, now(), 'online', $2::jsonb)
`, [serverId, JSON.stringify([{ name: "gre-client", type: "gre-tunnel" }, { name: "ether1", type: "ether" }])])
rememberServerIfaces(serverId, [{ name: "gre-client", ifindex: "2" }])
invalidateFlowCatalogCache()
const bucketAt = new Date(Date.UTC(
ts.getUTCFullYear(),
ts.getUTCMonth(),
ts.getUTCDate(),
ts.getUTCHours(),
0, 0, 0,
)).toISOString()
await dbQuery(`
INSERT INTO flow_buckets (server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface, out_iface)
VALUES
($1, $2, '95.167.1.10', '10.200.100.53', 6, 51234, 443, 100, 2, 'gre-client', 'ether1'),
($1, $2, '95.167.1.10', '8.8.8.8', 6, 51234, 443, 50, 1, 'gre-client', 'ether1')
`, [serverId, bucketAt])
try {
const { rebuildFlowFactsFromBuckets } = await import("./traffic-flow-facts-rebuild.js")
const result = await rebuildFlowFactsFromBuckets()
assert.equal(result.ok, true)
assert.ok(result.buckets >= 2)
const rows = await dbQuery<{ asn: number; bytes: number }>(`
SELECT asn, SUM(bytes)::bigint AS bytes
FROM flow_hour_facts
WHERE server_id = $1
GROUP BY asn
`, [serverId])
const byAsn = new Map(rows.rows.map((r) => [Number(r.asn), Number(r.bytes)]))
const total = [...byAsn.values()].reduce((s, n) => s + n, 0)
assert.equal(total, 150)
assert.equal(byAsn.get(12389), undefined, "ASN клиента не в hour facts")
assert.equal(byAsn.get(15169), 50)
assert.equal(byAsn.get(0), 100)
} finally {
await dbQuery(`DELETE FROM flow_hour_facts WHERE server_id = $1`, [serverId])
await dbQuery(`DELETE FROM flow_daily_facts WHERE server_id = $1`, [serverId])
await dbQuery(`DELETE FROM flow_buckets WHERE server_id = $1`, [serverId])
await dbQuery(`DELETE FROM user_interface_bindings WHERE server_id = $1`, [serverId])
await dbQuery(`DELETE FROM server_snapshots WHERE server_id = $1`, [serverId])
await dbQuery(`DELETE FROM app_users WHERE id = 'u-rebuild-1'`)
await dbQuery(`DELETE FROM servers WHERE id = $1`, [serverId])
resetEngineForTests()
invalidateFlowCatalogCache()
}
console.log("traffic-flow-facts-rebuild.test.ts: ok")
@@ -0,0 +1,130 @@
import { dbAll, dbGet, dbQuery, withAdvisoryLock } from "../db/index.js"
import { flushEngineNow } from "./traffic-flow-engine.js"
import { resolveInternetDest } from "./traffic-flow-dest.js"
import {
bumpFlowFact,
discardPendingFacts,
flushFlowFacts,
hourBucketIso,
} from "./traffic-flow-facts.js"
import { shouldWriteFlowFact } from "./traffic-flow-facts-filter.js"
import { canonicalFactIface } from "./traffic-flow-ifindex.js"
import { getServerCatalog, loadFlowTopology } from "./traffic-flow-topology.js"
export const FACT_REBUILD_LOCK_KEY = 8_723_104
const BATCH = 4_000
export interface FlowFactsRebuildResult {
ok: true
buckets: number
facts: number
days: string[]
}
function hourFromBucket(raw: Date | string): string {
const iso = raw instanceof Date ? raw.toISOString() : String(raw)
const ms = new Date(iso).getTime()
return hourBucketIso(Number.isFinite(ms) ? ms : Date.now())
}
export async function rebuildFlowFactsFromBuckets(): Promise<FlowFactsRebuildResult> {
return await withAdvisoryLock(FACT_REBUILD_LOCK_KEY, async () => {
await flushEngineNow({ prune: false })
discardPendingFacts()
const days = await dbAll<{ day: string }>(`
SELECT DISTINCT (bucket_at AT TIME ZONE 'UTC')::date::text AS day
FROM flow_buckets
ORDER BY 1
`)
const dayList = days.map((r) => r.day).filter(Boolean)
if (dayList.length === 0) {
return { ok: true as const, buckets: 0, facts: 0, days: [] }
}
await dbQuery(
`DELETE FROM flow_hour_facts WHERE (bucket_at AT TIME ZONE 'UTC')::date = ANY(?::date[])`,
[dayList],
)
await dbQuery(
`DELETE FROM flow_daily_facts WHERE day = ANY(?::date[])`,
[dayList],
)
const topo = await loadFlowTopology()
const catalog = await getServerCatalog()
let offset = 0
let buckets = 0
for (;;) {
const rows = await dbAll<{
serverId: number
bucketAt: Date | string
src: string
dst: string
proto: number
srcPort: number
dstPort: number
bytes: number
packets: number
inIface: string
outIface: string
}>(`
SELECT server_id AS "serverId", bucket_at AS "bucketAt",
host(src) AS src, host(dst) AS dst, proto, src_port AS "srcPort", dst_port AS "dstPort",
bytes, packets, in_iface AS "inIface", COALESCE(out_iface, '') AS "outIface"
FROM flow_buckets
ORDER BY bucket_at, server_id
LIMIT ? OFFSET ?
`, [BATCH, offset])
if (rows.length === 0) break
for (const row of rows) {
buckets += 1
const serverType = catalog.byId.get(row.serverId)?.type
if (!shouldWriteFlowFact({
serverId: row.serverId,
serverType,
inIface: row.inIface,
outIface: row.outIface,
proto: Number(row.proto) || 0,
srcPort: Number(row.srcPort) || 0,
dstPort: Number(row.dstPort) || 0,
src: row.src,
dst: row.dst,
topo,
})) continue
const destMeta = resolveInternetDest({
src: row.src,
dst: row.dst,
proto: Number(row.proto) || 0,
srcPort: Number(row.srcPort) || 0,
dstPort: Number(row.dstPort) || 0,
serverId: row.serverId,
inIface: row.inIface,
topo,
})
bumpFlowFact({
serverId: row.serverId,
bucketAt: hourFromBucket(row.bucketAt),
iface: canonicalFactIface(row.serverId, row.inIface),
country: destMeta.country || "XX",
service: destMeta.classified.service,
asn: destMeta.asn,
bytes: Number(row.bytes) || 0,
packets: Number(row.packets) || 0,
})
}
offset += rows.length
if (rows.length < BATCH) break
}
const facts = await flushFlowFacts()
const range = await dbGet<{ n: number }>(`SELECT COUNT(*)::int AS n FROM flow_buckets`)
return {
ok: true as const,
buckets: Number(range?.n) || buckets,
facts,
days: dayList,
}
})
}
@@ -275,6 +275,10 @@ export function resetFactsForTests(): void {
ensuredParts.clear()
}
export function discardPendingFacts(): void {
hourFacts.clear()
}
export function factsSnapshotForTests(): FactRow[] {
const parsed: FactRow[] = []
for (const [k, acc] of hourFacts) {
+36 -1
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict"
import { isNonPublicIp, pickInternetPeer } from "./traffic-flow-ip.js"
import { isNonPublicIp, pickInternetDest, pickInternetPeer } from "./traffic-flow-ip.js"
assert.equal(isNonPublicIp("10.200.100.53"), true)
assert.equal(isNonPublicIp("173.194.151.65"), false)
@@ -22,4 +22,39 @@ assert.equal(
)
assert.equal(pickInternetPeer("10.1.1.1", "10.2.2.2", 443, 80), "10.2.2.2")
const rost = "95.167.1.10"
const ours = new Set(["198.51.100.1", "203.0.113.10"])
const client = { ours, boundClient: true }
assert.equal(
pickInternetDest("10.200.100.53", "104.18.35.51", 53880, 443, client),
"104.18.35.51",
"RFC1918 → CF на client GRE",
)
assert.equal(
pickInternetDest("173.194.151.65", "10.200.100.53", 443, 57182, client),
"173.194.151.65",
"Google:443 → RFC1918 на client GRE",
)
assert.equal(
pickInternetDest(rost, "10.200.100.53", 51234, 443, client),
"",
"Rostelecom → overlay 10.x: не dest ASN клиента",
)
assert.equal(
pickInternetDest(rost, "8.8.8.8", 51234, 443, client),
"8.8.8.8",
"Rostelecom → Google:443 на client GRE",
)
assert.equal(
pickInternetDest(rost, "1.1.1.1", 51234, 40000, client),
"1.1.1.1",
"оба публичные без well-known на client GRE → dst",
)
assert.equal(
pickInternetDest("8.8.8.8", "198.51.100.1", 443, 51234, { ours }),
"8.8.8.8",
"ours как dst: dest = публичный src",
)
console.log("traffic-flow-ip.test.ts: ok")
+46 -5
View File
@@ -55,13 +55,46 @@ export function isNonPublicIp(ip: string): boolean {
const PEER_WELL_KNOWN_PORTS = new Set([80, 443, 53, 853])
export interface InternetDestCtx {
/** WAN IP узлов сети (EN/JH) — не интернет-назначение. */
ours?: ReadonlySet<string>
/** Ingress с bound GRE/WG клиента: dest = нелокальный IP, не ASN клиента. */
boundClient?: boolean
}
export function isLocalIp(ip: string, ours?: ReadonlySet<string>): boolean {
if (isNonPublicIp(ip)) return true
return Boolean(ours?.has(String(ip ?? "").trim()))
}
/**
* Интернет-сторона потока: у IPFIX сервис часто в src (Google:443 RFC1918:ephemeral).
* Классифицировать этот IP, не слепой dst.
* Интернет-назначение потока для ASN/страны/сервиса.
* Пустая строка dest нет (не GeoIP IP клиента).
*/
export function pickInternetPeer(src: string, dst: string, srcPort: number, dstPort: number): string {
const srcPub = !isNonPublicIp(src)
const dstPub = !isNonPublicIp(dst)
export function pickInternetDest(
src: string,
dst: string,
srcPort: number,
dstPort: number,
ctx?: InternetDestCtx,
): string {
const ours = ctx?.ours
const srcLocal = isLocalIp(src, ours)
const dstLocal = isLocalIp(dst, ours)
const srcPub = !srcLocal
const dstPub = !dstLocal
if (ctx?.boundClient) {
if (dstPub) return dst
if (srcPub && dstLocal) {
const srcWk = PEER_WELL_KNOWN_PORTS.has(srcPort)
const dstWk = PEER_WELL_KNOWN_PORTS.has(dstPort)
if (srcWk && !dstWk) return src
return ""
}
return ""
}
if (srcPub && !dstPub) return src
if (dstPub && !srcPub) return dst
if (srcPub && dstPub) {
@@ -72,3 +105,11 @@ export function pickInternetPeer(src: string, dst: string, srcPort: number, dstP
}
return dst
}
/**
* Интернет-сторона потока без топологии: у IPFIX сервис часто в src (Google:443 RFC1918).
* Для куба статистики используйте pickInternetDest.
*/
export function pickInternetPeer(src: string, dst: string, srcPort: number, dstPort: number): string {
return pickInternetDest(src, dst, srcPort, dstPort) || dst
}
+12 -4
View File
@@ -12,7 +12,8 @@ import { dedupFlowRowsAcrossExporters, dedupFlowRowsMaxBytes } from "./traffic-f
import { getFlowListenerState, listFlowRowsForWindow } from "./traffic-flow-ingest.js"
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
import { classifyFlowPlane, shouldKeepPlane } from "./traffic-flow-planes.js"
import { pickInternetPeer } from "./traffic-flow-ip.js"
import { destCtxForIface } from "./traffic-flow-dest.js"
import { pickInternetDest } from "./traffic-flow-ip.js"
import { type FlowIpMeta } from "./traffic-flow-ripe.js"
import { resolveFlowIp } from "./traffic-flow-geoip.js"
import { getTrafficFlowSettingsRow } from "./traffic-flow-settings.js"
@@ -354,9 +355,16 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
const inName = resolveIfaceName(r.serverId, r.inIface).name
const outName = resolveIfaceName(r.serverId, r.outIface).name
totalBytes += r.bytes
const peer = pickInternetPeer(r.src, r.dst, r.srcPort, r.dstPort)
const dest = pickInternetDest(
r.src,
r.dst,
r.srcPort,
r.dstPort,
destCtxForIface(topo, r.serverId, inName),
)
if (!dest) continue
const client = resolveMapClient(topo, r.serverId, inName, outName)
const prevDst = dstAcc.get(peer)
const prevDst = dstAcc.get(dest)
if (prevDst) {
prevDst.bytes += r.bytes
bumpFrom(prevDst, String(r.serverId), r.bytes, client)
@@ -369,7 +377,7 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
fromBytes: new Map(),
}
bumpFrom(acc, String(r.serverId), r.bytes, client)
dstAcc.set(peer, acc)
dstAcc.set(dest, acc)
}
}
@@ -172,6 +172,18 @@ export function seedFlowTopologyForTests(topo: FlowTopology | null): void {
invalidateFlowCatalogCache()
}
export function flowOursHosts(topo: FlowTopology | null | undefined): Set<string> {
const ours = new Set<string>()
if (!topo) return ours
for (const h of topo.enHosts) {
if (h) ours.add(h)
}
for (const h of topo.jhHosts) {
if (h) ours.add(h)
}
return ours
}
export function resolveClient(
topo: FlowTopology,
serverId: number,
+43
View File
@@ -0,0 +1,43 @@
import assert from "node:assert/strict"
import { mapVxlanRow } from "./vxlan-live.js"
const server = {
id: 7,
name: "mt-msk",
host: "10.0.0.1",
site: "MSK",
country: "RU",
} as Parameters<typeof mapVxlanRow>[0]
const row = mapVxlanRow(
server,
{
".id": "*3",
name: "vxlan-10",
vni: "10010",
port: "8472",
"local-address": "10.0.0.1",
running: "true",
disabled: "false",
l2mtu: "1500",
"mac-learning": "true",
"arp-proxy": "true",
comment: "overlay",
},
[
{ interface: "vxlan-10", "remote-ip": "10.0.1.1" },
{ interface: "other", "remote-ip": "1.1.1.1" },
{ interface: "vxlan-10", "remote-ip": "10.0.2.1" },
],
0,
)
assert.equal(row.serverId, "7")
assert.equal(row.vni, 10010)
assert.equal(row.dstPort, 8472)
assert.equal(row.status, "up")
assert.equal(row.enabled, true)
assert.deepEqual(row.remoteVteps, ["10.0.1.1", "10.0.2.1"])
assert.equal(row.vtepIp, "10.0.0.1")
console.log("vxlan-live.test.ts: ok")
+136
View File
@@ -0,0 +1,136 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "./mikrotik.js"
type ServerRow = typeof servers.$inferSelect
interface RosVxlan {
".id"?: string
name?: string
vni?: string
port?: string
"local-address"?: string
"vtep-address"?: string
running?: string
disabled?: string
comment?: string
l2mtu?: string
arp?: string
"arp-proxy"?: string
"mac-learning"?: string
learning?: string
}
interface RosVxlanVtep {
".id"?: string
interface?: string
"remote-ip"?: string
}
export type VxlanTunnelLive = {
id: string
rosId: string
name: string
vni: number
port: number
dstPort: number
serverId: string
vtepIp: string
remoteVteps: string[]
l2mtu: number
arpProxy: boolean
macLearning: boolean
comment: string
enabled: boolean
status: "up" | "down"
}
function rosYes(v: string | undefined): boolean {
return v === "true" || v === "yes"
}
function parseIntSafe(v: string | undefined, fallback: number): number {
const n = Number.parseInt(v ?? "", 10)
return Number.isFinite(n) ? n : fallback
}
export function mapVxlanRow(
server: ServerRow,
vx: RosVxlan,
vteps: RosVxlanVtep[],
idx: number,
): VxlanTunnelLive {
const name = (vx.name ?? "").trim() || `vxlan-${idx + 1}`
const rosId = String(vx[".id"] ?? name)
const disabled = rosYes(vx.disabled)
const running = rosYes(vx.running)
const port = parseIntSafe(vx.port, 8472)
const remoteVteps = vteps
.filter((v) => (v.interface ?? "").trim() === name)
.map((v) => (v["remote-ip"] ?? "").trim())
.filter(Boolean)
return {
id: `${server.id}-${rosId}`,
rosId,
name,
vni: parseIntSafe(vx.vni, 0),
port: 0,
dstPort: port,
serverId: String(server.id),
vtepIp: (vx["local-address"] ?? vx["vtep-address"] ?? "").trim(),
remoteVteps,
l2mtu: parseIntSafe(vx.l2mtu, 1500),
arpProxy: rosYes(vx["arp-proxy"]) || vx.arp === "proxy-arp" || vx.arp === "enabled",
macLearning: vx["mac-learning"] != null ? rosYes(vx["mac-learning"]) : vx.learning !== "false",
comment: vx.comment ?? "",
enabled: !disabled,
status: !disabled && running ? "up" : "down",
}
}
async function fetchVxlanForServer(server: ServerRow): Promise<VxlanTunnelLive[]> {
const client = MikrotikClient.fromServer(server)
const [vxRaw, vtepRaw] = await Promise.all([
client.get<RosVxlan[]>("/interface/vxlan"),
client.get<RosVxlanVtep[]>("/interface/vxlan/vteps").catch(() => [] as RosVxlanVtep[]),
])
const list = Array.isArray(vxRaw) ? vxRaw : []
const vteps = Array.isArray(vtepRaw) ? vtepRaw : []
return list.map((vx, idx) => mapVxlanRow(server, vx, vteps, idx))
}
export async function listVxlanTunnels(): Promise<VxlanTunnelLive[]> {
const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results = await Promise.all(
enabledServers.map(async (server) => {
try {
return await fetchVxlanForServer(server)
} catch {
return [] as VxlanTunnelLive[]
}
}),
)
return results.flat()
}
export async function listVxlanTunnelsForServer(server: ServerRow): Promise<VxlanTunnelLive[]> {
try {
return await fetchVxlanForServer(server)
} catch {
return []
}
}
export async function countVxlanTunnels(): Promise<number> {
try {
const result = await Promise.race([
listVxlanTunnels(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8_000)),
])
if (!result) return 0
return result.length
} catch {
return 0
}
}
+17
View File
@@ -285,6 +285,19 @@ export interface OspfInterfaceRead {
useBfd: boolean
}
export interface OspfRouteRead {
id: string
serverId: number
serverName: string
serverSite: string
destination: string
type: "O" | "O IA" | "O E1" | "O E2"
cost: number
nextHop: string
via: string
area: string
}
export interface OspfInstanceRead {
id: string
serverId: number
@@ -306,6 +319,7 @@ export interface RosIpRoute {
"dst-address": string
"pref-src"?: string
"gateway"?: string
"immediate-gw"?: string
"distance"?: string
"scope"?: string
"active"?: string // "true"
@@ -314,6 +328,9 @@ export interface RosIpRoute {
"connect"?: string
"bgp"?: string
"ospf"?: string
"ospf-type"?: string
"ospf-metric"?: string
"ospf-area"?: string
"rip"?: string
"blackhole"?: string
"unreachable"?: string
+12 -5
View File
@@ -106,7 +106,15 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
},
]
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number; wireguard?: number; users?: number }
type LiveSidebarCounts = SidebarCountsDto & {
greTunnels?: number
certificates?: number
wireguard?: number
users?: number
bgpSessions?: number
vxlan?: number
containers?: number
}
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { mode, backendUrl, prefsHydrated } = useDataSource()
@@ -171,10 +179,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0)
if (url === "/certificates") return formatSidebarBadgeCount(liveCounts.certificates ?? 0)
if (url === "/wireguard") return formatSidebarBadgeCount(liveCounts.wireguard ?? 0)
if (url === "/containers" || url === "/bgp") {
return undefined
}
if (url === "/bgp") return formatSidebarBadgeCount(liveCounts.bgpSessions ?? 0)
if (url === "/vxlan") return formatSidebarBadgeCount(liveCounts.vxlan ?? 0)
if (url === "/containers") return formatSidebarBadgeCount(liveCounts.containers ?? 0)
return undefined
}
+13 -8
View File
@@ -1,6 +1,6 @@
"use client"
import { useMemo } from "react"
import { useCallback, useMemo, useState } from "react"
import { format } from "date-fns"
import { ru } from "date-fns/locale"
import { CalendarIcon } from "lucide-react"
@@ -130,14 +130,19 @@ export function PeriodSelector({
range: DateRangeYmd
onChange: (next: DateRangeYmd) => void
}) {
const [open, setOpen] = useState(false)
const selectorValue = useMemo(() => rangeToSelector(range), [range])
function handleSelectorChange(value: DateSelectorValue) {
const next = dateSelectorToRange(value)
if (!next) return
if (next.from === range.from && next.to === range.to) return
onChange(next)
}
const handleSelectorChange = useCallback(
(value: DateSelectorValue) => {
const next = dateSelectorToRange(value)
if (!next) return
if (next.from === range.from && next.to === range.to) return
onChange(next)
setOpen(false)
},
[onChange, range.from, range.to],
)
const activePreset = PRESETS.find((p) => {
const r = rangeForPreset(p.id)
@@ -157,7 +162,7 @@ export function PeriodSelector({
{p.label}
</Button>
))}
<Popover>
<Popover open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger
render={
<Button type="button" variant="outline" size="sm" className="min-w-40 justify-between" />
+13 -2
View File
@@ -264,7 +264,7 @@ export function useDashboardLive() {
setInternetPathLoading(true)
}
try {
const [overviewRes, serversRes, fr, br, ipRes, greRes, wgRes, trafficRes] = await Promise.allSettled([
const [overviewRes, serversRes, fr, br, ipRes, greRes, wgRes, trafficRes, vxRes] = await Promise.allSettled([
apiFetch<{ probes: PingProbe[] }>("/api/uptime/overview?range=1h"),
apiFetch<BackendServerRow[]>("/api/servers"),
apiFetch<{ rulesets: Array<{ rules?: unknown[] }> }>("/api/filters/rules"),
@@ -273,6 +273,7 @@ export function useDashboardLive() {
apiFetch<{ tunnels?: ApiGreTunnelRow[] }>("/api/filters/gre-tunnels"),
listWireGuard(backendUrl),
apiFetch<{ servers?: TrafficServerRow[] }>("/api/traffic/servers?range=1h"),
apiFetch<{ tunnels?: Array<{ id: string; name: string; status: OverlayItem["status"] }> }>("/api/vxlan"),
])
const hardFail = overviewRes.status === "rejected" && serversRes.status === "rejected"
@@ -333,7 +334,17 @@ export function useDashboardLive() {
status: iface.status,
}))
: []
setOverlayItems([...greItems, ...wgItems])
const vxItems: OverlayItem[] =
vxRes.status === "fulfilled"
? (vxRes.value.tunnels ?? []).map((t) => ({
id: `vx-${t.id}`,
name: t.name,
kind: "vxlan" as const,
href: "/vxlan",
status: t.status === "up" ? "up" : "down",
}))
: []
setOverlayItems([...greItems, ...wgItems, ...vxItems])
if (trafficRes.status === "fulfilled") {
const rows = trafficRes.value.servers ?? []
+1
View File
@@ -233,6 +233,7 @@ export interface VrfInstance {
export interface RouterContainer {
id: string
rosId?: string
name: string
serverId: string
image: string // e.g. "nginx:alpine"
+5
View File
@@ -9,6 +9,7 @@ import {
routerCertificates,
routerContainers,
servers,
vxlanTunnels,
} from "@/lib/data"
/** Число мок-сессий BGP (см. `SESSIONS` в `app/(main)/bgp/page.tsx`). */
@@ -41,6 +42,7 @@ export function mockSidebarBadgesByUrl(): Record<string, string> {
"/filters": formatSidebarBadgeCount(filters.length),
"/wireguard": formatSidebarBadgeCount(mockWireGuardIfacesCount()),
"/gre": formatSidebarBadgeCount(greTunnels.length),
"/vxlan": formatSidebarBadgeCount(vxlanTunnels.length),
"/containers": formatSidebarBadgeCount(routerContainers.length),
"/certificates": formatSidebarBadgeCount(routerCertificates.length),
"/bgp": formatSidebarBadgeCount(MOCK_BGP_SESSION_COUNT),
@@ -57,4 +59,7 @@ export interface SidebarCountsDto {
certificates?: number
wireguard?: number
users?: number
bgpSessions?: number
vxlan?: number
containers?: number
}
+15
View File
@@ -14875,6 +14875,21 @@
"dependencies": {
"zod": "^4.4.1"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}
+8
View File
@@ -252,6 +252,13 @@ export const flowPurgeDtoSchema = z.object({
vacuumed: z.boolean(),
})
export const flowFactsRebuildDtoSchema = z.object({
ok: z.literal(true),
buckets: z.number().int().nonnegative(),
facts: z.number().int().nonnegative(),
days: z.array(z.string()),
})
export const flowMapHopKindSchema = z.enum(["gre", "wan", "iface"])
export const flowMapHopDtoSchema = z.object({
@@ -330,6 +337,7 @@ export type FlowExportersDto = z.infer<typeof flowExportersDtoSchema>
export type FlowClientsDto = z.infer<typeof flowClientsDtoSchema>
export type FlowMonthlyDto = z.infer<typeof flowMonthlyDtoSchema>
export type FlowPurgeDto = z.infer<typeof flowPurgeDtoSchema>
export type FlowFactsRebuildDto = z.infer<typeof flowFactsRebuildDtoSchema>
export type FlowMapHopKind = z.infer<typeof flowMapHopKindSchema>
export type FlowMapHop = z.infer<typeof flowMapHopDtoSchema>
export type FlowMapService = z.infer<typeof flowMapServiceDtoSchema>
+9
View File
@@ -141,4 +141,13 @@ export async function purgeTrafficFlowData(baseUrl: string): Promise<FlowPurgeDt
return requestJson<FlowPurgeDto>(baseUrl, "/api/traffic/flow/purge", { method: "POST" })
}
export async function rebuildTrafficFlowFacts(baseUrl: string): Promise<{
ok: true
buckets: number
facts: number
days: string[]
}> {
return requestJson(baseUrl, "/api/traffic/flow/rebuild-facts", { method: "POST" })
}
export { flowQuery }
+1 -1
View File
File diff suppressed because one or more lines are too long