Compare commits

...
2 Commits
Author SHA1 Message Date
DenozordecandCursor 3687bb8fa2 feat(statistics): добавить раздел статистики трафика
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m19s
Docker images / frontend-image (push) Successful in 4m47s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m53s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 13s
Куб IPFIX час+день с AND-слайсами и экраном отчётности /statistics, живой /traffic не меняем.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 11:57:47 +07:00
Denozordec a80caf5676 feat(netflow): локальные GeoLite2-базы для стран и ASN потоков
Docker images / prepare-release (push) Successful in 13s
Docker images / backend-test (push) Successful in 2m13s
Docker images / frontend-image (push) Successful in 4m31s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 3m1s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 15s
Lookup страны/ASN при ingest теперь идёт сначала по локальным mmdb
MaxMind GeoLite2 (Country + ASN, зеркало P3TERX, без ключей), с
мгновенным синхронным ответом для IPv4/IPv6; RIPEstat остаётся
fallback до первой загрузки баз и при промахе.

- backend/src/services/traffic-flow-geoip.ts: ридеры maxmind, фасад
  resolveFlowIp (geoip-first → RIPE-кэш), статус ридеров
- geoip-update-collector.ts: conditional GET по ETag, валидация пробоем
  8.8.8.8 (US / AS15169), атомарная подмена с .prev-откатом
- джоба планировщика geoip_update (по умолчанию раз в 7 дней),
  настройки geoip_settings + миграция 0004, API /api/geoip
  (GET/PUT/update), контракты @mmapp/contracts/geoip
- engine/analytics/map-hops переведены на resolveFlowIp
- секция «GeoIP-базы» в настройках NetFlow, метки джобы на странице
  сбора данных, тесты test:geoip
2026-09-09 23:33:31 +07:00
51 changed files with 4664 additions and 28 deletions
+8
View File
@@ -190,6 +190,14 @@ npm run build -w @mmapp/contracts
npm --prefix backend run db:migrate-from-sqlite
```
### GeoIP-базы GeoLite2 (страны и ASN для NetFlow)
Backend держит локальные mmdb-базы MaxMind GeoLite2 (Country + ASN) в `backend/storage/geoip/` и скачивает их с зеркала [P3TERX/GeoLite.mmdb](https://github.com/P3TERX/GeoLite.mmdb) — без регистрации и ключей. Lookup страны/ASN потока при ingest становится мгновенным (включая IPv6) и не упирается в лимиты RIPEstat; пока базы не скачаны или lookup промахнулся, работает прежний RIPE-fallback.
Управление — секция «GeoIP-базы (GeoLite2)» в настройках NetFlow (страница «Сбор данных»): автообновление (по умолчанию проверка раз в 7 дней, upstream обновляется еженедельно), статус сборки баз и кнопка «Обновить сейчас». Джоба планировщика — `geoip_update`. Атрибуция: данные MaxMind GeoLite2, CC BY-SA 4.0.
Примечание для Docker: каталог `storage/geoip` внутри контейнера ephemeral — без смонтированного volume базы (~17 МБ) перекачаются после пересоздания контейнера. Каталог переопределяется переменной `GEOIP_DIR`.
## CI/CD (Gitea Actions)
Файл: `.gitea/workflows/docker.yml` (имя workflow: **Docker images**).
+36
View File
@@ -34,6 +34,7 @@ import {
type InternetPathRunSnapshot,
type CertificatesRenewRunSnapshot,
type BackupsRunSnapshot,
type GeoipUpdateRunSnapshot,
type PingRunSnapshot,
type ResourcesRunSnapshot,
type SchedulerRunSnapshot,
@@ -340,6 +341,41 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</div>
)
}
if (snap.job === "geoip_update") {
const g = snap as GeoipUpdateRunSnapshot
return (
<div className="flex flex-col gap-3">
{g.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: обновление уже выполнялось или задача отключена.</p>
) : null}
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
<div>
<dt className="text-muted-foreground">Баз проверено</dt>
<dd className="font-mono font-medium">{g.checked}</dd>
</div>
<div>
<dt className="text-muted-foreground">Скачано</dt>
<dd className="font-mono font-medium">{g.downloaded}</dd>
</div>
<div>
<dt className="text-muted-foreground">Без изменений</dt>
<dd className="font-mono font-medium">{g.skippedUnchanged}</dd>
</div>
<div>
<dt className="text-muted-foreground">Объём, МБ</dt>
<dd className="font-mono font-medium">{(g.bytes / 1024 / 1024).toFixed(1)}</dd>
</div>
</dl>
{g.errors.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
{g.errors.map((e, i) => (
<p key={i} className="break-words">{e}</p>
))}
</div>
) : null}
</div>
)
}
if (snap.job === "alert_engine") {
const a = snap as AlertEngineRunSnapshot
return (
+427
View File
@@ -0,0 +1,427 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import {
ActivityIcon,
CableIcon,
DatabaseIcon,
GaugeIcon,
GlobeIcon,
ServerIcon,
UsersIcon,
} from "lucide-react"
import { PageHeader } from "@/components/page-header"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { EmptyState } from "@/components/empty-state"
import { PeriodSelector, rangeForPreset, type DateRangeYmd } from "@/components/statistics/period-selector"
import { StatisticsVolumeChart } from "@/components/statistics/statistics-volume-chart"
import {
StatisticsBreakdownDataGrid,
type StatisticsSliceKind,
} from "@/components/data-grids/statistics-breakdown-data-grid"
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
import type { Filter } from "@/components/reui/filters"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { STATISTICS_FILTER_FIELDS } from "@/lib/data-filters/statistics-filter-fields"
import { useDataSource } from "@/lib/data-source"
import { fmtBps, formatBytes } from "@/lib/fmt-rate"
import { getStatistics, type StatisticsDto, type StatisticsQuery } from "@/shared/api/statistics"
/**
* Отчётный куб трафика — KPI + период + график + табы-гриды.
* Preview: https://reui.io/preview/base/dashboard-1 · https://reui.io/preview/base/stats-12
* · https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/chart-23
* · https://reui.io/preview/base/components/c-date-selector-2 · https://reui.io/preview/base/empty-state-12
*/
const TABS: { id: StatisticsSliceKind; label: string }[] = [
{ id: "users", label: "Пользователи" },
{ id: "servers", label: "Серверы" },
{ id: "interfaces", label: "Интерфейсы" },
{ id: "countries", label: "Страны" },
{ id: "services", label: "Сервисы" },
{ id: "asns", label: "ASN" },
]
const EMPTY: StatisticsDto = {
from: "",
to: "",
grain: "day",
kpis: {
bytes: 0,
packets: 0,
avgBps: 0,
users: 0,
servers: 0,
ifaces: 0,
topCountry: "",
topService: "",
},
series: [],
users: [],
servers: [],
interfaces: [],
countries: [],
services: [],
asns: [],
}
interface CubeSlices {
country?: string
service?: string
asn?: string
serverId?: string
userId?: string
iface?: string
}
const SLICE_KEYS = ["country", "service", "asn", "serverId", "userId", "iface"] as const
function readRange(sp: URLSearchParams): DateRangeYmd {
const from = sp.get("from")
const to = sp.get("to")
if (from && to && from <= to) return { from, to }
return rangeForPreset("7d")
}
function readTab(sp: URLSearchParams): StatisticsSliceKind {
const t = sp.get("tab")
return TABS.some((x) => x.id === t) ? (t as StatisticsSliceKind) : "users"
}
function readSlices(sp: URLSearchParams): CubeSlices {
const next: CubeSlices = {}
for (const key of SLICE_KEYS) {
const v = sp.get(key)?.trim()
if (v) next[key] = v
}
return next
}
function slicesToFilters(slices: CubeSlices): Filter[] {
return SLICE_KEYS.flatMap((key) => {
const val = slices[key]
if (!val) return []
return [{ id: key, field: key, operator: "is", values: [val] }]
})
}
function filtersToSlices(filters: Filter[]): CubeSlices {
const next: CubeSlices = {}
for (const f of filters) {
const raw = String(f.values[0] ?? "").trim()
if (!raw) continue
if (f.field === "country") next.country = raw.toUpperCase().slice(0, 2)
else if (f.field === "service") next.service = raw
else if (f.field === "asn") next.asn = raw.replace(/[^\d]/g, "")
else if (f.field === "serverId") next.serverId = raw
else if (f.field === "userId") next.userId = raw
else if (f.field === "iface") next.iface = raw
}
return next
}
function toQuery(range: DateRangeYmd, slices: CubeSlices): StatisticsQuery {
const serverId = slices.serverId ? Number(slices.serverId) : undefined
const asn = slices.asn != null && slices.asn !== "" ? Number(slices.asn) : undefined
return {
from: range.from,
to: range.to,
serverId: Number.isFinite(serverId) && (serverId ?? 0) > 0 ? serverId : undefined,
userId: slices.userId,
iface: slices.iface,
country: slices.country && slices.country.length === 2 ? slices.country : undefined,
service: slices.service,
asn: Number.isFinite(asn) ? asn : undefined,
}
}
function selectedIdForTab(tab: StatisticsSliceKind, slices: CubeSlices): string | undefined {
if (tab === "users") return slices.userId
if (tab === "servers") return slices.serverId
if (tab === "countries") return slices.country
if (tab === "services") return slices.service
if (tab === "asns") return slices.asn
if (tab === "interfaces" && slices.serverId && slices.iface) {
return `${slices.serverId}:${slices.iface}`
}
if (tab === "interfaces") return slices.iface
return undefined
}
function rowsForTab(data: StatisticsDto, tab: StatisticsSliceKind) {
if (tab === "users") return data.users
if (tab === "servers") return data.servers
if (tab === "interfaces") return data.interfaces
if (tab === "countries") return data.countries
if (tab === "services") return data.services
return data.asns
}
export default function StatisticsPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = mode === "live"
const range = useMemo(() => readRange(searchParams), [searchParams])
const slices = useMemo(() => readSlices(searchParams), [searchParams])
const filters = useMemo(() => slicesToFilters(slices), [slices])
const [tab, setTab] = useState<StatisticsSliceKind>(() => readTab(searchParams))
const [data, setData] = useState<StatisticsDto>(EMPTY)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const replaceParams = useCallback(
(patch: Record<string, string | undefined>) => {
const sp = new URLSearchParams(searchParams.toString())
for (const [k, v] of Object.entries(patch)) {
if (v) sp.set(k, v)
else sp.delete(k)
}
const qs = sp.toString()
router.replace(qs ? `/statistics?${qs}` : "/statistics")
},
[router, searchParams],
)
const setRange = useCallback(
(next: DateRangeYmd) => {
replaceParams({ from: next.from, to: next.to })
},
[replaceParams],
)
const setSlices = useCallback(
(next: CubeSlices) => {
replaceParams({
country: next.country,
service: next.service,
asn: next.asn,
serverId: next.serverId,
userId: next.userId,
iface: next.iface,
})
},
[replaceParams],
)
useEffect(() => {
if (!prefsHydrated || !isLive) return
let cancelled = false
void (async () => {
setLoading(true)
setError(null)
try {
const dto = await getStatistics(backendUrl, toQuery(range, slices))
if (!cancelled) setData(dto)
} catch (e: unknown) {
if (!cancelled) {
setData(EMPTY)
setError(e instanceof Error ? e.message : "Не удалось загрузить статистику")
}
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [backendUrl, isLive, prefsHydrated, range, slices])
const rows = rowsForTab(isLive ? data : EMPTY, tab)
const selectedId = selectedIdForTab(tab, slices)
const view = isLive ? data : EMPTY
function handleRowClick(kind: StatisticsSliceKind, row: { id: string }) {
const next: CubeSlices = { ...slices }
if (kind === "users") {
if (next.userId === row.id) delete next.userId
else next.userId = row.id
} else if (kind === "servers") {
if (next.serverId === row.id) delete next.serverId
else next.serverId = row.id
} else if (kind === "countries") {
if (next.country === row.id) delete next.country
else next.country = row.id
} else if (kind === "services") {
if (next.service === row.id) delete next.service
else next.service = row.id
} else if (kind === "asns") {
if (next.asn === row.id) delete next.asn
else next.asn = row.id
} else {
const colon = row.id.indexOf(":")
const sid = colon >= 0 ? row.id.slice(0, colon) : undefined
const iface = colon >= 0 ? row.id.slice(colon + 1) : row.id
if (next.iface === iface && next.serverId === sid) {
delete next.iface
delete next.serverId
} else {
next.iface = iface
if (sid) next.serverId = sid
}
}
setSlices(next)
}
const kpis = view.kpis
const emptyCube = !isLive || (!loading && kpis.bytes === 0)
return (
<div className="flex h-full flex-col">
<PageHeader
crumbs={[{ label: "Обзор", href: "/dashboard" }, { label: "Статистика" }]}
actions={<PeriodSelector range={range} onChange={setRange} />}
/>
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4 md:gap-6 md:px-6 md:py-5">
{!isLive ? (
<Alert>
<AlertTitle>Живые данные выключены</AlertTitle>
<AlertDescription>
Куб статистики строится из IPFIX. Переключитесь на живой источник, чтобы увидеть отчёт.
</AlertDescription>
</Alert>
) : null}
{error ? (
<Alert variant="destructive">
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<KpiStatGrid
aria-label="Сводка трафика"
isLoading={loading}
skeletonCount={5}
items={[
{
id: "bytes",
label: "Объём",
value: formatBytes(kpis.bytes),
hint: kpis.topCountry ? `топ: ${kpis.topCountry}` : undefined,
icon: <DatabaseIcon />,
iconClassName: "text-muted-foreground",
},
{
id: "packets",
label: "Пакеты",
value: kpis.packets.toLocaleString("ru-RU"),
hint: kpis.topService ? `топ: ${kpis.topService}` : undefined,
icon: <ActivityIcon />,
iconClassName: "text-muted-foreground",
},
{
id: "bps",
label: "Средний bitrate",
value: fmtBps(kpis.avgBps),
icon: <GaugeIcon />,
iconClassName: "text-muted-foreground",
},
{
id: "users",
label: "Пользователи",
value: String(kpis.users),
icon: <UsersIcon />,
iconClassName: "text-muted-foreground",
},
{
id: "servers",
label: "Серверы",
value: String(kpis.servers),
hint: kpis.ifaces ? `${kpis.ifaces} iface` : undefined,
icon: <ServerIcon />,
iconClassName: "text-muted-foreground",
},
]}
/>
<StatisticsVolumeChart series={view.series} grain={view.grain} />
<DataPageCard>
<DataPageToolbar
filters={filters}
onFiltersChange={(next) => setSlices(filtersToSlices(next))}
filterFields={STATISTICS_FILTER_FIELDS}
countLabel={`${rows.length} строк`}
/>
{SLICE_KEYS.some((k) => slices[k]) ? (
<div className="flex flex-wrap items-center gap-1.5 border-b px-5 py-2">
{slices.country ? (
<Badge variant="outline" size="sm">страна {slices.country}</Badge>
) : null}
{slices.service ? (
<Badge variant="outline" size="sm">сервис {slices.service}</Badge>
) : null}
{slices.asn ? (
<Badge variant="outline" size="sm">ASN {slices.asn}</Badge>
) : null}
{slices.serverId ? (
<Badge variant="outline" size="sm">сервер {slices.serverId}</Badge>
) : null}
{slices.userId ? (
<Badge variant="outline" size="sm">пользователь {slices.userId}</Badge>
) : null}
{slices.iface ? (
<Badge variant="outline" size="sm">iface {slices.iface}</Badge>
) : null}
</div>
) : null}
<Tabs
value={tab}
onValueChange={(v) => {
const next = String(v) as StatisticsSliceKind
setTab(next)
replaceParams({ tab: next })
}}
className="gap-0"
>
<div className="px-5 pt-2">
<TabsList variant="line" className="w-fit">
<TabsTrigger value="users">
<UsersIcon /> Пользователи
</TabsTrigger>
<TabsTrigger value="servers">
<ServerIcon /> Серверы
</TabsTrigger>
<TabsTrigger value="interfaces">
<CableIcon /> Интерфейсы
</TabsTrigger>
<TabsTrigger value="countries">
<GlobeIcon /> Страны
</TabsTrigger>
<TabsTrigger value="services">Сервисы</TabsTrigger>
<TabsTrigger value="asns">ASN</TabsTrigger>
</TabsList>
</div>
{TABS.map((t) => (
<TabsContent key={t.id} value={t.id}>
{emptyCube ? (
<EmptyState
title="Нет данных куба"
description="За выбранный период нет IPFIX-фактов. Куб заполняется с момента деплоя, без бэкфилла за год."
/>
) : (
<StatisticsBreakdownDataGrid
rows={rowsForTab(view, t.id)}
kind={t.id}
selectedId={t.id === tab ? selectedId : undefined}
onRowClick={(row) => handleRowClick(t.id, row)}
isLoading={loading}
/>
)}
</TabsContent>
))}
</Tabs>
</DataPageCard>
</div>
</div>
)
}
+1
View File
@@ -5,3 +5,4 @@ dist/
*.db-wal
.env
storage/backups/
storage/geoip/
+19
View File
@@ -0,0 +1,19 @@
-- GeoLite2 mmdb (страна/ASN для netflow): настройки автообновления зеркала P3TERX
CREATE TABLE IF NOT EXISTS geoip_settings (
id BIGINT PRIMARY KEY CHECK (id = 1),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
update_interval_sec INTEGER NOT NULL DEFAULT 604800,
last_check_at TIMESTAMPTZ,
last_success_at TIMESTAMPTZ,
last_error TEXT,
country_build_at TIMESTAMPTZ,
asn_build_at TIMESTAMPTZ,
etags_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO geoip_settings (id)
VALUES (1)
ON CONFLICT (id) DO NOTHING;
+39
View File
@@ -0,0 +1,39 @@
-- Statistics cube: hour + daily facts (server × iface × country × service × ASN).
-- Compact types, fillfactor for HOT upserts, autovacuum tuned for ON CONFLICT.
-- Retention: DROP partitions only (see PARTITION_SPECS).
CREATE TABLE IF NOT EXISTS flow_hour_facts (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
bucket_at TIMESTAMPTZ NOT NULL,
iface TEXT NOT NULL,
country CHAR(2) NOT NULL,
service TEXT NOT NULL,
asn INTEGER NOT NULL,
bytes BIGINT NOT NULL DEFAULT 0,
packets BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, bucket_at, iface, country, service, asn)
) PARTITION BY RANGE (bucket_at);
ALTER TABLE flow_hour_facts SET (
fillfactor = 70,
autovacuum_vacuum_scale_factor = 0.05,
autovacuum_vacuum_cost_limit = 2000
);
CREATE TABLE IF NOT EXISTS flow_daily_facts (
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
day DATE NOT NULL,
iface TEXT NOT NULL,
country CHAR(2) NOT NULL,
service TEXT NOT NULL,
asn INTEGER NOT NULL,
bytes BIGINT NOT NULL DEFAULT 0,
packets BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, day, iface, country, service, asn)
) PARTITION BY RANGE (day);
ALTER TABLE flow_daily_facts SET (
fillfactor = 70,
autovacuum_vacuum_scale_factor = 0.05,
autovacuum_vacuum_cost_limit = 2000
);
+4 -2
View File
@@ -15,11 +15,12 @@
"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-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",
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-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/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": "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:geoip": "tsx src/services/traffic-flow-geoip.test.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.888.0",
@@ -33,6 +34,7 @@
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.5",
"fastify-plugin": "^5.1.0",
"maxmind": "^5.0.7",
"pg": "^8.23.0",
"undici": "^8.1.0",
"zod": "^4.4.1"
+2
View File
@@ -13,6 +13,8 @@ export const PARTITION_SPECS: PartitionSpec[] = [
{ parent: "flow_minute_stats", kind: "day", keepDays: 4 },
{ parent: "flow_minute_dims", kind: "day", keepDays: 4 },
{ parent: "flow_daily_dims", kind: "month", keepDays: 420 },
{ parent: "flow_hour_facts", kind: "day", keepDays: 3 },
{ parent: "flow_daily_facts", kind: "month", keepDays: 420 },
{ parent: "traffic_samples", kind: "week", keepDays: 21 },
{ parent: "servers_rest_ping_samples", kind: "week", keepDays: 35 },
{ parent: "uptime_probe_samples", kind: "week", keepDays: 21 },
+44
View File
@@ -208,6 +208,36 @@ export const flowDailyDims = pgTable("flow_daily_dims", {
index("idx_flow_daily_dims_day").on(t.day, t.dim),
])
/** Hour-grain traffic cube for statistics (≤48h). No secondary indexes. */
export const flowHourFacts = pgTable("flow_hour_facts", {
serverId: bigint("server_id", { mode: "number" }).notNull()
.references(() => servers.id, { onDelete: "cascade" }),
bucketAt: ts("bucket_at").notNull(),
iface: text("iface").notNull(),
country: text("country").notNull(),
service: text("service").notNull(),
asn: integer("asn").notNull(),
bytes: bigint("bytes", { mode: "number" }).notNull().default(0),
packets: bigint("packets", { mode: "number" }).notNull().default(0),
}, (t) => [
primaryKey({ columns: [t.serverId, t.bucketAt, t.iface, t.country, t.service, t.asn] }),
])
/** Daily-grain traffic cube for statistics (long window). No secondary indexes. */
export const flowDailyFacts = pgTable("flow_daily_facts", {
serverId: bigint("server_id", { mode: "number" }).notNull()
.references(() => servers.id, { onDelete: "cascade" }),
day: date("day", { mode: "string" }).notNull(),
iface: text("iface").notNull(),
country: text("country").notNull(),
service: text("service").notNull(),
asn: integer("asn").notNull(),
bytes: bigint("bytes", { mode: "number" }).notNull().default(0),
packets: bigint("packets", { mode: "number" }).notNull().default(0),
}, (t) => [
primaryKey({ columns: [t.serverId, t.day, t.iface, t.country, t.service, t.asn] }),
])
export const flowBuckets = pgTable("flow_buckets", {
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
bucketAt: ts("bucket_at").notNull(),
@@ -231,6 +261,20 @@ export const flowBuckets = pgTable("flow_buckets", {
index("idx_flow_buckets_server_time").on(t.serverId, t.bucketAt),
])
export const geoipSettings = pgTable("geoip_settings", {
id: idSingleton(),
enabled: boolean("enabled").notNull().default(true),
updateIntervalSec: integer("update_interval_sec").notNull().default(604800),
lastCheckAt: ts("last_check_at"),
lastSuccessAt: ts("last_success_at"),
lastError: text("last_error"),
countryBuildAt: ts("country_build_at"),
asnBuildAt: ts("asn_build_at"),
etagsJson: jsonb("etags_json").notNull().default(sql`'{}'::jsonb`),
createdAt: ts("created_at").notNull().defaultNow(),
updatedAt: ts("updated_at").notNull().defaultNow(),
})
export const flowIpMeta = pgTable("flow_ip_meta", {
prefix: text("prefix").primaryKey(),
asn: integer("asn").notNull().default(0),
+6
View File
@@ -14,6 +14,7 @@ import filtersRoutes from "./routes/filters.js"
import recursiveRoutes from "./routes/recursive-routes.js"
import trafficRoutes from "./routes/traffic.js"
import trafficFlowRoutes from "./routes/traffic-flow.js"
import geoipRoutes from "./routes/geoip.js"
import serversApiPingRoutes from "./routes/servers-api-ping.js"
import uptimeRoutes from "./routes/uptime.js"
import networkRoutes from "./routes/network.js"
@@ -30,8 +31,10 @@ import eventsRoutes from "./routes/events.js"
import wireguardRoutes from "./routes/wireguard.js"
import firewallRoutes from "./routes/firewall.js"
import usersRoutes from "./routes/users.js"
import statisticsRoutes from "./routes/statistics.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
import { getFlowWorkerHealth, startTrafficFlowListener, stopTrafficFlowListener } from "./services/traffic-flow-ingest.js"
import { initGeoip } from "./services/traffic-flow-geoip.js"
const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 })
eventLoopDelay.enable()
@@ -116,6 +119,7 @@ export async function buildApp(opts?: {
await app.register(recursiveRoutes, { prefix: "/api" })
await app.register(trafficRoutes, { prefix: "/api" })
await app.register(trafficFlowRoutes, { prefix: "/api" })
await app.register(geoipRoutes, { prefix: "/api" })
await app.register(serversApiPingRoutes, { prefix: "/api" })
await app.register(uptimeRoutes, { prefix: "/api" })
await app.register(networkRoutes, { prefix: "/api" })
@@ -132,9 +136,11 @@ export async function buildApp(opts?: {
await app.register(wireguardRoutes, { prefix: "/api" })
await app.register(firewallRoutes, { prefix: "/api" })
await app.register(usersRoutes, { prefix: "/api" })
await app.register(statisticsRoutes, { prefix: "/api" })
if (opts?.startScheduler !== false) {
await refreshScheduler()
await initGeoip()
await startTrafficFlowListener()
app.addHook("onClose", async () => {
stopScheduler()
+1 -1
View File
@@ -18,7 +18,7 @@ assert.equal(
"mm:settings:admin",
)
assert.equal(
permissionForRequest("GET", "/api/traffic/servers/1/live"),
permissionForRequest("GET", "/api/statistics"),
"mm:traffic:read",
)
assert.equal(
+1 -1
View File
@@ -107,7 +107,7 @@ const RULES: Rule[] = [
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/traffic"),
match: (p) => p.startsWith("/api/traffic") || p.startsWith("/api/statistics"),
permission: "mm:traffic:read",
},
{
+57
View File
@@ -0,0 +1,57 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { geoipSettingsPatchSchema } from "@mmapp/contracts/geoip"
import { refreshScheduler } from "../services/scheduler.js"
import { getGeoipSettings, updateGeoipSettings } from "../services/geoip-settings.js"
import {
GEOIP_ASN_FILE,
GEOIP_COUNTRY_FILE,
geoipReadersStatus,
initGeoip,
} from "../services/traffic-flow-geoip.js"
import { collectGeoipUpdateOnce, getGeoipUpdateState } from "../services/geoip-update-collector.js"
async function buildGeoipStatus() {
await initGeoip()
const readers = geoipReadersStatus()
return {
ready: readers.countryLoaded && readers.asnLoaded,
countryLoaded: readers.countryLoaded,
asnLoaded: readers.asnLoaded,
countryFile: GEOIP_COUNTRY_FILE,
asnFile: GEOIP_ASN_FILE,
dir: readers.dir,
running: getGeoipUpdateState().running,
settings: await getGeoipSettings(),
}
}
const geoipRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/geoip", async (_req, reply) => {
return reply.send(await buildGeoipStatus())
})
app.put("/geoip", async (req, reply) => {
const parsed = geoipSettingsPatchSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply
.status(400)
.send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
await updateGeoipSettings(parsed.data)
await refreshScheduler()
return reply.send({ ok: true, status: await buildGeoipStatus() })
})
app.post("/geoip/update", async (_req, reply) => {
try {
const snapshot = await collectGeoipUpdateOnce({ force: true })
return reply.send({ ok: !snapshot.fatalError && snapshot.errors.length === 0, snapshot })
} catch (e) {
const status = (e as { statusCode?: number }).statusCode ?? 502
const msg = e instanceof Error ? e.message : String(e)
return reply.status(status).send({ error: msg })
}
})
}
export default geoipRoutes
+15
View File
@@ -0,0 +1,15 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { statisticsQuerySchema } from "@mmapp/contracts/statistics"
import { getStatistics } from "../services/statistics-aggregate.js"
const statisticsRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/statistics", async (req, reply) => {
const parsed = statisticsQuerySchema.safeParse(req.query ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректный период или фильтры", details: parsed.error.flatten() })
}
return reply.send(await getStatistics(parsed.data))
})
}
export default statisticsRoutes
+100
View File
@@ -0,0 +1,100 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { geoipSettings } from "../db/schema.js"
import type { GeoipSettingsDto, GeoipSettingsPatch } from "@mmapp/contracts/geoip"
const SETTINGS_ID = 1
const DEFAULT_INTERVAL_SEC = 604800
let dbEnabled = true
/** Тесты без PostgreSQL: геттеры отдают дефолты, touch/update — no-op. */
export function disableGeoipDbForTests(): void {
dbEnabled = false
}
export function resetGeoipSettingsForTests(): void {
dbEnabled = true
}
type GeoipSettingsRow = typeof geoipSettings.$inferSelect
async function getGeoipSettingsRow(): Promise<GeoipSettingsRow | undefined> {
if (!dbEnabled) return undefined
return (
(await db.select().from(geoipSettings).where(eq(geoipSettings.id, SETTINGS_ID)).limit(1))[0]
)
}
function toDto(row: GeoipSettingsRow | undefined): GeoipSettingsDto {
return {
enabled: row?.enabled ?? true,
updateIntervalSec: row?.updateIntervalSec ?? DEFAULT_INTERVAL_SEC,
lastCheckAt: row?.lastCheckAt ?? null,
lastSuccessAt: row?.lastSuccessAt ?? null,
lastError: row?.lastError ?? null,
countryBuildAt: row?.countryBuildAt ?? null,
asnBuildAt: row?.asnBuildAt ?? null,
updatedAt: row?.updatedAt ?? new Date().toISOString(),
}
}
export async function getGeoipSettings(): Promise<GeoipSettingsDto> {
return toDto(await getGeoipSettingsRow())
}
/** ETag'и зеркала для conditional GET (ключ — имя файла базы). */
export async function getGeoipEtags(): Promise<Record<string, string>> {
const row = await getGeoipSettingsRow()
if (!row) return {}
const raw = row?.etagsJson
if (!raw || typeof raw !== "object") return {}
return Object.fromEntries(
Object.entries(raw as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
)
}
export async function updateGeoipSettings(patch: GeoipSettingsPatch): Promise<GeoipSettingsDto> {
const prev = await getGeoipSettingsRow()
const next = {
enabled: patch.enabled ?? prev?.enabled ?? true,
updateIntervalSec: patch.updateIntervalSec ?? prev?.updateIntervalSec ?? DEFAULT_INTERVAL_SEC,
updatedAt: new Date().toISOString(),
}
if (prev) {
await db.update(geoipSettings).set(next).where(eq(geoipSettings.id, SETTINGS_ID))
} else {
await db.insert(geoipSettings).values({ id: SETTINGS_ID, ...next })
}
return getGeoipSettings()
}
export async function touchGeoipRunMeta(patch: {
lastCheckAt?: string
lastSuccessAt?: string | null
lastError?: string | null
countryBuildAt?: string | null
asnBuildAt?: string | null
etags?: Record<string, string>
}): Promise<void> {
const prev = await getGeoipSettingsRow()
const set: Partial<typeof geoipSettings.$inferInsert> = {
updatedAt: new Date().toISOString(),
}
if (patch.lastCheckAt !== undefined) set.lastCheckAt = patch.lastCheckAt
if (patch.lastSuccessAt !== undefined) set.lastSuccessAt = patch.lastSuccessAt
if (patch.lastError !== undefined) set.lastError = patch.lastError
if (patch.countryBuildAt !== undefined) set.countryBuildAt = patch.countryBuildAt
if (patch.asnBuildAt !== undefined) set.asnBuildAt = patch.asnBuildAt
if (patch.etags !== undefined) {
const prevEtags = (prev?.etagsJson as Record<string, string> | null) ?? {}
set.etagsJson = { ...prevEtags, ...patch.etags }
}
if (prev) {
await db.update(geoipSettings).set(set).where(eq(geoipSettings.id, SETTINGS_ID))
} else {
await db.insert(geoipSettings).values({ id: SETTINGS_ID, ...set })
}
}
@@ -0,0 +1,198 @@
import { rename, rm, mkdir, writeFile } from "node:fs/promises"
import path from "node:path"
import { open, type AsnResponse, type CountryResponse } from "maxmind"
import type { GeoipUpdateRunSnapshot } from "../types/scheduler-run-snapshot.js"
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
import { getGeoipEtags, getGeoipSettings, touchGeoipRunMeta } from "./geoip-settings.js"
import {
GEOIP_ASN_FILE,
GEOIP_COUNTRY_FILE,
geoipDir,
reloadGeoipReaders,
} from "./traffic-flow-geoip.js"
/** Зеркало GeoLite2 без регистрации и ключей (см. README: GeoIP). */
const MIRROR_BASE = "https://github.com/P3TERX/GeoLite.mmdb/raw/download"
const DOWNLOAD_TIMEOUT_MS = 120_000
/** Пробный IP для валидации скачанной базы: Google DNS. */
const PROBE_IP = "8.8.8.8"
type GeoipDbKind = "country" | "asn"
let updating = false
let fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis)
export function getGeoipUpdateState(): { running: boolean } {
return { running: updating }
}
async function validateCountryFile(filePath: string): Promise<string> {
const reader = await open<CountryResponse>(filePath)
const rec = reader.get(PROBE_IP)
const iso = rec?.country?.iso_code ?? rec?.registered_country?.iso_code ?? ""
if (iso !== "US") {
throw new Error(`база Country не распознала ${PROBE_IP} как US (${iso || "нет записи"})`)
}
return reader.metadata.buildEpoch.toISOString()
}
async function validateAsnFile(filePath: string): Promise<string> {
const reader = await open<AsnResponse>(filePath)
const rec = reader.get(PROBE_IP)
const asn = rec?.autonomous_system_number ?? 0
if (asn !== 15169) {
throw new Error(`база ASN не распознала ${PROBE_IP} как AS15169 (${asn ? `AS${asn}` : "нет записи"})`)
}
return reader.metadata.buildEpoch.toISOString()
}
let validateCountry = validateCountryFile
let validateAsn = validateAsnFile
export function setGeoipFetchForTests(fn: typeof fetch): void {
fetchImpl = fn
}
export function setGeoipValidateForTests(opts: {
country?: (filePath: string) => Promise<string>
asn?: (filePath: string) => Promise<string>
}): void {
validateCountry = opts.country ?? validateCountryFile
validateAsn = opts.asn ?? validateAsnFile
}
export function resetGeoipUpdateForTests(): void {
updating = false
fetchImpl = globalThis.fetch.bind(globalThis)
validateCountry = validateCountryFile
validateAsn = validateAsnFile
}
/**
* Разовая проверка/доставка баз с зеркала P3TERX. Conditional GET по ETag
* (304 = не меняем файл), валидация пробоем 8.8.8.8, атомарная подмена через rename.
*/
export async function collectGeoipUpdateOnce(
opts: { force?: boolean } = {},
): Promise<GeoipUpdateRunSnapshot> {
const sampledAt = new Date().toISOString()
if (updating) {
if (opts.force) {
throw Object.assign(new Error("Обновление GeoIP уже выполняется"), { statusCode: 409 })
}
return emptySnapshot(sampledAt, true)
}
const settings = await getGeoipSettings()
if (!settings.enabled && !opts.force) {
return emptySnapshot(sampledAt, true)
}
updating = true
const snapshot: GeoipUpdateRunSnapshot = {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "geoip_update",
sampledAt,
checked: 0,
downloaded: 0,
skippedUnchanged: 0,
bytes: 0,
errors: [],
}
try {
const dir = geoipDir()
await mkdir(dir, { recursive: true })
const storedEtags = await getGeoipEtags()
const etags: Record<string, string> = {}
const buildAt: Partial<Record<GeoipDbKind, string>> = {}
for (const kind of ["country", "asn"] as const) {
snapshot.checked += 1
const file = kind === "country" ? GEOIP_COUNTRY_FILE : GEOIP_ASN_FILE
const target = path.join(dir, file)
const tmp = `${target}.tmp`
const prevEtag = storedEtags[file]
try {
const ac = new AbortController()
const timer = setTimeout(() => ac.abort(), DOWNLOAD_TIMEOUT_MS)
let res: Response
try {
res = await fetchImpl(`${MIRROR_BASE}/${file}`, {
headers: prevEtag ? { "If-None-Match": prevEtag } : {},
signal: ac.signal,
})
} finally {
clearTimeout(timer)
}
if (res.status === 304) {
snapshot.skippedUnchanged += 1
if (prevEtag) etags[file] = prevEtag
continue
}
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const etag = res.headers.get("etag") ?? ""
const body = Buffer.from(await res.arrayBuffer())
snapshot.bytes += body.byteLength
await writeFile(tmp, body)
buildAt[kind] =
kind === "country" ? await validateCountry(tmp) : await validateAsn(tmp)
const prevFile = `${target}.prev`
await rm(prevFile, { force: true })
await rename(target, prevFile).catch(() => {
/* текущего файла могло ещё не быть */
})
await rename(tmp, target)
snapshot.downloaded += 1
if (etag) etags[file] = etag
} catch (e) {
await rm(tmp, { force: true }).catch(() => {
/* best-effort */
})
const message = e instanceof Error ? e.message : String(e)
snapshot.errors.push(`${file}: ${message}`)
}
}
if (snapshot.downloaded > 0) {
await reloadGeoipReaders()
}
await touchGeoipRunMeta({
lastCheckAt: sampledAt,
lastSuccessAt: snapshot.errors.length ? null : sampledAt,
lastError: snapshot.errors.length ? snapshot.errors.join("; ") : null,
countryBuildAt: buildAt.country,
asnBuildAt: buildAt.asn,
etags,
})
return snapshot
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
snapshot.fatalError = message
await touchGeoipRunMeta({
lastCheckAt: sampledAt,
lastError: message,
}).catch(() => {
/* best-effort */
})
return snapshot
} finally {
updating = false
}
}
function emptySnapshot(sampledAt: string, skipped: boolean): GeoipUpdateRunSnapshot {
return {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "geoip_update",
sampledAt,
skipped,
checked: 0,
downloaded: 0,
skippedUnchanged: 0,
bytes: 0,
errors: [],
}
}
+20
View File
@@ -46,6 +46,8 @@ import { collectCertificatesRenewOnce } from "./certificate-renew-collector.js"
import { getCertificateRenewSettings } from "./certificates-service.js"
import { collectScheduledBackupsOnce } from "./backup-scheduler-collector.js"
import { getBackupScheduleSettings } from "./backup-service.js"
import { collectGeoipUpdateOnce } from "./geoip-update-collector.js"
import { getGeoipSettings } from "./geoip-settings.js"
import {
endSchedulerJob,
isSchedulerJobRunning,
@@ -63,6 +65,7 @@ export const JOB_KEYS = [
"gre_bgp",
"certificates_renew",
"backups",
"geoip_update",
"alert_engine",
] as const
export type SchedulerJobKey = (typeof JOB_KEYS)[number]
@@ -148,6 +151,9 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
case "backups":
snapshot = await collectScheduledBackupsOnce()
break
case "geoip_update":
snapshot = await collectGeoipUpdateOnce()
break
case "alert_engine": {
const r = await runAlertEngineOnce()
snapshot = {
@@ -377,6 +383,18 @@ export async function refreshScheduler(): Promise<void> {
)
}
const geoip = await getGeoipSettings()
if (geoip.enabled) {
const geoipMs = Math.max(6 * 3600_000, geoip.updateIntervalSec * 1000)
void executeSchedulerJob("geoip_update").catch(() => {})
timers.set(
"geoip_update",
setInterval(() => {
void executeSchedulerJob("geoip_update").catch(() => {})
}, geoipMs),
)
}
const alertMs = 20_000
void executeSchedulerJob("alert_engine").catch(() => {})
timers.set(
@@ -409,6 +427,7 @@ export async function getSchedulerStatus() {
const internetPath = await getInternetPathSettings()
const certRenew = await getCertificateRenewSettings()
const backupSchedule = await getBackupScheduleSettings()
const geoip = await getGeoipSettings()
const resOn = uptime.resourcesEnabled ?? uptime.enabled
const pingOn = uptime.pingEnabled ?? uptime.enabled
@@ -424,6 +443,7 @@ export async function getSchedulerStatus() {
gre_bgp: { enabled: true, intervalSec: 30 },
certificates_renew: { enabled: certRenew.enabled, intervalSec: certRenew.intervalSec },
backups: { enabled: backupSchedule.enabled, intervalSec: 60 },
geoip_update: { enabled: geoip.enabled, intervalSec: geoip.updateIntervalSec },
alert_engine: { enabled: true, intervalSec: 20 },
}
@@ -0,0 +1,92 @@
import assert from "node:assert/strict"
import { getStatistics, parseStatisticsPeriod } from "./statistics-aggregate.js"
import { withPgOrSkip } from "../test/pg.js"
import { dbQuery } from "../db/index.js"
import { ensurePartitionFor } from "../db/partitions.js"
import { pool } from "../db/index.js"
{
const sameDay = parseStatisticsPeriod("2026-09-10", "2026-09-10")
assert.ok(sameDay)
assert.equal(sameDay.fromDay, "2026-09-10")
assert.equal(sameDay.toDayExclusive, "2026-09-11")
assert.equal(sameDay.grain, "hour")
const month = parseStatisticsPeriod("2026-08-01", "2026-08-31")
assert.ok(month)
assert.equal(month.grain, "day")
assert.equal(month.toDayExclusive, "2026-09-01")
assert.equal(parseStatisticsPeriod("2026-09-10", "2026-09-09"), null)
}
if (!(await withPgOrSkip())) {
console.log("statistics-aggregate.test.ts: skip")
process.exit(0)
}
const inserted = await dbQuery<{ id: number }>(`
INSERT INTO servers (name, host) VALUES ('stats-cube', '127.0.0.1') RETURNING id
`)
const serverId = inserted.rows[0]?.id
if (serverId == null) throw new Error("no server")
await ensurePartitionFor(pool, "flow_daily_facts", "month", new Date("2026-09-01T00:00:00Z"))
await ensurePartitionFor(pool, "flow_hour_facts", "day", new Date("2026-09-10T00:00:00Z"))
await dbQuery(`DELETE FROM flow_daily_facts WHERE server_id = $1`, [serverId])
await dbQuery(`DELETE FROM flow_hour_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-stats-1'`)
await dbQuery(`
INSERT INTO app_users (id, name, login, role, active)
VALUES ('u-stats-1', 'Клиент', 'stats-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-stats-1', 'u-stats-1', $1, 'ether1', 'ether')
`, [serverId])
await dbQuery(`
INSERT INTO flow_daily_facts (server_id, day, iface, country, service, asn, bytes, packets)
VALUES
($1, '2026-09-10', 'ether1', 'US', 'https', 15169, 800, 10),
($1, '2026-09-10', 'ether1', 'DE', 'dns', 15133, 200, 4)
`, [serverId])
try {
const all = await getStatistics({ from: "2026-09-01", to: "2026-09-30" })
assert.equal(all.grain, "day")
assert.equal(all.kpis.bytes, 1000)
assert.ok(all.countries.some((r) => r.id === "US"))
assert.ok(all.users.some((r) => r.id === "u-stats-1"))
assert.ok(all.servers.some((r) => r.id === String(serverId)))
const sliced = await getStatistics({
from: "2026-09-01",
to: "2026-09-30",
country: "US",
service: "https",
asn: 15169,
})
assert.equal(sliced.kpis.bytes, 800)
assert.equal(sliced.countries.length, 1)
assert.equal(sliced.countries[0]?.id, "US")
assert.ok(sliced.users.some((r) => r.id === "u-stats-1"))
await dbQuery(`
INSERT INTO flow_hour_facts (server_id, bucket_at, iface, country, service, asn, bytes, packets)
VALUES ($1, '2026-09-10T10:00:00Z', 'ether1', 'US', 'https', 15169, 40, 2)
`, [serverId])
const hourly = await getStatistics({
from: "2026-09-10T00:00:00.000Z",
to: "2026-09-10T23:00:00.000Z",
})
assert.equal(hourly.grain, "hour")
assert.equal(hourly.kpis.bytes, 40)
} finally {
await dbQuery(`DELETE FROM flow_daily_facts WHERE server_id = $1`, [serverId])
await dbQuery(`DELETE FROM flow_hour_facts WHERE server_id = $1`, [serverId])
await dbQuery(`DELETE FROM servers WHERE id = $1`, [serverId])
}
console.log("statistics-aggregate.test.ts: ok")
@@ -0,0 +1,369 @@
import { eq } from "drizzle-orm"
import { db, dbAll } from "../db/index.js"
import { appUsers, flowAsnMeta, servers, userInterfaceBindings } from "../db/schema.js"
import type {
StatisticsBreakdownRow,
StatisticsDto,
StatisticsQuery,
} from "@mmapp/contracts/statistics"
const TOP_N = 200
const HOUR_WINDOW_MS = 48 * 3600_000
export interface ParsedPeriod {
fromIso: string
toIso: string
fromDay: string
toDayExclusive: string
grain: "hour" | "day"
windowSec: number
}
function pad2(n: number): string {
return String(n).padStart(2, "0")
}
function toUtcDay(d: Date): string {
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`
}
function addUtcDays(day: string, n: number): string {
const d = new Date(`${day}T00:00:00Z`)
d.setUTCDate(d.getUTCDate() + n)
return toUtcDay(d)
}
/** Parse from/to. Date-only `to` is inclusive (end of that UTC day). */
export function parseStatisticsPeriod(fromRaw: string, toRaw: string): ParsedPeriod | null {
const from = Date.parse(fromRaw.includes("T") ? fromRaw : `${fromRaw}T00:00:00Z`)
const toHasTime = toRaw.includes("T")
const to = Date.parse(toHasTime ? toRaw : `${toRaw}T00:00:00Z`)
if (!Number.isFinite(from) || !Number.isFinite(to)) return null
const fromDate = new Date(from)
let toDate = new Date(to)
let toDayExclusive: string
if (toHasTime) {
toDayExclusive = toUtcDay(toDate)
if (toDate.getUTCHours() !== 0 || toDate.getUTCMinutes() !== 0 || toDate.getUTCSeconds() !== 0) {
toDayExclusive = addUtcDays(toDayExclusive, 1)
}
} else {
toDayExclusive = addUtcDays(toUtcDay(toDate), 1)
toDate = new Date(`${toDayExclusive}T00:00:00Z`)
}
if (toDate.getTime() <= from) return null
const windowSec = Math.max(1, Math.round((toDate.getTime() - from) / 1000))
const grain: "hour" | "day" = toDate.getTime() - from <= HOUR_WINDOW_MS ? "hour" : "day"
return {
fromIso: fromDate.toISOString(),
toIso: toDate.toISOString(),
fromDay: toUtcDay(fromDate),
toDayExclusive,
grain,
windowSec,
}
}
interface FilterCtx {
fromIso: string
toIso: string
fromDay: string
toDayExclusive: string
serverId?: number
iface?: string
country?: string
service?: string
asn?: number
userIfaces: Array<{ serverId: number; iface: string }> | null
}
function factWhere(alias: string, grain: "hour" | "day", ctx: FilterCtx): { sql: string; params: unknown[] } {
const params: unknown[] = []
const parts: string[] = []
if (grain === "hour") {
params.push(ctx.fromIso, ctx.toIso)
parts.push(`${alias}.bucket_at >= ? AND ${alias}.bucket_at < ?`)
} else {
params.push(ctx.fromDay, ctx.toDayExclusive)
parts.push(`${alias}.day >= ? AND ${alias}.day < ?`)
}
if (ctx.serverId != null) {
parts.push(`${alias}.server_id = ?`)
params.push(ctx.serverId)
}
if (ctx.iface) {
parts.push(`${alias}.iface = ?`)
params.push(ctx.iface)
}
if (ctx.country) {
parts.push(`${alias}.country = ?`)
params.push(ctx.country.toUpperCase())
}
if (ctx.service) {
parts.push(`${alias}.service = ?`)
params.push(ctx.service)
}
if (ctx.asn != null) {
parts.push(`${alias}.asn = ?`)
params.push(ctx.asn)
}
if (ctx.userIfaces) {
if (ctx.userIfaces.length === 0) {
parts.push("FALSE")
} else {
const tuples = ctx.userIfaces.map(() => "(?, ?)").join(", ")
parts.push(`(${alias}.server_id, ${alias}.iface) IN (${tuples})`)
for (const u of ctx.userIfaces) {
params.push(u.serverId, u.iface)
}
}
}
return { sql: parts.join(" AND "), params }
}
type FilterCtxFull = FilterCtx
function emptyDto(period: ParsedPeriod): StatisticsDto {
return {
from: period.fromIso,
to: period.toIso,
grain: period.grain,
kpis: {
bytes: 0,
packets: 0,
avgBps: 0,
users: 0,
servers: 0,
ifaces: 0,
topCountry: "—",
topService: "—",
},
series: [],
users: [],
servers: [],
interfaces: [],
countries: [],
services: [],
asns: [],
}
}
function toBreakdown(
rows: Array<{ id: string; label: string; bytes: number; packets: number }>,
totalBytes: number,
windowSec: number,
): StatisticsBreakdownRow[] {
const denom = totalBytes || 1
return rows
.sort((a, b) => b.bytes - a.bytes)
.slice(0, TOP_N)
.map((r) => ({
id: r.id,
label: r.label,
bytes: r.bytes,
packets: r.packets,
bps: (r.bytes * 8) / windowSec,
percent: (r.bytes / denom) * 100,
}))
}
async function resolveUserIfaces(userId?: string): Promise<Array<{ serverId: number; iface: string }> | null> {
if (!userId) return null
const binds = await db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId))
return binds.map((b) => ({ serverId: b.serverId, iface: b.interfaceName }))
}
export async function getStatistics(query: StatisticsQuery): Promise<StatisticsDto> {
const period = parseStatisticsPeriod(query.from, query.to)
if (!period) return emptyDto({
fromIso: query.from,
toIso: query.to,
fromDay: query.from.slice(0, 10),
toDayExclusive: query.to.slice(0, 10),
grain: "day",
windowSec: 1,
})
const userIfaces = await resolveUserIfaces(query.userId)
const ctx: FilterCtxFull = {
...period,
serverId: query.serverId,
iface: query.iface,
country: query.country,
service: query.service,
asn: query.asn,
userIfaces,
}
if (userIfaces && userIfaces.length === 0) return emptyDto(period)
const table = period.grain === "hour" ? "flow_hour_facts" : "flow_daily_facts"
const timeCol = period.grain === "hour" ? "bucket_at" : "day"
const where = factWhere("f", period.grain, ctx)
const totals = await dbAll<{ bytes: number; packets: number; servers: number; ifaces: number }>(`
SELECT
COALESCE(SUM(f.bytes), 0) AS bytes,
COALESCE(SUM(f.packets), 0) AS packets,
COUNT(DISTINCT f.server_id)::int AS servers,
COUNT(DISTINCT (f.server_id::text || ':' || f.iface))::int AS ifaces
FROM ${table} f
WHERE ${where.sql}
`, where.params)
const bytes = Number(totals[0]?.bytes) || 0
const packets = Number(totals[0]?.packets) || 0
const serverCount = Number(totals[0]?.servers) || 0
const ifaceCount = Number(totals[0]?.ifaces) || 0
const seriesRows = await dbAll<{ t: string; bytes: number }>(`
SELECT ${timeCol}::text AS t, SUM(f.bytes) AS bytes
FROM ${table} f
WHERE ${where.sql}
GROUP BY ${timeCol}
ORDER BY ${timeCol}
`, where.params)
const countryRows = await dbAll<{ id: string; bytes: number; packets: number }>(`
SELECT f.country AS id, SUM(f.bytes) AS bytes, SUM(f.packets) AS packets
FROM ${table} f
WHERE ${where.sql}
GROUP BY f.country
`, where.params)
const serviceRows = await dbAll<{ id: string; bytes: number; packets: number }>(`
SELECT f.service AS id, SUM(f.bytes) AS bytes, SUM(f.packets) AS packets
FROM ${table} f
WHERE ${where.sql}
GROUP BY f.service
`, where.params)
const asnRows = await dbAll<{ id: number; bytes: number; packets: number }>(`
SELECT f.asn AS id, SUM(f.bytes) AS bytes, SUM(f.packets) AS packets
FROM ${table} f
WHERE ${where.sql}
GROUP BY f.asn
`, where.params)
const serverRows = await dbAll<{ id: number; bytes: number; packets: number }>(`
SELECT f.server_id AS id, SUM(f.bytes) AS bytes, SUM(f.packets) AS packets
FROM ${table} f
WHERE ${where.sql}
GROUP BY f.server_id
`, where.params)
const ifaceRows = await dbAll<{ serverId: number; iface: string; bytes: number; packets: number }>(`
SELECT f.server_id AS "serverId", f.iface AS iface, SUM(f.bytes) AS bytes, SUM(f.packets) AS packets
FROM ${table} f
WHERE ${where.sql}
GROUP BY f.server_id, f.iface
`, where.params)
const userRows = await dbAll<{ id: string; bytes: number; packets: number }>(`
SELECT b.user_id AS id, SUM(f.bytes) AS bytes, SUM(f.packets) AS packets
FROM ${table} f
JOIN user_interface_bindings b
ON b.server_id = f.server_id AND b.interface_name = f.iface
WHERE ${where.sql}
GROUP BY b.user_id
`, where.params)
const serverNames = new Map<number, string>()
const allServers = await db.select({ id: servers.id, name: servers.name, host: servers.host }).from(servers)
for (const s of allServers) serverNames.set(s.id, s.name || s.host)
const userNames = new Map<string, string>()
const allUsers = await db.select({ id: appUsers.id, name: appUsers.name, login: appUsers.login }).from(appUsers)
for (const u of allUsers) userNames.set(u.id, u.name || u.login)
const asnHolders = new Map<number, string>()
const asnMeta = await db.select({ asn: flowAsnMeta.asn, holder: flowAsnMeta.holder }).from(flowAsnMeta)
for (const a of asnMeta) asnHolders.set(a.asn, a.holder)
const countries = toBreakdown(
countryRows.map((r) => ({
id: r.id,
label: r.id === "XX" ? "Неизвестно" : r.id,
bytes: Number(r.bytes) || 0,
packets: Number(r.packets) || 0,
})),
bytes,
period.windowSec,
)
const services = toBreakdown(
serviceRows.map((r) => ({
id: r.id,
label: r.id,
bytes: Number(r.bytes) || 0,
packets: Number(r.packets) || 0,
})),
bytes,
period.windowSec,
)
const asns = toBreakdown(
asnRows.map((r) => {
const id = Number(r.id) || 0
const holder = asnHolders.get(id)
return {
id: String(id),
label: id === 0 ? "other" : holder ? `AS${id} · ${holder}` : `AS${id}`,
bytes: Number(r.bytes) || 0,
packets: Number(r.packets) || 0,
}
}),
bytes,
period.windowSec,
)
const serverBreakdown = toBreakdown(
serverRows.map((r) => ({
id: String(r.id),
label: serverNames.get(r.id) || String(r.id),
bytes: Number(r.bytes) || 0,
packets: Number(r.packets) || 0,
})),
bytes,
period.windowSec,
)
const interfaces = toBreakdown(
ifaceRows.map((r) => ({
id: `${r.serverId}:${r.iface}`,
label: `${serverNames.get(r.serverId) || r.serverId} · ${r.iface}`,
bytes: Number(r.bytes) || 0,
packets: Number(r.packets) || 0,
})),
bytes,
period.windowSec,
)
const users = toBreakdown(
userRows.map((r) => ({
id: r.id,
label: userNames.get(r.id) || r.id,
bytes: Number(r.bytes) || 0,
packets: Number(r.packets) || 0,
})),
bytes,
period.windowSec,
)
return {
from: period.fromIso,
to: period.toIso,
grain: period.grain,
kpis: {
bytes,
packets,
avgBps: (bytes * 8) / period.windowSec,
users: users.length,
servers: serverCount,
ifaces: ifaceCount,
topCountry: countries[0]?.label || "—",
topService: services[0]?.label || "—",
},
series: seriesRows.map((r) => ({ t: r.t, bytes: Number(r.bytes) || 0 })),
users,
servers: serverBreakdown,
interfaces,
countries,
services,
asns,
}
}
@@ -26,7 +26,8 @@ import { resolveIfaceName } from "./traffic-flow-ifaces.js"
import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js"
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
import { dedupFlowRowsMaxBytes, flowTupleKey } from "./traffic-flow-dedup.js"
import { enqueueRipeMisses, lookupRipeCached } from "./traffic-flow-ripe.js"
import { 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"
@@ -255,7 +256,7 @@ async function buildFlowAnalyticsUncached(q: FlowAnalyticsQuery): Promise<FlowAn
const peer = pickInternetPeer(r.src, r.dst, r.srcPort, r.dstPort)
peers.add(peer)
const app = applicationName(r.proto, r.dstPort, r.srcPort)
const ripe = lookupRipeCached(peer)
const ripe = resolveFlowIp(peer)
const classified = classifyFlowDst(peer, r.proto, r.dstPort, r.srcPort, ripe)
bump(applications, app, r.bytes, r.packets)
bump(protocols, protoName(r.proto), r.bytes, r.packets)
+50 -3
View File
@@ -5,11 +5,19 @@ 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, lookupRipeCached, pruneRipeSqlite } from "./traffic-flow-ripe.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 { pickInternetPeer } from "./traffic-flow-ip.js"
import {
bumpFlowFact,
factsPendingSize,
flushFlowFacts,
hourBucketIso,
resetFactsForTests,
} from "./traffic-flow-facts.js"
export const TICK_MS = 2_000
export const PERSIST_MS = 10_000
@@ -327,13 +335,14 @@ export function getEngineStats(): EngineStats {
export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): void {
if (flows.length) bumpDataEpoch()
const bucketAt = minuteBucketIso()
const hourAt = hourBucketIso()
const ripeMisses: string[] = []
for (const raw of flows) {
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 = lookupRipeCached(peer)
const ripe = resolveFlowIp(peer)
if (peer && !ripe) ripeMisses.push(peer)
const classified = classifyFlowDst(peer, flow.proto, flow.dstPort, flow.srcPort, ripe)
const app = applicationName(flow.proto, flow.dstPort, flow.srcPort)
@@ -348,6 +357,16 @@ export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): vo
bumpDim(serverId, bucketAt, "service", classified.service, flow.bytes, flow.packets)
if (country) bumpDim(serverId, bucketAt, "country", country, flow.bytes, flow.packets)
bumpDim(serverId, bucketAt, "asn", asnKey, flow.bytes, flow.packets)
bumpFlowFact({
serverId,
bucketAt: hourAt,
iface: flow.inIface,
country: country || "XX",
service: classified.service,
asn: ripe?.ok && ripe.asn ? ripe.asn : 0,
bytes: flow.bytes,
packets: flow.packets,
})
const key = pendingKey(serverId, bucketAt, flow)
const prev = pending.get(key)
@@ -826,7 +845,7 @@ export async function flushPending(opts?: { force?: boolean }): Promise<void> {
pruneRecent()
rollFlowRings()
const force = Boolean(opts?.force)
const hasWork = pending.size > 0 || minuteRollup.size > 0 || minuteDims.size > 0
const hasWork = pending.size > 0 || minuteRollup.size > 0 || minuteDims.size > 0 || factsPendingSize() > 0
const due = persistDue(force, hasWork)
try {
await persistListenerStats(force)
@@ -884,6 +903,11 @@ export async function flushPending(opts?: { force?: boolean }): Promise<void> {
} catch {
/* rollup best-effort */
}
try {
await flushFlowFacts()
} catch {
/* statistics cube best-effort */
}
try {
await pruneStored()
} catch {
@@ -915,6 +939,7 @@ export function resetEngineForTests(): void {
rings.clear()
minuteRollup.clear()
minuteDims.clear()
resetFactsForTests()
packetsReceived = 0
lastExporterIp = null
lastError = ""
@@ -939,3 +964,25 @@ export function pendingSizeForTests(): number {
export function droppedForTests(): number {
return dropped
}
/** Снимок минутных dims (dim → key → bytes) для тестов обогащения потоков. */
export function minuteDimsSnapshotForTests(): Map<string, Map<string, { bytes: number; packets: number }>> {
const out = new Map<string, Map<string, { bytes: number; packets: number }>>()
for (const [k, acc] of minuteDims) {
// dimKey: serverId\0bucketAt\0dim\0key
const parts = k.split("\0")
const dim = parts[2] ?? ""
const key = parts.slice(3).join("\0")
let byKey = out.get(dim)
if (!byKey) {
byKey = new Map()
out.set(dim, byKey)
}
const prev = byKey.get(key)
byKey.set(key, {
bytes: (prev?.bytes ?? 0) + acc.bytes,
packets: (prev?.packets ?? 0) + acc.packets,
})
}
return out
}
@@ -0,0 +1,71 @@
import assert from "node:assert/strict"
import {
bumpFlowFact,
collectCappedFacts,
FACT_ASN_TOP,
FACT_TUPLE_CAP,
resetFactsForTests,
} from "./traffic-flow-facts.js"
resetFactsForTests()
bumpFlowFact({
serverId: 1,
bucketAt: "2026-09-10T10:00:00.000Z",
iface: "ether1",
country: "US",
service: "https",
asn: 15169,
bytes: 100,
packets: 2,
})
bumpFlowFact({
serverId: 1,
bucketAt: "2026-09-10T10:00:00.000Z",
iface: "ether1",
country: "us",
service: "https",
asn: 15169,
bytes: 50,
packets: 1,
})
const merged = collectCappedFacts()
assert.equal(merged.length, 1)
assert.equal(merged[0]?.bytes, 150)
assert.equal(merged[0]?.country, "US")
assert.equal(merged[0]?.asn, 15169)
resetFactsForTests()
for (let i = 1; i <= FACT_ASN_TOP + 20; i++) {
bumpFlowFact({
serverId: 2,
bucketAt: "2026-09-10T11:00:00.000Z",
iface: "ether1",
country: "DE",
service: "https",
asn: i,
bytes: FACT_ASN_TOP + 21 - i,
packets: 1,
})
}
const cappedAsn = collectCappedFacts()
const asns = new Set(cappedAsn.map((r) => r.asn))
assert.ok(asns.has(0))
assert.ok(asns.size <= FACT_ASN_TOP + 1)
resetFactsForTests()
for (let i = 0; i < FACT_TUPLE_CAP + 30; i++) {
bumpFlowFact({
serverId: 3,
bucketAt: "2026-09-10T12:00:00.000Z",
iface: `ether${i % 3}`,
country: "NL",
service: `svc-${i}`,
asn: 1,
bytes: 10,
packets: 1,
})
}
const cappedTuples = collectCappedFacts()
assert.ok(cappedTuples.length <= FACT_TUPLE_CAP + 3)
console.log("traffic-flow-facts.test.ts: ok")
+285
View File
@@ -0,0 +1,285 @@
import { pool } from "../db/index.js"
import { ensurePartitionFor, specForParent } from "../db/partitions.js"
export const FACT_ASN_TOP = 200
export const FACT_TUPLE_CAP = 8000
export const FACT_SERVICE_MAX_LEN = 64
export const UNKNOWN_COUNTRY = "XX"
export const OTHER_SERVICE = "other"
export const UNKNOWN_IFACE = "__unknown__"
export interface FactAcc {
bytes: number
packets: number
}
export interface FactRow {
serverId: number
bucketAt: string
iface: string
country: string
service: string
asn: number
bytes: number
packets: number
}
const hourFacts = new Map<string, FactAcc>()
const ensuredParts = new Set<string>()
export function hourBucketIso(at = Date.now()): string {
const d = new Date(at)
d.setMinutes(0, 0, 0)
return d.toISOString()
}
export function normalizeFactCountry(raw: string): string {
const iso = raw.trim().toUpperCase()
if (/^[A-Z]{2}$/.test(iso)) return iso
return UNKNOWN_COUNTRY
}
export function normalizeFactService(raw: string): string {
const s = raw.trim().slice(0, FACT_SERVICE_MAX_LEN)
return s || OTHER_SERVICE
}
export function normalizeFactIface(raw: string): string {
return raw.trim() || UNKNOWN_IFACE
}
export function normalizeFactAsn(raw: number): number {
if (!Number.isFinite(raw) || raw <= 0) return 0
return Math.trunc(raw)
}
function factKey(
serverId: number,
bucketAt: string,
iface: string,
country: string,
service: string,
asn: number,
): string {
return `${serverId}\0${bucketAt}\0${iface}\0${country}\0${service}\0${asn}`
}
function parseFactKey(k: string, acc: FactAcc): FactRow | null {
const parts = k.split("\0")
if (parts.length !== 6) return null
const serverId = Number(parts[0])
const asn = Number(parts[5])
if (!Number.isFinite(serverId) || !Number.isFinite(asn)) return null
return {
serverId,
bucketAt: parts[1] ?? "",
iface: parts[2] ?? UNKNOWN_IFACE,
country: parts[3] ?? UNKNOWN_COUNTRY,
service: parts[4] ?? OTHER_SERVICE,
asn,
bytes: acc.bytes,
packets: acc.packets,
}
}
export function bumpFlowFact(row: {
serverId: number
bucketAt: string
iface: string
country: string
service: string
asn: number
bytes: number
packets: number
}): void {
const iface = normalizeFactIface(row.iface)
const country = normalizeFactCountry(row.country)
const service = normalizeFactService(row.service)
const asn = normalizeFactAsn(row.asn)
const k = factKey(row.serverId, row.bucketAt, iface, country, service, asn)
const prev = hourFacts.get(k)
if (prev) {
prev.bytes += row.bytes
prev.packets += row.packets
return
}
hourFacts.set(k, { bytes: row.bytes, packets: row.packets })
}
function groupKey(row: FactRow): string {
return `${row.serverId}\0${row.bucketAt}`
}
function mergeRow(map: Map<string, FactRow>, row: FactRow): void {
const k = factKey(row.serverId, row.bucketAt, row.iface, row.country, row.service, row.asn)
const prev = map.get(k)
if (prev) {
prev.bytes += row.bytes
prev.packets += row.packets
return
}
map.set(k, { ...row })
}
/** Cap ASN tail and tuple count per server×hour before persist. */
export function collectCappedFacts(): FactRow[] {
const parsed: FactRow[] = []
for (const [k, acc] of hourFacts) {
const row = parseFactKey(k, acc)
if (row) parsed.push(row)
}
hourFacts.clear()
const groups = new Map<string, FactRow[]>()
for (const row of parsed) {
const g = groupKey(row)
const list = groups.get(g) ?? []
list.push(row)
groups.set(g, list)
}
const out = new Map<string, FactRow>()
for (const list of groups.values()) {
const byAsn = new Map<number, number>()
for (const row of list) {
byAsn.set(row.asn, (byAsn.get(row.asn) ?? 0) + row.bytes)
}
const asnKeep = new Set(
[...byAsn.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, FACT_ASN_TOP)
.map(([asn]) => asn),
)
const afterAsn: FactRow[] = []
for (const row of list) {
if (asnKeep.has(row.asn) || row.asn === 0) {
afterAsn.push(row)
continue
}
afterAsn.push({ ...row, asn: 0 })
}
const collapsed = new Map<string, FactRow>()
for (const row of afterAsn) mergeRow(collapsed, row)
const tuples = [...collapsed.values()].sort((a, b) => b.bytes - a.bytes)
const keep = tuples.slice(0, FACT_TUPLE_CAP)
const tail = tuples.slice(FACT_TUPLE_CAP)
for (const row of keep) mergeRow(out, row)
for (const row of tail) {
mergeRow(out, {
...row,
country: UNKNOWN_COUNTRY,
service: OTHER_SERVICE,
asn: 0,
})
}
}
return [...out.values()]
}
async function ensureParentPartition(parent: string, ts: string): Promise<void> {
const spec = specForParent(parent)
if (!spec) return
const iso = ts.length === 10 ? `${ts}T00:00:00Z` : ts
const key = `${parent}:${iso.slice(0, 10)}`
if (ensuredParts.has(key)) return
await ensurePartitionFor(pool, parent, spec.kind, new Date(iso))
ensuredParts.add(key)
}
function dayKey(bucketAt: string): string {
return bucketAt.slice(0, 10)
}
export async function flushFlowFacts(): Promise<number> {
const rows = collectCappedFacts()
if (rows.length === 0) return 0
const hours = new Set(rows.map((r) => r.bucketAt))
const days = new Set(rows.map((r) => dayKey(r.bucketAt)))
for (const h of hours) await ensureParentPartition("flow_hour_facts", h)
for (const d of days) await ensureParentPartition("flow_daily_facts", d)
await pool.query({
text: `
INSERT INTO flow_hour_facts (
server_id, bucket_at, iface, country, service, asn, bytes, packets
)
SELECT *
FROM UNNEST(
$1::bigint[],
$2::timestamptz[],
$3::text[],
$4::char(2)[],
$5::text[],
$6::int[],
$7::bigint[],
$8::bigint[]
) AS t(server_id, bucket_at, iface, country, service, asn, bytes, packets)
ON CONFLICT (server_id, bucket_at, iface, country, service, asn)
DO UPDATE SET
bytes = flow_hour_facts.bytes + excluded.bytes,
packets = flow_hour_facts.packets + excluded.packets
`,
values: [
rows.map((r) => r.serverId),
rows.map((r) => r.bucketAt),
rows.map((r) => r.iface),
rows.map((r) => r.country),
rows.map((r) => r.service),
rows.map((r) => r.asn),
rows.map((r) => r.bytes),
rows.map((r) => r.packets),
],
})
await pool.query({
text: `
INSERT INTO flow_daily_facts (
server_id, day, iface, country, service, asn, bytes, packets
)
SELECT *
FROM UNNEST(
$1::bigint[],
$2::date[],
$3::text[],
$4::char(2)[],
$5::text[],
$6::int[],
$7::bigint[],
$8::bigint[]
) AS t(server_id, day, iface, country, service, asn, bytes, packets)
ON CONFLICT (server_id, day, iface, country, service, asn)
DO UPDATE SET
bytes = flow_daily_facts.bytes + excluded.bytes,
packets = flow_daily_facts.packets + excluded.packets
`,
values: [
rows.map((r) => r.serverId),
rows.map((r) => dayKey(r.bucketAt)),
rows.map((r) => r.iface),
rows.map((r) => r.country),
rows.map((r) => r.service),
rows.map((r) => r.asn),
rows.map((r) => r.bytes),
rows.map((r) => r.packets),
],
})
return rows.length
}
export function factsPendingSize(): number {
return hourFacts.size
}
export function resetFactsForTests(): void {
hourFacts.clear()
ensuredParts.clear()
}
export function factsSnapshotForTests(): FactRow[] {
const parsed: FactRow[] = []
for (const [k, acc] of hourFacts) {
const row = parseFactKey(k, acc)
if (row) parsed.push(row)
}
return parsed
}
@@ -0,0 +1,186 @@
import assert from "node:assert/strict"
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import path from "node:path"
import type { AsnResponse, CountryResponse, Reader } from "maxmind"
import {
disableRipeEnqueueForTests,
disableRipePersistForTests,
resetRipeCacheForTests,
seedRipeCacheForTests,
} from "./traffic-flow-ripe.js"
import {
lookupGeoip,
resetGeoipForTests,
resolveFlowIp,
setGeoipReadersForTests,
} from "./traffic-flow-geoip.js"
import {
resetEngineForTests,
ingestParsedFlowsForServerForTests,
minuteDimsSnapshotForTests,
} from "./traffic-flow-engine.js"
import { classifyFlowDst } from "./traffic-flow-classify.js"
import { disableGeoipDbForTests } from "./geoip-settings.js"
import {
collectGeoipUpdateOnce,
resetGeoipUpdateForTests,
setGeoipFetchForTests,
setGeoipValidateForTests,
} from "./geoip-update-collector.js"
disableRipePersistForTests()
disableRipeEnqueueForTests()
resetRipeCacheForTests()
resetGeoipForTests()
// ── lookupGeoip: приватные IP → negative без ридеров ──────────────────────────
assert.equal(lookupGeoip("10.1.1.8")?.ok, false)
assert.equal(lookupGeoip("192.168.0.1")?.prefix, "192.168.0.1/32")
assert.equal(lookupGeoip("100.64.1.2")?.ok, false)
assert.equal(lookupGeoip("fe80::1")?.prefix, "fe80::1/128")
// ── без ридеров публичный IP → null, resolveFlowIp уходит в RIPE-кэш ─────────
assert.equal(lookupGeoip("1.2.3.10"), null)
seedRipeCacheForTests({
prefix: "1.2.3.0/24",
asn: 64500,
country: "NL",
lat: null,
lng: null,
holder: "TEST",
ok: true,
fetchedAt: Date.now(),
})
assert.equal(resolveFlowIp("1.2.3.10")?.country, "NL")
assert.equal(resolveFlowIp("1.2.3.10")?.asn, 64500)
// ── fake-ридеры: geoip приоритетнее RIPE ──────────────────────────────────────
function fakeCountryReader(byIp: Record<string, string>): Reader<CountryResponse> {
return {
get(ip: string) {
const iso = byIp[ip]
return iso ? ({ country: { iso_code: iso } } as CountryResponse) : null
},
metadata: { buildEpoch: new Date("2026-09-02T00:00:00Z") },
} as unknown as Reader<CountryResponse>
}
function fakeAsnReader(byIp: Record<string, { asn: number; org: string }>): Reader<AsnResponse> {
return {
get(ip: string) {
const hit = byIp[ip]
return hit
? ({ autonomous_system_number: hit.asn, autonomous_system_organization: hit.org } as AsnResponse)
: null
},
metadata: { buildEpoch: new Date("2026-09-02T00:00:00Z") },
} as unknown as Reader<AsnResponse>
}
setGeoipReadersForTests({
country: fakeCountryReader({ "8.8.8.8": "US", "6.6.6.6": "EU" }),
asn: fakeAsnReader({
"8.8.8.8": { asn: 15169, org: "GOOGLE" },
"6.6.6.6": { asn: 15169, org: "GOOGLE" },
}),
})
const hit = resolveFlowIp("8.8.8.8")
assert.equal(hit?.country, "US")
assert.equal(hit?.asn, 15169)
assert.equal(hit?.holder, "GOOGLE")
assert.equal(hit?.ok, true)
// 1.2.3.10 в fake-ридерах нет — по-прежнему из RIPE-кэша
assert.equal(resolveFlowIp("1.2.3.10")?.asn, 64500)
// EU не ISO-страна: отфильтрована, страна выведена из ASN (HQ Google → US)
assert.equal(lookupGeoip("6.6.6.6")?.country, "US")
// geoip-мета совместима с classifyFlowDst (бренд по ASN 15169)
const classified = classifyFlowDst("8.8.8.8", 6, 443, 51504, hit)
assert.equal(classified.service, "Google")
// ── движок: dims country/asn наполняются из geoip-ридеров ────────────────────
resetEngineForTests()
ingestParsedFlowsForServerForTests(1, [{
src: "192.168.88.10",
dst: "8.8.8.8",
proto: 6,
srcPort: 51504,
dstPort: 443,
bytes: 1000,
packets: 10,
inIface: "wg-flow",
outIface: "",
nextHop: "",
flowStartMs: 0,
flowEndMs: 0,
natSrc: "",
natDst: "",
}])
const dims = minuteDimsSnapshotForTests()
assert.equal(dims.get("country")?.get("US")?.bytes, 1000)
assert.equal(dims.get("asn")?.get("15169")?.bytes, 1000)
// ── коллектор: 304 → обе базы без изменений ───────────────────────────────────
disableGeoipDbForTests()
resetGeoipUpdateForTests()
const geoipDir = mkdtempSync(path.join(tmpdir(), "mm-geoip-test-"))
process.env.GEOIP_DIR = geoipDir
setGeoipFetchForTests(async () => new Response(null, { status: 304 }))
let snap = await collectGeoipUpdateOnce({ force: true })
assert.equal(snap.skippedUnchanged, 2)
assert.equal(snap.downloaded, 0)
assert.equal(existsSync(path.join(geoipDir, "GeoLite2-Country.mmdb")), false)
// ── коллектор: 200 + валидация ok → подмена, старый файл в .prev ─────────────
const countryPath = path.join(geoipDir, "GeoLite2-Country.mmdb")
const asnPath = path.join(geoipDir, "GeoLite2-ASN.mmdb")
writeFileSync(countryPath, "old-country")
setGeoipFetchForTests(async () =>
new Response(new Uint8Array([1, 2, 3]), { status: 200, headers: { etag: '"v1"' } }))
setGeoipValidateForTests({
country: async (p) => {
assert.ok(p.endsWith(".tmp"), "валидация должна идти по tmp-файлу")
return "2026-09-08T00:00:00.000Z"
},
asn: async () => "2026-09-08T00:00:00.000Z",
})
snap = await collectGeoipUpdateOnce({ force: true })
assert.equal(snap.downloaded, 2)
assert.equal(snap.errors.length, 0)
assert.deepEqual(readFileSync(countryPath), Buffer.from([1, 2, 3]))
assert.equal(readFileSync(`${countryPath}.prev`, "utf8"), "old-country")
assert.equal(existsSync(`${asnPath}.prev`), false, "prev у asn не бывает при первой загрузке")
assert.equal(existsSync(`${countryPath}.tmp`), false)
// ── коллектор: битая база → подмены нет, старый файл цел, tmp удалён ─────────
writeFileSync(asnPath, "good-asn")
setGeoipFetchForTests(async () =>
new Response(new Uint8Array([9, 9]), { status: 200 }))
setGeoipValidateForTests({
country: async () => {
throw new Error("битая база")
},
asn: async () => {
throw new Error("битая база")
},
})
snap = await collectGeoipUpdateOnce({ force: true })
assert.equal(snap.downloaded, 0)
assert.equal(snap.errors.length, 2)
assert.deepEqual(readFileSync(countryPath), Buffer.from([1, 2, 3]), "country не тронута")
assert.equal(readFileSync(asnPath, "utf8"), "good-asn", "asn не тронут")
assert.equal(existsSync(`${countryPath}.tmp`), false)
assert.equal(existsSync(`${asnPath}.tmp`), false)
rmSync(geoipDir, { recursive: true, force: true })
delete process.env.GEOIP_DIR
resetGeoipUpdateForTests()
resetGeoipForTests()
console.log("traffic-flow-geoip.test.ts: ok")
+162
View File
@@ -0,0 +1,162 @@
import { existsSync } from "node:fs"
import path from "node:path"
import { open, type AsnResponse, type CountryResponse, type Reader } from "maxmind"
import { isNonPublicIp } from "./traffic-flow-ip.js"
import { isIsoCountry, resolveRipeCountry } from "./traffic-flow-brands.js"
import { lookupRipeCached, type FlowIpMeta } from "./traffic-flow-ripe.js"
export const GEOIP_COUNTRY_FILE = "GeoLite2-Country.mmdb"
export const GEOIP_ASN_FILE = "GeoLite2-ASN.mmdb"
/** Каталог баз: `storage/geoip` рядом со storage/backups; переопределяется GEOIP_DIR. */
export function geoipDir(): string {
return path.resolve(process.env.GEOIP_DIR ?? path.join(process.cwd(), "storage", "geoip"))
}
export function geoipCountryPath(): string {
return path.join(geoipDir(), GEOIP_COUNTRY_FILE)
}
export function geoipAsnPath(): string {
return path.join(geoipDir(), GEOIP_ASN_FILE)
}
export interface GeoipReaders {
country: Reader<CountryResponse> | null
asn: Reader<AsnResponse> | null
}
let readers: GeoipReaders = { country: null, asn: null }
let initPromise: Promise<GeoipReaders> | null = null
/** Открывает оба файла best-effort: отсутствующий/битый файл не мешает второму. */
export async function openGeoipReaders(dir = geoipDir()): Promise<GeoipReaders> {
const next: GeoipReaders = { country: null, asn: null }
if (existsSync(path.join(dir, GEOIP_COUNTRY_FILE))) {
try {
next.country = await open<CountryResponse>(path.join(dir, GEOIP_COUNTRY_FILE))
} catch {
/* битый файл — работаем без country */
}
}
if (existsSync(path.join(dir, GEOIP_ASN_FILE))) {
try {
next.asn = await open<AsnResponse>(path.join(dir, GEOIP_ASN_FILE))
} catch {
/* битый файл — работаем без ASN */
}
}
return next
}
/** Открывает ридеры при старте; файлы есть — работают, нет — lookup уходит в RIPE-fallback. */
export async function initGeoip(): Promise<GeoipReaders> {
if (!initPromise) {
initPromise = openGeoipReaders().then((next) => {
readers = next
return next
})
}
return initPromise
}
/** Переоткрывает ридеры после обновления файлов (атомарная замена ссылок). */
export async function reloadGeoipReaders(): Promise<GeoipReaders> {
const next = await openGeoipReaders()
readers = next
initPromise = Promise.resolve(next)
return next
}
export function setGeoipReadersForTests(next: Partial<GeoipReaders>): void {
readers = { country: next.country ?? null, asn: next.asn ?? null }
}
export function resetGeoipForTests(): void {
readers = { country: null, asn: null }
initPromise = null
}
function negativeMeta(ip: string): FlowIpMeta {
const v6 = ip.includes(":")
return {
prefix: `${ip}/${v6 ? 128 : 32}`,
asn: 0,
country: "—",
lat: null,
lng: null,
holder: "",
ok: false,
fetchedAt: Date.now(),
}
}
function safeCountryIso(reader: Reader<CountryResponse>, ip: string): string {
try {
const rec = reader.get(ip)
return rec?.country?.iso_code ?? rec?.registered_country?.iso_code ?? ""
} catch {
return ""
}
}
function safeAsn(reader: Reader<AsnResponse>, ip: string): { asn: number; holder: string } {
try {
const rec = reader.get(ip)
return {
asn: rec?.autonomous_system_number ?? 0,
holder: rec?.autonomous_system_organization ?? "",
}
} catch {
return { asn: 0, holder: "" }
}
}
/**
* Синхронный lookup по локальным GeoLite2. Возвращает FlowIpMeta в семантике RIPE-кэша
* (ok=true когда есть страна или ASN; null данных нет, пусть пробует RIPE).
*/
export function lookupGeoip(ip: string): FlowIpMeta | null {
const trimmed = String(ip ?? "").trim()
if (!trimmed) return null
if (isNonPublicIp(trimmed)) return negativeMeta(trimmed)
const { country: countryReader, asn: asnReader } = readers
if (!countryReader && !asnReader) return null
const iso = countryReader ? safeCountryIso(countryReader, trimmed) : ""
const country = iso && isIsoCountry(iso) ? iso : ""
const { asn, holder } = asnReader ? safeAsn(asnReader, trimmed) : { asn: 0, holder: "" }
if (!asn && !country) return null
return {
prefix: `${trimmed}/${trimmed.includes(":") ? 128 : 32}`,
asn,
country: resolveRipeCountry(country, asn, holder) || "—",
lat: null,
lng: null,
holder,
ok: true,
fetchedAt: Date.now(),
}
}
/** Главный вход для потребителей пайплайна: локальные базы первыми, RIPE-кэш fallback. */
export function resolveFlowIp(ip: string): FlowIpMeta | null {
return lookupGeoip(ip) ?? lookupRipeCached(ip)
}
export interface GeoipReadersStatus {
countryLoaded: boolean
asnLoaded: boolean
countryBuildAt: string | null
asnBuildAt: string | null
dir: string
}
export function geoipReadersStatus(): GeoipReadersStatus {
return {
countryLoaded: Boolean(readers.country),
asnLoaded: Boolean(readers.asn),
countryBuildAt: readers.country?.metadata.buildEpoch.toISOString() ?? null,
asnBuildAt: readers.asn?.metadata.buildEpoch.toISOString() ?? null,
dir: geoipDir(),
}
}
@@ -467,11 +467,15 @@ export async function purgeTrafficFlowStore(): Promise<FlowPurgeDto> {
minuteStats: await tableCount("flow_minute_stats"),
minuteDims: await tableCount("flow_minute_dims"),
dailyDims: await tableCount("flow_daily_dims"),
hourFacts: await tableCount("flow_hour_facts"),
dailyFacts: await tableCount("flow_daily_facts"),
}
await dbQuery(`DELETE FROM flow_buckets`)
await dbQuery(`DELETE FROM flow_minute_stats`)
await dbQuery(`DELETE FROM flow_minute_dims`)
await dbQuery(`DELETE FROM flow_daily_dims`)
await dbQuery(`DELETE FROM flow_hour_facts`)
await dbQuery(`DELETE FROM flow_daily_facts`)
await resetFlowIngestCounters()
await dropExpiredPartitions(pool)
try {
@@ -13,7 +13,8 @@ import { getFlowListenerState, listFlowRowsForWindow } from "./traffic-flow-inge
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
import { classifyFlowPlane, shouldKeepPlane } from "./traffic-flow-planes.js"
import { pickInternetPeer } from "./traffic-flow-ip.js"
import { lookupRipeCached, type FlowIpMeta } from "./traffic-flow-ripe.js"
import { type FlowIpMeta } from "./traffic-flow-ripe.js"
import { resolveFlowIp } from "./traffic-flow-geoip.js"
import { getTrafficFlowSettingsRow } from "./traffic-flow-settings.js"
import { loadFlowTopology, resolveClient, resolveEn, getServerCatalog } from "./traffic-flow-topology.js"
import { flowDataEpoch } from "./traffic-flow-engine.js"
@@ -394,7 +395,7 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
}
for (const [dst, acc] of dstAcc) {
const ripe = lookupRipeCached(dst)
const ripe = resolveFlowIp(dst)
const classified = classifyMapDstLite(dst, acc.proto, acc.dstPort, acc.srcPort, ripe)
if (!classified) continue
const toId = mapServiceNodeId(classified.service)
@@ -226,6 +226,19 @@ export interface BackupsRunSnapshot {
fatalError?: string
}
export interface GeoipUpdateRunSnapshot {
v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION
job: "geoip_update"
sampledAt: string
skipped?: boolean
fatalError?: string
checked: number
downloaded: number
skippedUnchanged: number
bytes: number
errors: string[]
}
export type SchedulerRunSnapshot =
| TrafficRunSnapshot
| ResourcesRunSnapshot
@@ -236,4 +249,5 @@ export type SchedulerRunSnapshot =
| InternetPathRunSnapshot
| CertificatesRenewRunSnapshot
| BackupsRunSnapshot
| GeoipUpdateRunSnapshot
| AlertEngineRunSnapshot
+2
View File
@@ -13,6 +13,7 @@ import {
import {
LayoutDashboardIcon,
ActivityIcon,
ChartColumnIcon,
MapIcon,
HeartPulseIcon,
GlobeIcon,
@@ -54,6 +55,7 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
items: [
{ title: "Дашборд", url: "/dashboard", icon: <LayoutDashboardIcon /> },
{ title: "Трафик", url: "/traffic", icon: <ActivityIcon /> },
{ title: "Статистика", url: "/statistics", icon: <ChartColumnIcon /> },
{ title: "Карта сети", url: "/network-map", icon: <MapIcon /> },
{ title: "Мониторинг", url: "/uptime", icon: <HeartPulseIcon /> },
],
+2 -1
View File
@@ -4,7 +4,7 @@ import { useEffect, useState, useRef, useMemo } from "react"
import { useRouter, usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
import {
SearchIcon, LayoutDashboardIcon, ActivityIcon, MapIcon, HeartPulseIcon,
SearchIcon, LayoutDashboardIcon, ActivityIcon, ChartColumnIcon, MapIcon, HeartPulseIcon,
GlobeIcon, NetworkIcon, LayersIcon, TagIcon, ServerIcon, FilterIcon,
ShieldIcon, ShieldCheckIcon, CableIcon, BoxIcon, BadgeCheckIcon,
HardDriveIcon, RouteIcon, GitForkIcon, GitMergeIcon, ScanLineIcon,
@@ -27,6 +27,7 @@ const ALL_ITEMS: CommandItem[] = [
// Обзор
{ id: "dashboard", title: "Дашборд", group: "Обзор", url: "/dashboard", icon: <LayoutDashboardIcon />, keywords: ["главная","home","overview"] },
{ id: "traffic", title: "Трафик", group: "Обзор", url: "/traffic", icon: <ActivityIcon />, keywords: ["bandwidth","traffic","клиенты","интерфейсы"] },
{ id: "statistics", title: "Статистика", group: "Обзор", url: "/statistics", icon: <ChartColumnIcon />, keywords: ["stats","отчёт","куб","страны","asn","ipfix"] },
{ id: "network-map", title: "Карта сети", group: "Обзор", url: "/network-map", icon: <MapIcon />, keywords: ["topology","топология","map"] },
{ id: "uptime", title: "Мониторинг / Uptime", group: "Обзор", url: "/uptime", icon: <HeartPulseIcon />, keywords: ["ping","uptime","мониторинг","проверка"] },
// Данные
@@ -0,0 +1,78 @@
"use client"
import { CompactDataGrid, type CompactDataGridColumn } from "@/components/data-grids/compact-data-grid"
import { Flag } from "@/components/flag"
import { Badge } from "@/components/reui/badge"
import { fmtBps, formatBytes } from "@/lib/fmt-rate"
import { cn } from "@/lib/utils"
import type { StatisticsBreakdownRow } from "@mmapp/contracts/statistics"
export type StatisticsSliceKind = "users" | "servers" | "interfaces" | "countries" | "services" | "asns"
export function StatisticsBreakdownDataGrid({
rows,
kind,
selectedId,
onRowClick,
isLoading,
}: {
rows: StatisticsBreakdownRow[]
kind: StatisticsSliceKind
selectedId?: string
onRowClick?: (row: StatisticsBreakdownRow) => void
isLoading?: boolean
}) {
const columns: CompactDataGridColumn<StatisticsBreakdownRow>[] = [
{
id: "label",
header: "Имя",
accessorKey: "label",
cell: (row) => (
<span className={cn("flex items-center gap-2", selectedId === row.id && "font-medium")}>
{kind === "countries" && row.id !== "XX" ? <Flag code={row.id} size={16} /> : null}
<span className="truncate">{row.label || row.id}</span>
{selectedId === row.id ? (
<Badge variant="outline" size="sm">
слайс
</Badge>
) : null}
</span>
),
},
{
id: "bytes",
header: "Байты",
accessorKey: "bytes",
cell: (row) => <span className="tabular-nums">{formatBytes(row.bytes)}</span>,
},
{
id: "packets",
header: "Пакеты",
accessorKey: "packets",
cell: (row) => <span className="tabular-nums">{row.packets.toLocaleString("ru-RU")}</span>,
},
{
id: "bps",
header: "Средний bitrate",
accessorKey: "bps",
cell: (row) => <span className="tabular-nums">{fmtBps(row.bps)}</span>,
},
{
id: "percent",
header: "Доля",
accessorKey: "percent",
cell: (row) => <span className="tabular-nums">{row.percent.toFixed(1)}%</span>,
},
]
return (
<CompactDataGrid
data={rows}
columns={columns}
isLoading={isLoading}
emptyTitle="Нет трафика"
emptyDescription="За выбранный период и слайсы нет данных куба."
onRowClick={onRowClick}
/>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,65 @@
import type { DateSelectorI18nConfig } from "@/components/reui/date-selector"
/** Русские подписи ReUI DateSelector (шапка /statistics). */
export const DATE_SELECTOR_RU: DateSelectorI18nConfig = {
selectDate: "Выбрать дату",
apply: "Применить",
cancel: "Отмена",
clear: "Сбросить",
today: "Сегодня",
filterTypes: {
is: "равно",
before: "до",
after: "после",
between: "между",
},
periodTypes: {
day: "День",
month: "Месяц",
quarter: "Квартал",
halfYear: "Полугодие",
year: "Год",
},
months: [
"Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
],
monthsShort: [
"янв",
"фев",
"мар",
"апр",
"май",
"июн",
"июл",
"авг",
"сен",
"окт",
"ноя",
"дек",
],
quarters: ["I кв.", "II кв.", "III кв.", "IV кв."],
halfYears: ["1-е полугодие", "2-е полугодие"],
weekdays: [
"Воскресенье",
"Понедельник",
"Вторник",
"Среда",
"Четверг",
"Пятница",
"Суббота",
],
weekdaysShort: ["вс", "пн", "вт", "ср", "чт", "пт", "сб"],
placeholder: "Выберите дату…",
rangePlaceholder: "Выберите период…",
}
+188
View File
@@ -0,0 +1,188 @@
"use client"
import { useMemo } from "react"
import { format } from "date-fns"
import { ru } from "date-fns/locale"
import { CalendarIcon } from "lucide-react"
import {
DateSelector,
type DateSelectorValue,
} from "@/components/reui/date-selector"
import { DATE_SELECTOR_RU } from "@/components/statistics/date-selector-i18n"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
/**
* Период отчёта DateSelector в Popover (c-date-selector-2).
* @see https://reui.io/preview/base/components/c-date-selector-2
* @see https://reui.io/docs/components/base/date-selector
*/
export type PeriodPreset = "today" | "24h" | "7d" | "30d" | "month"
export interface DateRangeYmd {
from: string
to: string
}
const PRESETS: { id: PeriodPreset; label: string }[] = [
{ id: "today", label: "Сегодня" },
{ id: "24h", label: "24 ч" },
{ id: "7d", label: "7 д" },
{ id: "30d", label: "30 д" },
{ id: "month", label: "Месяц" },
]
function pad2(n: number): string {
return String(n).padStart(2, "0")
}
export function formatYmd(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
}
export function parseYmd(s: string): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
if (!m) return null
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
return Number.isNaN(d.getTime()) ? null : d
}
export function rangeForPreset(preset: PeriodPreset, now = new Date()): DateRangeYmd {
const to = formatYmd(now)
if (preset === "today") return { from: to, to }
if (preset === "24h") {
const from = new Date(now)
from.setDate(from.getDate() - 1)
return { from: formatYmd(from), to }
}
if (preset === "7d") {
const from = new Date(now)
from.setDate(from.getDate() - 6)
return { from: formatYmd(from), to }
}
if (preset === "30d") {
const from = new Date(now)
from.setDate(from.getDate() - 29)
return { from: formatYmd(from), to }
}
const start = new Date(now.getFullYear(), now.getMonth(), 1)
return { from: formatYmd(start), to }
}
export function dateSelectorToRange(value: DateSelectorValue): DateRangeYmd | null {
if (value.period === "day") {
if (value.operator === "between") {
if (!value.startDate || !value.endDate) return null
const a = formatYmd(value.startDate)
const b = formatYmd(value.endDate)
return a <= b ? { from: a, to: b } : { from: b, to: a }
}
if (value.startDate) {
const d = formatYmd(value.startDate)
return { from: d, to: d }
}
}
if (value.period === "month" && value.year != null && value.month != null) {
const start = new Date(value.year, value.month, 1)
const end = new Date(value.year, value.month + 1, 0)
return { from: formatYmd(start), to: formatYmd(end) }
}
if (value.period === "year" && value.year != null) {
return { from: `${value.year}-01-01`, to: `${value.year}-12-31` }
}
if (value.period === "quarter" && value.year != null && value.quarter != null) {
const startMonth = value.quarter * 3
const start = new Date(value.year, startMonth, 1)
const end = new Date(value.year, startMonth + 3, 0)
return { from: formatYmd(start), to: formatYmd(end) }
}
if (value.period === "half-year" && value.year != null && value.halfYear != null) {
const startMonth = value.halfYear * 6
const start = new Date(value.year, startMonth, 1)
const end = new Date(value.year, startMonth + 6, 0)
return { from: formatYmd(start), to: formatYmd(end) }
}
return null
}
export function rangeToSelector(range: DateRangeYmd): DateSelectorValue {
return {
period: "day",
operator: "between",
startDate: parseYmd(range.from) ?? undefined,
endDate: parseYmd(range.to) ?? undefined,
}
}
function formatRangeLabel(range: DateRangeYmd): string {
const from = parseYmd(range.from)
const to = parseYmd(range.to)
if (!from || !to) return "Период"
if (range.from === range.to) return format(from, "d MMM yyyy", { locale: ru })
return `${format(from, "d MMM", { locale: ru })} ${format(to, "d MMM yyyy", { locale: ru })}`
}
export function PeriodSelector({
range,
onChange,
}: {
range: DateRangeYmd
onChange: (next: DateRangeYmd) => void
}) {
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 activePreset = PRESETS.find((p) => {
const r = rangeForPreset(p.id)
return r.from === range.from && r.to === range.to
})?.id
return (
<div className="flex flex-wrap items-center gap-1.5">
{PRESETS.map((p) => (
<Button
key={p.id}
type="button"
variant={activePreset === p.id ? "secondary" : "ghost"}
size="sm"
onClick={() => onChange(rangeForPreset(p.id))}
>
{p.label}
</Button>
))}
<Popover>
<PopoverTrigger
render={
<Button type="button" variant="outline" size="sm" className="min-w-40 justify-between" />
}
>
<CalendarIcon className="size-3.5" />
<span className="tabular-nums">{formatRangeLabel(range)}</span>
</PopoverTrigger>
<PopoverContent align="end" className="w-auto min-w-[32rem] max-w-[min(100vw-2rem,42rem)] p-3">
<DateSelector
value={selectorValue}
onChange={handleSelectorChange}
allowRange
defaultPeriodType="day"
defaultFilterType="between"
periodTypes={["day", "month", "year"]}
showTwoMonths
weekStartsOn={1}
maxYear={2035}
dayDateFormat="dd.MM.yyyy"
i18n={DATE_SELECTOR_RU}
className="sm:w-[470px]"
/>
</PopoverContent>
</Popover>
</div>
)
}
@@ -0,0 +1,105 @@
"use client"
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"
import { Frame, FramePanel } from "@/components/reui/frame"
import { ChartContainer, ChartTooltip, type ChartConfig } from "@/components/ui/chart"
import { formatBytes } from "@/lib/fmt-rate"
import type { StatisticsSeriesPoint } from "@mmapp/contracts/statistics"
/**
* Объём трафика за период adapt ReUI chart-23 (байты, не live bps).
* @see https://reui.io/preview/base/chart-23
*/
const chartConfig = {
bytes: { label: "Объём", color: "var(--chart-1)" },
} satisfies ChartConfig
function formatTick(iso: string, grain: "hour" | "day"): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso.slice(0, 10)
if (grain === "hour") {
return `${String(d.getHours()).padStart(2, "0")}:00`
}
return d.toLocaleDateString("ru-RU", { day: "2-digit", month: "short" })
}
function CustomTooltip({
active,
payload,
}: {
active?: boolean
payload?: { payload: { label: string; bytes: number } }[]
}) {
if (!active || !payload?.length) return null
const row = payload[0]?.payload
if (!row) return null
return (
<div className="flex min-w-[120px] flex-col gap-1.5 rounded-lg bg-popover p-3 text-popover-foreground shadow-lg ring-1 ring-foreground/10">
<div className="text-[10px] font-medium tracking-wider uppercase opacity-70">{row.label}</div>
<div className="text-sm font-semibold tabular-nums">{formatBytes(row.bytes)}</div>
</div>
)
}
export function StatisticsVolumeChart({
series,
grain,
}: {
series: StatisticsSeriesPoint[]
grain: "hour" | "day"
}) {
const data = series.map((p) => ({
t: p.t,
bytes: p.bytes,
label: formatTick(p.t, grain),
}))
const tickEvery = Math.max(1, Math.ceil(data.length / 8))
return (
<Frame className="w-full">
<FramePanel className="flex flex-col gap-4">
<div className="flex items-baseline justify-between gap-2">
<h2 className="text-sm font-medium">Объём по времени</h2>
<span className="text-muted-foreground text-xs">
{grain === "hour" ? "по часам" : "по суткам"}
</span>
</div>
{data.length === 0 ? (
<p className="text-muted-foreground py-10 text-center text-sm">Нет данных за выбранный период</p>
) : (
<ChartContainer config={chartConfig} className="-ms-4 aspect-auto h-[220px] w-full">
<AreaChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
<CartesianGrid strokeDasharray="4 8" vertical={false} stroke="var(--border)" />
<XAxis
dataKey="label"
axisLine={false}
tickLine={false}
tick={{ fontSize: 11 }}
tickMargin={10}
interval={tickEvery - 1}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 11 }}
tickFormatter={(v: number) => formatBytes(Number(v))}
tickMargin={8}
width={72}
/>
<ChartTooltip content={<CustomTooltip />} />
<Area
dataKey="bytes"
type="monotone"
stroke="var(--chart-1)"
fill="var(--chart-1)"
fillOpacity={0.15}
strokeWidth={2}
/>
</AreaChart>
</ChartContainer>
)}
</FramePanel>
</Frame>
)
}
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { CodeExportSheet, type CodeExportFormat } from "@/components/reui-kit/code-export-sheet"
import type { TrafficFlowSettingsDto } from "@mmapp/contracts/traffic-flow"
import type { GeoipStatusDto } from "@mmapp/contracts/geoip"
import {
generateTrafficFlowKeys,
getTrafficFlowHostFiles,
@@ -17,6 +18,7 @@ import {
purgeTrafficFlowData,
putTrafficFlowSettings,
} from "@/shared/api/traffic-flow"
import { getGeoipStatus, putGeoipSettings, runGeoipUpdateNow } from "@/shared/api/geoip"
import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic/netflow-purge-dialog"
import { KeyRoundIcon, DownloadIcon, InfoIcon } from "lucide-react"
@@ -29,6 +31,127 @@ const HOST_STEPS = [
"Проверка: wg show · ss -ulnp | grep 4739 · в этой панели — last datagram.",
]
function fmtDate(iso: string | null | undefined): string {
return iso ? new Date(iso).toLocaleString("ru-RU") : "—"
}
function GeoipSettingsSection({ backendUrl }: { backendUrl: string }) {
const [status, setStatus] = useState<GeoipStatusDto | null>(null)
const [busy, setBusy] = useState(false)
const [updating, setUpdating] = useState(false)
const [autoOn, setAutoOn] = useState(true)
const [intervalHours, setIntervalHours] = useState("168")
const load = useCallback(async () => {
const s = await getGeoipStatus(backendUrl)
setStatus(s)
setAutoOn(s.settings.enabled)
setIntervalHours(String(Math.round(s.settings.updateIntervalSec / 3600)))
}, [backendUrl])
useEffect(() => {
void load().catch((e: unknown) => {
toast.error(e instanceof Error ? e.message : "Не удалось загрузить GeoIP")
})
}, [load])
async function handleSave() {
setBusy(true)
try {
const hours = Math.min(720, Math.max(6, Number.parseInt(intervalHours, 10) || 168))
const res = await putGeoipSettings(backendUrl, {
enabled: autoOn,
updateIntervalSec: hours * 3600,
})
setStatus(res.status)
toast.success("Настройки GeoIP сохранены")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось сохранить")
} finally {
setBusy(false)
}
}
async function handleUpdateNow() {
setUpdating(true)
try {
const res = await runGeoipUpdateNow(backendUrl)
if (res.ok || res.snapshot.downloaded > 0 || res.snapshot.skippedUnchanged > 0) {
toast.success(
res.snapshot.downloaded > 0
? `Скачано баз: ${res.snapshot.downloaded} (${(res.snapshot.bytes / 1024 / 1024).toFixed(1)} МБ)`
: "Базы актуальны, скачивание не требуется",
)
} else {
toast.error(res.snapshot.errors.join("; ") || "Обновление не выполнено")
}
await load()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось обновить базы")
} finally {
setUpdating(false)
}
}
return (
<OpsPanel
title="GeoIP-базы (GeoLite2)"
description="Локальные mmdb MaxMind GeoLite2 с зеркала P3TERX: страна и ASN каждого потока при ingest — мгновенно, включая IPv6, без лимитов RIPEstat."
headerRight={
<div className="flex items-center gap-2">
<Badge variant={status?.countryLoaded ? "success" : "secondary"}>
country {status?.countryLoaded ? "ok" : "нет"}
</Badge>
<Badge variant={status?.asnLoaded ? "success" : "secondary"}>
asn {status?.asnLoaded ? "ok" : "нет"}
</Badge>
</div>
}
contentClassName="px-5 py-4 flex flex-col gap-4"
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex items-center gap-3">
<FormToggle checked={autoOn} onChange={setAutoOn} />
<span className="text-sm">Автообновление</span>
</div>
<FormField label="Интервал проверки (часов)" hint="Upstream обновляется еженедельно; минимум 6 ч">
<Input
className="font-mono"
value={intervalHours}
onChange={(e) => setIntervalHours(e.target.value)}
inputMode="numeric"
disabled={!autoOn}
/>
</FormField>
</div>
<p className="text-xs text-muted-foreground">
Сборка Country: {fmtDate(status?.settings.countryBuildAt)} · ASN: {fmtDate(status?.settings.asnBuildAt)}
{" · "}последняя проверка: {fmtDate(status?.settings.lastCheckAt)}
{status?.settings.lastError ? ` · ошибка: ${status.settings.lastError}` : ""}
</p>
<p className="text-xs text-muted-foreground">
Каталог: <span className="font-mono">{status?.dir || "storage/geoip"}</span>. До загрузки баз
и при промахе lookup страна/ASN берутся из RIPEstat, как раньше.
</p>
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={busy || updating} onClick={() => { void handleSave() }}>
Сохранить GeoIP
</Button>
<Button size="sm" variant="outline" disabled={busy || updating} onClick={() => { void handleUpdateNow() }}>
<DownloadIcon className={updating ? "size-4 animate-spin" : "size-4"} />
{updating ? "Обновление…" : "Обновить сейчас"}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Данные: MaxMind GeoLite2 (CC BY-SA 4.0), зеркало P3TERX/GeoLite.mmdb.
</p>
</OpsPanel>
)
}
function NetflowSettingsPanel({
backendUrl,
enabled,
@@ -276,6 +399,8 @@ function NetflowSettingsPanel({
</div>
</OpsPanel>
<GeoipSettingsSection backendUrl={backendUrl} />
<CodeExportSheet
open={exportOpen}
onClose={() => setExportOpen(false)}
+221
View File
@@ -0,0 +1,221 @@
"use client"
import * as React from "react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
type Locale,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString(locale?.code, { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative rounded-(--cell-radius)",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"font-medium select-none",
captionLayout === "label"
? "text-sm"
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] text-muted-foreground select-none",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
defaultClassNames.day
),
range_start: cn(
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn(
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
defaultClassNames.range_end
),
today: cn(
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon className={cn("size-4", className)} {...props} />
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: ({ ...props }) => (
<CalendarDayButton locale={locale} {...props} />
),
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
+4 -1
View File
@@ -233,7 +233,9 @@ export function permissionForPath(pathname: string): string | null {
}
if (pathname.startsWith("/bgp")) return "mm:bgp:read"
if (pathname.startsWith("/uptime")) return "mm:uptime:read"
if (pathname.startsWith("/traffic")) return "mm:traffic:read"
if (pathname.startsWith("/traffic") || pathname.startsWith("/statistics")) {
return "mm:traffic:read"
}
if (pathname.startsWith("/alerts")) return "mm:alerts:read"
if (pathname.startsWith("/backups")) return "mm:backups:read"
if (pathname.startsWith("/certificates")) return "mm:certificates:read"
@@ -257,6 +259,7 @@ export function firstAllowedPath(): string {
"/filters",
"/uptime",
"/traffic",
"/statistics",
"/alerts",
"/backups",
"/settings",
@@ -0,0 +1,46 @@
import type { FilterFieldConfig } from "@/components/reui/filters"
export const STATISTICS_FILTER_FIELDS: FilterFieldConfig[] = [
{
key: "country",
label: "Страна",
type: "text",
placeholder: "ISO, напр. US",
defaultOperator: "is",
},
{
key: "service",
label: "Сервис",
type: "text",
placeholder: "https, dns…",
defaultOperator: "is",
},
{
key: "asn",
label: "ASN",
type: "text",
placeholder: "номер ASN",
defaultOperator: "is",
},
{
key: "serverId",
label: "Сервер",
type: "text",
placeholder: "id сервера",
defaultOperator: "is",
},
{
key: "userId",
label: "Пользователь",
type: "text",
placeholder: "id пользователя",
defaultOperator: "is",
},
{
key: "iface",
label: "Интерфейс",
type: "text",
placeholder: "ether1",
defaultOperator: "is",
},
]
+14
View File
@@ -13,6 +13,20 @@ export function fmtGB(v: number): string {
return `${v.toFixed(1)} ГБ`
}
export function formatBytes(n: number): string {
if (!Number.isFinite(n) || n <= 0) return "0 Б"
if (n >= 1_000_000_000_000) return `${(n / 1_000_000_000_000).toFixed(2)} ТБ`
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)} ГБ`
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
if (n >= 1000) return `${(n / 1000).toFixed(1)} КБ`
return `${Math.round(n)} Б`
}
/** API bitrate is bits/s; fmtRate expects Мбит/с. */
export function fmtBps(bps: number): string {
return fmtRate(bps / 1_000_000)
}
export const TRAFFIC_RANGE_MINUTES: Record<string, number> = {
"5m": 5,
"15m": 15,
+14
View File
@@ -97,6 +97,19 @@ export interface BackupsRunSnapshot {
fatalError?: string
}
export interface GeoipUpdateRunSnapshot {
v: number
job: "geoip_update"
sampledAt: string
skipped?: boolean
fatalError?: string
checked: number
downloaded: number
skippedUnchanged: number
bytes: number
errors: string[]
}
export type SchedulerRunSnapshot =
| TrafficRunSnapshot
| ResourcesRunSnapshot
@@ -107,6 +120,7 @@ export type SchedulerRunSnapshot =
| InternetPathRunSnapshot
| CertificatesRenewRunSnapshot
| BackupsRunSnapshot
| GeoipUpdateRunSnapshot
| AlertEngineRunSnapshot
export interface TrafficServerSnapshot {
+4
View File
@@ -11,6 +11,7 @@ export const SCHEDULER_JOB_KEYS = [
"gre_bgp",
"certificates_renew",
"backups",
"geoip_update",
"alert_engine",
] as const
export type SchedulerJobKey = (typeof SCHEDULER_JOB_KEYS)[number]
@@ -25,6 +26,7 @@ export const SCHEDULER_JOB_LABELS: Record<string, string> = {
gre_bgp: "GRE + BGP",
certificates_renew: "Сертификаты: автообновление",
backups: "Бэкапы",
geoip_update: "GeoIP: базы GeoLite2",
alert_engine: "Оповещения",
}
@@ -43,6 +45,8 @@ export const SCHEDULER_JOB_DESCRIPTIONS: Record<string, string> = {
"Автообновление сертификатов, выпущенных через UI (ACME DNS-01 / Cloudflare). Отключается на странице «Сертификаты», если ACME ведёт RouterOS.",
backups:
"Плановые бэкапы RouterOS по расписанию со страницы «Бэкапы»; тик планировщика раз в минуту.",
geoip_update:
"Проверка и доставка GeoLite2 Country/ASN с зеркала P3TERX в backend/storage/geoip (ETag, атомарная подмена). Управление — в настройках NetFlow.",
alert_engine:
"Оценка правил по данным из PostgreSQL (сэмплы пишут джобы сбора, в т.ч. «GRE + BGP» и «Серверы: REST API»).",
}
+78 -15
View File
@@ -25,9 +25,11 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"cn": "^0.2.5",
"date-fns": "^4.4.0",
"lucide-react": "^1.11.0",
"next": "16.2.4",
"react": "19.2.4",
"react-day-picker": "^10.0.1",
"react-dom": "19.2.4",
"recharts": "^3.8.0",
"shadcn": "^4.5.0",
@@ -61,6 +63,7 @@
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.5",
"fastify-plugin": "^5.1.0",
"maxmind": "^5.0.7",
"pg": "^8.23.0",
"undici": "^8.1.0",
"zod": "^4.4.1"
@@ -893,6 +896,12 @@
}
}
},
"node_modules/@date-fns/tz": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz",
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
"license": "MIT"
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@@ -6690,6 +6699,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/dateformat": {
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
@@ -10386,6 +10405,20 @@
"node": ">= 0.4"
}
},
"node_modules/maxmind": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/maxmind/-/maxmind-5.0.7.tgz",
"integrity": "sha512-+w637dwfv01MKjkrp4sKDBTEKHLPvWLYb647QTjiz3wG/teSemqudIKNShaS6eqZ7ffxC9oZlQQgIqY0rGojog==",
"license": "MIT",
"dependencies": {
"mmdb-lib": "3.0.3",
"tiny-lru": "13.0.0"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
@@ -10531,6 +10564,16 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/mmdb-lib": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/mmdb-lib/-/mmdb-lib-3.0.3.tgz",
"integrity": "sha512-xQPoBXcNjjHiOvOraFBKtA++uNWF6aCVHL9dRKFXEov8eI3QJwtgiw3qApsonFT5SpoqsEVISUTg3HIDs2DiXw==",
"license": "MIT",
"engines": {
"node": ">=10",
"npm": ">=6"
}
},
"node_modules/mnemonist": {
"version": "0.40.4",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.4.tgz",
@@ -11848,6 +11891,32 @@
"node": ">=0.10.0"
}
},
"node_modules/react-day-picker": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.1.tgz",
"integrity": "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "^1.4.1",
"date-fns": "^4.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"@types/react": ">=16.8.0",
"react": ">=16.8.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
@@ -13299,6 +13368,15 @@
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tiny-lru": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-13.0.0.tgz",
"integrity": "sha512-xDHxKKS1FdF0Tv2P+QT7IeSEg74K/8cEDzbv3Tv6UyHHUgBOjOiQiBp818MGj66dhurQus/IBcoAbwIKtSGc6Q==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=14"
}
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -14797,21 +14875,6 @@
"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"
}
}
}
}
+2
View File
@@ -27,9 +27,11 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"cn": "^0.2.5",
"date-fns": "^4.4.0",
"lucide-react": "^1.11.0",
"next": "16.2.4",
"react": "19.2.4",
"react-day-picker": "^10.0.1",
"react-dom": "19.2.4",
"recharts": "^3.8.0",
"shadcn": "^4.5.0",
+8
View File
@@ -45,6 +45,14 @@
"./traffic-flow": {
"types": "./dist/traffic-flow.d.ts",
"default": "./dist/traffic-flow.js"
},
"./geoip": {
"types": "./dist/geoip.d.ts",
"default": "./dist/geoip.js"
},
"./statistics": {
"types": "./dist/statistics.d.ts",
"default": "./dist/statistics.js"
}
},
"dependencies": {
+51
View File
@@ -0,0 +1,51 @@
import { z } from "zod"
export const geoipSettingsDtoSchema = z.object({
enabled: z.boolean(),
updateIntervalSec: z.number().int().positive(),
lastCheckAt: z.string().nullable(),
lastSuccessAt: z.string().nullable(),
lastError: z.string().nullable(),
countryBuildAt: z.string().nullable(),
asnBuildAt: z.string().nullable(),
updatedAt: z.string(),
})
export const geoipSettingsPatchSchema = z.object({
enabled: z.boolean().optional(),
updateIntervalSec: z
.number()
.int()
.min(6 * 3600)
.max(30 * 86400)
.optional(),
})
export const geoipStatusDtoSchema = z.object({
ready: z.boolean(),
countryLoaded: z.boolean(),
asnLoaded: z.boolean(),
countryFile: z.string(),
asnFile: z.string(),
dir: z.string(),
running: z.boolean(),
settings: geoipSettingsDtoSchema,
})
export const geoipUpdateSnapshotDtoSchema = z.object({
v: z.number(),
job: z.literal("geoip_update"),
sampledAt: z.string(),
skipped: z.boolean().optional(),
fatalError: z.string().optional(),
checked: z.number().int(),
downloaded: z.number().int(),
skippedUnchanged: z.number().int(),
bytes: z.number().int().nonnegative(),
errors: z.array(z.string()),
})
export type GeoipSettingsDto = z.infer<typeof geoipSettingsDtoSchema>
export type GeoipSettingsPatch = z.infer<typeof geoipSettingsPatchSchema>
export type GeoipStatusDto = z.infer<typeof geoipStatusDtoSchema>
export type GeoipUpdateSnapshotDto = z.infer<typeof geoipUpdateSnapshotDtoSchema>
+2
View File
@@ -6,3 +6,5 @@ export * from "./backups.js"
export * from "./wireguard.js"
export * from "./users.js"
export * from "./traffic-flow.js"
export * from "./geoip.js"
export * from "./statistics.js"
+57
View File
@@ -0,0 +1,57 @@
import { z } from "zod"
export const statisticsBreakdownRowSchema = z.object({
id: z.string(),
label: z.string(),
bytes: z.number().nonnegative(),
packets: z.number().nonnegative(),
bps: z.number().nonnegative(),
percent: z.number().nonnegative(),
})
export const statisticsSeriesPointSchema = z.object({
t: z.string(),
bytes: z.number().nonnegative(),
})
export const statisticsKpisSchema = z.object({
bytes: z.number().nonnegative(),
packets: z.number().nonnegative(),
avgBps: z.number().nonnegative(),
users: z.number().int().nonnegative(),
servers: z.number().int().nonnegative(),
ifaces: z.number().int().nonnegative(),
topCountry: z.string(),
topService: z.string(),
})
export const statisticsQuerySchema = z.object({
from: z.string().min(1),
to: z.string().min(1),
serverId: z.coerce.number().int().positive().optional(),
userId: z.string().min(1).optional(),
iface: z.string().min(1).optional(),
country: z.string().min(2).max(2).optional(),
service: z.string().min(1).optional(),
asn: z.coerce.number().int().optional(),
})
export const statisticsDtoSchema = z.object({
from: z.string(),
to: z.string(),
grain: z.enum(["hour", "day"]),
kpis: statisticsKpisSchema,
series: z.array(statisticsSeriesPointSchema),
users: z.array(statisticsBreakdownRowSchema),
servers: z.array(statisticsBreakdownRowSchema),
interfaces: z.array(statisticsBreakdownRowSchema),
countries: z.array(statisticsBreakdownRowSchema),
services: z.array(statisticsBreakdownRowSchema),
asns: z.array(statisticsBreakdownRowSchema),
})
export type StatisticsBreakdownRow = z.infer<typeof statisticsBreakdownRowSchema>
export type StatisticsSeriesPoint = z.infer<typeof statisticsSeriesPointSchema>
export type StatisticsKpis = z.infer<typeof statisticsKpisSchema>
export type StatisticsQuery = z.infer<typeof statisticsQuerySchema>
export type StatisticsDto = z.infer<typeof statisticsDtoSchema>
+2
View File
@@ -244,6 +244,8 @@ export const flowPurgeDtoSchema = z.object({
minuteStats: z.number().int().nonnegative(),
minuteDims: z.number().int().nonnegative(),
dailyDims: z.number().int().nonnegative(),
hourFacts: z.number().int().nonnegative().optional(),
dailyFacts: z.number().int().nonnegative().optional(),
}),
fileBytesBefore: z.number().int().nonnegative(),
fileBytesAfter: z.number().int().nonnegative(),
+27
View File
@@ -0,0 +1,27 @@
import type {
GeoipSettingsPatch,
GeoipStatusDto,
GeoipUpdateSnapshotDto,
} from "@mmapp/contracts/geoip"
import { requestJson } from "@/shared/api/http-client"
export async function getGeoipStatus(baseUrl: string): Promise<GeoipStatusDto> {
return requestJson<GeoipStatusDto>(baseUrl, "/api/geoip")
}
export async function putGeoipSettings(
baseUrl: string,
patch: GeoipSettingsPatch,
): Promise<{ ok: boolean; status: GeoipStatusDto }> {
return requestJson(baseUrl, "/api/geoip", {
method: "PUT",
body: JSON.stringify(patch),
})
}
export async function runGeoipUpdateNow(baseUrl: string): Promise<{
ok: boolean
snapshot: GeoipUpdateSnapshotDto
}> {
return requestJson(baseUrl, "/api/geoip/update", { method: "POST" })
}
+20
View File
@@ -0,0 +1,20 @@
import type { StatisticsDto, StatisticsQuery } from "@mmapp/contracts/statistics"
import { requestJson } from "@/shared/api/http-client"
export type { StatisticsDto, StatisticsQuery }
export async function getStatistics(
baseUrl: string,
query: StatisticsQuery,
): Promise<StatisticsDto> {
const params = new URLSearchParams()
params.set("from", query.from)
params.set("to", query.to)
if (query.serverId != null) params.set("serverId", String(query.serverId))
if (query.userId) params.set("userId", query.userId)
if (query.iface) params.set("iface", query.iface)
if (query.country) params.set("country", query.country)
if (query.service) params.set("service", query.service)
if (query.asn != null) params.set("asn", String(query.asn))
return requestJson<StatisticsDto>(baseUrl, `/api/statistics?${params.toString()}`)
}