From 3687bb8fa27d4a1c778534f3cd793be240dd5999 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 10 Sep 2026 11:57:47 +0700 Subject: [PATCH] =?UTF-8?q?feat(statistics):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B8?= =?UTF-8?q?=20=D1=82=D1=80=D0=B0=D1=84=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Куб IPFIX час+день с AND-слайсами и экраном отчётности /statistics, живой /traffic не меняем. Co-authored-by: Cursor --- app/(main)/statistics/page.tsx | 427 ++++++ backend/drizzle/0005_flow_facts.sql | 39 + backend/package.json | 2 +- backend/src/db/partitions.ts | 2 + backend/src/db/schema.ts | 30 + backend/src/index.ts | 2 + backend/src/lib/permissions.test.ts | 2 +- backend/src/lib/permissions.ts | 2 +- backend/src/routes/statistics.ts | 15 + .../src/services/statistics-aggregate.test.ts | 92 ++ backend/src/services/statistics-aggregate.ts | 369 +++++ backend/src/services/traffic-flow-engine.ts | 26 +- .../src/services/traffic-flow-facts.test.ts | 71 + backend/src/services/traffic-flow-facts.ts | 285 ++++ backend/src/services/traffic-flow-ingest.ts | 4 + components/app-sidebar.tsx | 2 + components/command-palette.tsx | 3 +- .../statistics-breakdown-data-grid.tsx | 78 + components/reui/date-selector.tsx | 1332 +++++++++++++++++ components/statistics/date-selector-i18n.ts | 65 + components/statistics/period-selector.tsx | 188 +++ .../statistics/statistics-volume-chart.tsx | 105 ++ components/ui/calendar.tsx | 221 +++ lib/auth.ts | 5 +- lib/data-filters/statistics-filter-fields.ts | 46 + lib/fmt-rate.ts | 14 + package-lock.json | 44 + package.json | 2 + packages/contracts/package.json | 4 + packages/contracts/src/index.ts | 1 + packages/contracts/src/statistics.ts | 57 + packages/contracts/src/traffic-flow.ts | 2 + shared/api/statistics.ts | 20 + 33 files changed, 3551 insertions(+), 6 deletions(-) create mode 100644 app/(main)/statistics/page.tsx create mode 100644 backend/drizzle/0005_flow_facts.sql create mode 100644 backend/src/routes/statistics.ts create mode 100644 backend/src/services/statistics-aggregate.test.ts create mode 100644 backend/src/services/statistics-aggregate.ts create mode 100644 backend/src/services/traffic-flow-facts.test.ts create mode 100644 backend/src/services/traffic-flow-facts.ts create mode 100644 components/data-grids/statistics-breakdown-data-grid.tsx create mode 100644 components/reui/date-selector.tsx create mode 100644 components/statistics/date-selector-i18n.ts create mode 100644 components/statistics/period-selector.tsx create mode 100644 components/statistics/statistics-volume-chart.tsx create mode 100644 components/ui/calendar.tsx create mode 100644 lib/data-filters/statistics-filter-fields.ts create mode 100644 packages/contracts/src/statistics.ts create mode 100644 shared/api/statistics.ts diff --git a/app/(main)/statistics/page.tsx b/app/(main)/statistics/page.tsx new file mode 100644 index 0000000..7e503aa --- /dev/null +++ b/app/(main)/statistics/page.tsx @@ -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(() => readTab(searchParams)) + + const [data, setData] = useState(EMPTY) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const replaceParams = useCallback( + (patch: Record) => { + 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 ( +
+ } + /> + +
+ {!isLive ? ( + + Живые данные выключены + + Куб статистики строится из IPFIX. Переключитесь на живой источник, чтобы увидеть отчёт. + + + ) : null} + + {error ? ( + + Ошибка загрузки + {error} + + ) : null} + + , + iconClassName: "text-muted-foreground", + }, + { + id: "packets", + label: "Пакеты", + value: kpis.packets.toLocaleString("ru-RU"), + hint: kpis.topService ? `топ: ${kpis.topService}` : undefined, + icon: , + iconClassName: "text-muted-foreground", + }, + { + id: "bps", + label: "Средний bitrate", + value: fmtBps(kpis.avgBps), + icon: , + iconClassName: "text-muted-foreground", + }, + { + id: "users", + label: "Пользователи", + value: String(kpis.users), + icon: , + iconClassName: "text-muted-foreground", + }, + { + id: "servers", + label: "Серверы", + value: String(kpis.servers), + hint: kpis.ifaces ? `${kpis.ifaces} iface` : undefined, + icon: , + iconClassName: "text-muted-foreground", + }, + ]} + /> + + + + + setSlices(filtersToSlices(next))} + filterFields={STATISTICS_FILTER_FIELDS} + countLabel={`${rows.length} строк`} + /> + {SLICE_KEYS.some((k) => slices[k]) ? ( +
+ {slices.country ? ( + страна {slices.country} + ) : null} + {slices.service ? ( + сервис {slices.service} + ) : null} + {slices.asn ? ( + ASN {slices.asn} + ) : null} + {slices.serverId ? ( + сервер {slices.serverId} + ) : null} + {slices.userId ? ( + пользователь {slices.userId} + ) : null} + {slices.iface ? ( + iface {slices.iface} + ) : null} +
+ ) : null} + { + const next = String(v) as StatisticsSliceKind + setTab(next) + replaceParams({ tab: next }) + }} + className="gap-0" + > +
+ + + Пользователи + + + Серверы + + + Интерфейсы + + + Страны + + Сервисы + ASN + +
+ {TABS.map((t) => ( + + {emptyCube ? ( + + ) : ( + handleRowClick(t.id, row)} + isLoading={loading} + /> + )} + + ))} +
+
+
+
+ ) +} diff --git a/backend/drizzle/0005_flow_facts.sql b/backend/drizzle/0005_flow_facts.sql new file mode 100644 index 0000000..d352fe2 --- /dev/null +++ b/backend/drizzle/0005_flow_facts.sql @@ -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 +); diff --git a/backend/package.json b/backend/package.json index a3b7a78..960354c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,7 +15,7 @@ "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 && tsx src/services/traffic-flow-geoip.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", diff --git a/backend/src/db/partitions.ts b/backend/src/db/partitions.ts index 4f85160..0333661 100644 --- a/backend/src/db/partitions.ts +++ b/backend/src/db/partitions.ts @@ -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 }, diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index f20e88b..4e5b1d7 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -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(), diff --git a/backend/src/index.ts b/backend/src/index.ts index c0dd3f6..ce0263e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -31,6 +31,7 @@ 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" @@ -135,6 +136,7 @@ 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() diff --git a/backend/src/lib/permissions.test.ts b/backend/src/lib/permissions.test.ts index dc70981..77ccee9 100644 --- a/backend/src/lib/permissions.test.ts +++ b/backend/src/lib/permissions.test.ts @@ -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( diff --git a/backend/src/lib/permissions.ts b/backend/src/lib/permissions.ts index 9511cd2..63ffe0d 100644 --- a/backend/src/lib/permissions.ts +++ b/backend/src/lib/permissions.ts @@ -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", }, { diff --git a/backend/src/routes/statistics.ts b/backend/src/routes/statistics.ts new file mode 100644 index 0000000..cb541fd --- /dev/null +++ b/backend/src/routes/statistics.ts @@ -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 diff --git a/backend/src/services/statistics-aggregate.test.ts b/backend/src/services/statistics-aggregate.test.ts new file mode 100644 index 0000000..17cf479 --- /dev/null +++ b/backend/src/services/statistics-aggregate.test.ts @@ -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") diff --git a/backend/src/services/statistics-aggregate.ts b/backend/src/services/statistics-aggregate.ts new file mode 100644 index 0000000..3783dd9 --- /dev/null +++ b/backend/src/services/statistics-aggregate.ts @@ -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 | 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 { + 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() + 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() + 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() + 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, + } +} diff --git a/backend/src/services/traffic-flow-engine.ts b/backend/src/services/traffic-flow-engine.ts index 19a9259..8e0b437 100644 --- a/backend/src/services/traffic-flow-engine.ts +++ b/backend/src/services/traffic-flow-engine.ts @@ -11,6 +11,13 @@ 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 @@ -328,6 +335,7 @@ 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) @@ -349,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) @@ -827,7 +845,7 @@ export async function flushPending(opts?: { force?: boolean }): Promise { 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) @@ -885,6 +903,11 @@ export async function flushPending(opts?: { force?: boolean }): Promise { } catch { /* rollup best-effort */ } + try { + await flushFlowFacts() + } catch { + /* statistics cube best-effort */ + } try { await pruneStored() } catch { @@ -916,6 +939,7 @@ export function resetEngineForTests(): void { rings.clear() minuteRollup.clear() minuteDims.clear() + resetFactsForTests() packetsReceived = 0 lastExporterIp = null lastError = "" diff --git a/backend/src/services/traffic-flow-facts.test.ts b/backend/src/services/traffic-flow-facts.test.ts new file mode 100644 index 0000000..4d2972d --- /dev/null +++ b/backend/src/services/traffic-flow-facts.test.ts @@ -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") diff --git a/backend/src/services/traffic-flow-facts.ts b/backend/src/services/traffic-flow-facts.ts new file mode 100644 index 0000000..ff0f015 --- /dev/null +++ b/backend/src/services/traffic-flow-facts.ts @@ -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() +const ensuredParts = new Set() + +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, 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() + 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() + for (const list of groups.values()) { + const byAsn = new Map() + 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() + 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 { + 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 { + 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 +} diff --git a/backend/src/services/traffic-flow-ingest.ts b/backend/src/services/traffic-flow-ingest.ts index ebf276c..71e7ed6 100644 --- a/backend/src/services/traffic-flow-ingest.ts +++ b/backend/src/services/traffic-flow-ingest.ts @@ -467,11 +467,15 @@ export async function purgeTrafficFlowStore(): Promise { 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 { diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index af16550..791b906 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -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: }, { title: "Трафик", url: "/traffic", icon: }, + { title: "Статистика", url: "/statistics", icon: }, { title: "Карта сети", url: "/network-map", icon: }, { title: "Мониторинг", url: "/uptime", icon: }, ], diff --git a/components/command-palette.tsx b/components/command-palette.tsx index 961da9c..69d5c76 100644 --- a/components/command-palette.tsx +++ b/components/command-palette.tsx @@ -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: , keywords: ["главная","home","overview"] }, { id: "traffic", title: "Трафик", group: "Обзор", url: "/traffic", icon: , keywords: ["bandwidth","traffic","клиенты","интерфейсы"] }, + { id: "statistics", title: "Статистика", group: "Обзор", url: "/statistics", icon: , keywords: ["stats","отчёт","куб","страны","asn","ipfix"] }, { id: "network-map", title: "Карта сети", group: "Обзор", url: "/network-map", icon: , keywords: ["topology","топология","map"] }, { id: "uptime", title: "Мониторинг / Uptime", group: "Обзор", url: "/uptime", icon: , keywords: ["ping","uptime","мониторинг","проверка"] }, // Данные diff --git a/components/data-grids/statistics-breakdown-data-grid.tsx b/components/data-grids/statistics-breakdown-data-grid.tsx new file mode 100644 index 0000000..c037e67 --- /dev/null +++ b/components/data-grids/statistics-breakdown-data-grid.tsx @@ -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[] = [ + { + id: "label", + header: "Имя", + accessorKey: "label", + cell: (row) => ( + + {kind === "countries" && row.id !== "XX" ? : null} + {row.label || row.id} + {selectedId === row.id ? ( + + слайс + + ) : null} + + ), + }, + { + id: "bytes", + header: "Байты", + accessorKey: "bytes", + cell: (row) => {formatBytes(row.bytes)}, + }, + { + id: "packets", + header: "Пакеты", + accessorKey: "packets", + cell: (row) => {row.packets.toLocaleString("ru-RU")}, + }, + { + id: "bps", + header: "Средний bitrate", + accessorKey: "bps", + cell: (row) => {fmtBps(row.bps)}, + }, + { + id: "percent", + header: "Доля", + accessorKey: "percent", + cell: (row) => {row.percent.toFixed(1)}%, + }, + ] + + return ( + + ) +} diff --git a/components/reui/date-selector.tsx b/components/reui/date-selector.tsx new file mode 100644 index 0000000..57bfc13 --- /dev/null +++ b/components/reui/date-selector.tsx @@ -0,0 +1,1332 @@ +"use client" + +import { + type ChangeEvent, + type ComponentProps, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react" +import { + addMonths, + format, + isBefore, + isSameMonth, + parse, + subMonths, +} from "date-fns" +import { DayButton } from "react-day-picker" +import type { DateRange } from "react-day-picker" + +import { useIsMobile } from "@/hooks/use-mobile" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Calendar, CalendarDayButton } from "@/components/ui/calendar" +import { Input } from "@/components/ui/input" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { CornerUpLeftIcon, CornerUpRightIcon, ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react" + +export interface DateSelectorI18nConfig { + // Labels + selectDate: string + apply: string + cancel: string + clear: string + today: string + // Filter types + filterTypes: { + is: string + before: string + after: string + between: string + } + // Period types + periodTypes: { + day: string + month: string + quarter: string + halfYear: string + year: string + } + // Months + months: string[] + monthsShort: string[] + // Quarters + quarters: string[] + // Half years + halfYears: string[] + // Weekdays + weekdays: string[] + weekdaysShort: string[] + // Placeholders + placeholder: string + rangePlaceholder: string +} + +export const DEFAULT_DATE_SELECTOR_I18N: DateSelectorI18nConfig = { + selectDate: "Select date", + apply: "Apply", + cancel: "Cancel", + clear: "Clear", + today: "Today", + filterTypes: { + is: "is", + before: "before", + after: "after", + between: "between", + }, + periodTypes: { + day: "Day", + month: "Month", + quarter: "Quarter", + halfYear: "Half-year", + year: "Year", + }, + months: [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ], + monthsShort: [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ], + quarters: ["Q1", "Q2", "Q3", "Q4"], + halfYears: ["H1", "H2"], + weekdays: [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ], + weekdaysShort: ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"], + placeholder: "Select date...", + rangePlaceholder: "Select date range...", +} + +export type DateSelectorPeriodType = + | "day" + | "month" + | "quarter" + | "half-year" + | "year" +export type DateSelectorFilterType = "is" | "before" | "after" | "between" + +export interface DateSelectorValue { + period: DateSelectorPeriodType + operator: DateSelectorFilterType + startDate?: Date + endDate?: Date + year?: number + month?: number + quarter?: number + halfYear?: number + rangeStart?: { year: number; value: number } + rangeEnd?: { year: number; value: number } +} + +export interface DateSelectorContextValue { + i18n: DateSelectorI18nConfig + variant: "outline" | "default" + size: "sm" | "default" | "lg" +} + +const DateSelectorContext = createContext({ + i18n: DEFAULT_DATE_SELECTOR_I18N, + variant: "outline", + size: "default", +}) + +export const useDateSelectorContext = () => useContext(DateSelectorContext) + +export function formatDateValue( + value: DateSelectorValue, + i18n: DateSelectorI18nConfig = DEFAULT_DATE_SELECTOR_I18N, + dayDateFormat: string = "MM/dd/yyyy" +): string { + const { + period, + startDate, + endDate, + year, + month, + quarter, + halfYear, + rangeStart, + rangeEnd, + } = value + + if (period === "day") { + if (startDate && endDate) { + return `${format(startDate, dayDateFormat)} - ${format(endDate, dayDateFormat)}` + } + if (startDate) { + return format(startDate, dayDateFormat) + } + return "" + } + + if (period === "month") { + if (rangeStart && rangeEnd) { + return `${i18n.monthsShort[rangeStart.value]} ${rangeStart.year} - ${i18n.monthsShort[rangeEnd.value]} ${rangeEnd.year}` + } + if (year !== undefined && month !== undefined) { + return `${i18n.monthsShort[month]} ${year}` + } + return "" + } + + if (period === "quarter") { + if (rangeStart && rangeEnd) { + return `${i18n.quarters[rangeStart.value]} ${rangeStart.year} - ${i18n.quarters[rangeEnd.value]} ${rangeEnd.year}` + } + if (year !== undefined && quarter !== undefined) { + return `${i18n.quarters[quarter]} ${year}` + } + return "" + } + + if (period === "half-year") { + if (rangeStart && rangeEnd) { + return `${i18n.halfYears[rangeStart.value]} ${rangeStart.year} - ${i18n.halfYears[rangeEnd.value]} ${rangeEnd.year}` + } + if (year !== undefined && halfYear !== undefined) { + return `${i18n.halfYears[halfYear]} ${year}` + } + return "" + } + + if (period === "year") { + if (rangeStart && rangeEnd) { + return `${rangeStart.year} - ${rangeEnd.year}` + } + if (year !== undefined) { + return `${year}` + } + return "" + } + + return "" +} + +interface UseDateSelectorOptions { + value?: DateSelectorValue + onChange?: (value: DateSelectorValue) => void + defaultPeriodType?: DateSelectorPeriodType + defaultFilterType?: DateSelectorFilterType + presetMode?: DateSelectorFilterType + allowRange?: boolean + yearRange?: number + baseYear?: number + minYear?: number + maxYear?: number + periodTypes?: DateSelectorPeriodType[] +} + +export function useDateSelector({ + value, + onChange, + defaultPeriodType = "day", + defaultFilterType = "is", + presetMode, + allowRange = true, + yearRange = 11, + baseYear, + minYear, + maxYear, + periodTypes, +}: UseDateSelectorOptions) { + const currentYear = baseYear ?? new Date().getFullYear() + + const validDefaultPeriodType = useMemo(() => { + if (!periodTypes || periodTypes.length === 0) return defaultPeriodType + if (periodTypes.includes(defaultPeriodType)) return defaultPeriodType + return periodTypes[0] + }, [periodTypes, defaultPeriodType]) + + // Use presetMode if provided, otherwise use value or default + const effectiveFilterType = presetMode ?? value?.operator ?? defaultFilterType + + const [periodType, setPeriodType] = useState( + value?.period || validDefaultPeriodType + ) + const [filterType, setFilterType] = + useState(effectiveFilterType) + const [selectedDate, setSelectedDate] = useState( + value?.startDate + ) + const [selectedEndDate, setSelectedEndDate] = useState( + value?.endDate + ) + const [calendarMonth, setCalendarMonth] = useState( + value?.startDate || new Date() + ) + const [selectedYear, setSelectedYear] = useState( + value?.year + ) + const [selectedMonth, setSelectedMonth] = useState( + value?.month + ) + const [selectedQuarter, setSelectedQuarter] = useState( + value?.quarter + ) + const [selectedHalfYear, setSelectedHalfYear] = useState( + value?.halfYear + ) + const [rangeStart, setRangeStart] = useState< + { year: number; value: number } | undefined + >(value?.rangeStart) + const [rangeEnd, setRangeEnd] = useState< + { year: number; value: number } | undefined + >(value?.rangeEnd) + const [hoverDate, setHoverDate] = useState() + + const years = useMemo(() => { + if (minYear !== undefined && maxYear !== undefined) { + return Array.from( + { length: maxYear - minYear + 1 }, + (_, i) => minYear + i + ) + } + return Array.from( + { length: yearRange }, + (_, i) => currentYear - Math.floor(yearRange / 2) + i + ) + }, [currentYear, yearRange, minYear, maxYear]) + + const currentValue = useMemo( + () => ({ + period: periodType, + operator: presetMode ?? filterType, + startDate: selectedDate, + endDate: selectedEndDate, + year: selectedYear, + month: selectedMonth, + quarter: selectedQuarter, + halfYear: selectedHalfYear, + rangeStart, + rangeEnd, + }), + [ + periodType, + presetMode, + filterType, + selectedDate, + selectedEndDate, + selectedYear, + selectedMonth, + selectedQuarter, + selectedHalfYear, + rangeStart, + rangeEnd, + ] + ) + + const clearSelection = useCallback(() => { + setSelectedDate(undefined) + setSelectedEndDate(undefined) + setSelectedYear(undefined) + setSelectedMonth(undefined) + setSelectedQuarter(undefined) + setSelectedHalfYear(undefined) + setRangeStart(undefined) + setRangeEnd(undefined) + }, []) + + const handleDayClick = useCallback( + (day: Date) => { + if (filterType === "between" && allowRange) { + if (!selectedDate || (selectedDate && selectedEndDate)) { + setSelectedDate(day) + setSelectedEndDate(undefined) + } else { + if (isBefore(day, selectedDate)) { + setSelectedEndDate(selectedDate) + setSelectedDate(day) + } else { + setSelectedEndDate(day) + } + } + } else { + setSelectedDate(day) + setSelectedEndDate(undefined) + } + }, + [filterType, allowRange, selectedDate, selectedEndDate] + ) + + const handlePeriodSelect = useCallback( + (year: number, value: number) => { + if (filterType === "between" && allowRange) { + if (!rangeStart || (rangeStart && rangeEnd)) { + setRangeStart({ year, value }) + setRangeEnd(undefined) + setSelectedYear(year) + if (periodType === "month") setSelectedMonth(value) + if (periodType === "quarter") setSelectedQuarter(value) + if (periodType === "half-year") setSelectedHalfYear(value) + } else { + const startKey = rangeStart.year * 100 + rangeStart.value + const endKey = year * 100 + value + if (endKey < startKey) { + setRangeEnd(rangeStart) + setRangeStart({ year, value }) + } else { + setRangeEnd({ year, value }) + } + } + } else { + setSelectedYear(year) + if (periodType === "month") setSelectedMonth(value) + if (periodType === "quarter") setSelectedQuarter(value) + if (periodType === "half-year") setSelectedHalfYear(value) + setRangeStart(undefined) + setRangeEnd(undefined) + } + }, + [filterType, allowRange, rangeStart, rangeEnd, periodType] + ) + + const handleYearSelect = useCallback( + (year: number) => { + if (filterType === "between" && allowRange) { + if (!rangeStart || (rangeStart && rangeEnd)) { + setRangeStart({ year, value: 0 }) + setRangeEnd(undefined) + setSelectedYear(year) + } else { + if (year < rangeStart.year) { + setRangeEnd(rangeStart) + setRangeStart({ year, value: 0 }) + } else { + setRangeEnd({ year, value: 0 }) + } + } + } else { + setSelectedYear(year) + setRangeStart(undefined) + setRangeEnd(undefined) + } + }, + [filterType, allowRange, rangeStart, rangeEnd] + ) + + const handlePeriodTypeChange = useCallback( + (type: DateSelectorPeriodType) => { + setPeriodType(type) + clearSelection() + }, + [clearSelection] + ) + + const handleFilterTypeChange = useCallback( + (type: DateSelectorFilterType) => { + // Don't allow changes if presetMode is set + if (presetMode !== undefined) return + setFilterType(type) + clearSelection() + }, + [clearSelection, presetMode] + ) + + const isInRange = useCallback( + (year: number, value: number) => { + if (!rangeStart || !rangeEnd) return false + const key = year * 100 + value + const startKey = rangeStart.year * 100 + rangeStart.value + const endKey = rangeEnd.year * 100 + rangeEnd.value + return key >= startKey && key <= endKey + }, + [rangeStart, rangeEnd] + ) + + const isYearInRange = useCallback( + (year: number) => { + if (!rangeStart || !rangeEnd) return false + return year >= rangeStart.year && year <= rangeEnd.year + }, + [rangeStart, rangeEnd] + ) + + useEffect(() => { + if (value) { + setPeriodType(value.period || validDefaultPeriodType) + // Use presetMode if provided, otherwise use value's operator or default + const newFilterType = presetMode ?? value.operator ?? defaultFilterType + setFilterType(newFilterType) + setSelectedDate(value.startDate) + setSelectedEndDate(value.endDate) + setSelectedYear(value.year) + setSelectedMonth(value.month) + setSelectedQuarter(value.quarter) + setSelectedHalfYear(value.halfYear) + setRangeStart(value.rangeStart) + setRangeEnd(value.rangeEnd) + } + }, [value, validDefaultPeriodType, defaultFilterType, presetMode]) + + // Sync filterType when presetMode changes + useEffect(() => { + if (presetMode !== undefined) { + setFilterType(presetMode) + } + }, [presetMode]) + + useEffect(() => { + onChange?.(currentValue) + }, [currentValue, onChange]) + + return { + // State + periodType, + filterType, + selectedDate, + selectedEndDate, + calendarMonth, + selectedYear, + selectedMonth, + selectedQuarter, + selectedHalfYear, + rangeStart, + rangeEnd, + hoverDate, + years, + currentValue, + allowRange, + + // Setters + setPeriodType: handlePeriodTypeChange, + setFilterType: handleFilterTypeChange, + setSelectedDate, + setSelectedEndDate, + setCalendarMonth, + setHoverDate, + + // Actions + clearSelection, + handleDayClick, + handlePeriodSelect, + handleYearSelect, + isInRange, + isYearInRange, + } +} + +interface DateSelectorFilterToggleProps { + value: DateSelectorFilterType + onChange: (value: DateSelectorFilterType) => void + showBetween?: boolean + showIs?: boolean + presetMode?: DateSelectorFilterType + className?: string +} + +function DateSelectorFilterToggle({ + value, + onChange, + showBetween = true, + showIs = true, + presetMode, + className, +}: DateSelectorFilterToggleProps) { + const { i18n } = useDateSelectorContext() + const isDisabled = presetMode !== undefined + + return ( + { + if (!isDisabled && newValue) { + onChange(newValue as DateSelectorFilterType) + } + }} + className={className} + > + + {showIs && ( + + {i18n.filterTypes.is} + + )} + + {i18n.filterTypes.before} + + + {i18n.filterTypes.after} + + {showBetween && ( + + {i18n.filterTypes.between} + + )} + + + ) +} + +interface DateSelectorDateSelectorPeriodTabsProps { + value: DateSelectorPeriodType + onChange: (value: DateSelectorPeriodType) => void + periodTypes?: DateSelectorPeriodType[] + className?: string + calendarMonth?: Date + onMonthChange?: (date: Date) => void + showNavigationButtons?: boolean +} + +function DateSelectorPeriodTabs({ + value, + onChange, + periodTypes, + className, + calendarMonth, + onMonthChange, + showNavigationButtons = false, +}: DateSelectorDateSelectorPeriodTabsProps) { + const { i18n } = useDateSelectorContext() + + const tabs: { value: DateSelectorPeriodType; label: string }[] = [ + { value: "day", label: i18n.periodTypes.day }, + { value: "month", label: i18n.periodTypes.month }, + { value: "quarter", label: i18n.periodTypes.quarter }, + { value: "half-year", label: i18n.periodTypes.halfYear }, + { value: "year", label: i18n.periodTypes.year }, + ] + + const filteredTabs = periodTypes + ? tabs.filter((tab) => periodTypes.includes(tab.value)) + : tabs + + return ( +
+ { + if (newValue) { + onChange(newValue as DateSelectorPeriodType) + } + }} + > + + {filteredTabs.map((tab) => ( + + {tab.label} + + ))} + + + {showNavigationButtons && + value === "day" && + calendarMonth && + onMonthChange && ( +
+ {(() => { + const today = new Date() + const isCurrentMonth = isSameMonth(calendarMonth, today) + + // Only show today button if not on current month + if (isCurrentMonth) { + return null + } + + // Determine direction based on whether calendarMonth is in future or past + const isFuture = calendarMonth > today + + return ( + + ) + })()} + + +
+ )} +
+ ) +} + +interface DateSelectorDayPickerProps { + currentMonth: Date + selectedDate?: Date + selectedEndDate?: Date + onDayClick: (day: Date) => void + isRange: boolean + onDayHover?: (day: Date | undefined) => void + hoverDate?: Date + showTwoMonths?: boolean + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 + className?: string +} + +function DateSelectorDayPicker({ + currentMonth, + selectedDate, + selectedEndDate, + onDayClick, + isRange, + onDayHover, + hoverDate, + showTwoMonths = true, + weekStartsOn, + className, +}: DateSelectorDayPickerProps) { + const { i18n } = useDateSelectorContext() + const isMobile = useIsMobile() + + // Convert to react-day-picker format + const selected: Date | DateRange | undefined = isRange + ? selectedDate && selectedEndDate + ? { from: selectedDate, to: selectedEndDate } + : selectedDate + ? { from: selectedDate, to: hoverDate || selectedDate } + : undefined + : selectedDate + + const handleSelect = (date: Date | DateRange | undefined) => { + if (!date) { + return + } + + if (isRange && "from" in date) { + // For range mode + if (date.from && !date.to) { + // First click - set start date + onDayClick(date.from) + } else if (date.from && date.to) { + // Range selected - set end date + onDayClick(date.to) + } + } else if (!isRange && date instanceof Date) { + onDayClick(date) + } + } + + // Create custom DayButton component with hover support + const CustomDayButton = useCallback( + (props: ComponentProps) => { + return ( + { + if (isRange && onDayHover && props.day) { + onDayHover(props.day.date) + } + }} + onMouseLeave={() => { + if (isRange && onDayHover) { + onDayHover(undefined) + } + }} + /> + ) + }, + [isRange, onDayHover] + ) + + // Create custom formatters for i18n + const formatters = { + formatWeekdayName: (date: Date) => { + const dayIndex = date.getDay() + return i18n.weekdaysShort[dayIndex] || i18n.weekdays[dayIndex] + }, + formatMonthCaption: (date: Date) => { + const monthIndex = date.getMonth() + const year = date.getFullYear() + return `${i18n.months[monthIndex]} ${year}` + }, + } + + return ( +
+ {isRange ? ( + void} + numberOfMonths={isMobile ? 1 : showTwoMonths ? 2 : 1} + showOutsideDays={true} + weekStartsOn={weekStartsOn} + formatters={formatters} + className="w-full shrink-0 p-0" + classNames={{ + months: "flex flex-wrap items-start justify-between gap-5 w-full", + month: "flex flex-col items-center min-w-0 flex-1", + nav: "hidden", + }} + components={{ + DayButton: CustomDayButton, + }} + /> + ) : ( + void} + numberOfMonths={isMobile ? 1 : showTwoMonths ? 2 : 1} + showOutsideDays={true} + weekStartsOn={weekStartsOn} + formatters={formatters} + className="w-full shrink-0 p-0" + classNames={{ + months: "flex flex-wrap items-start justify-between gap-5 w-full", + month: "flex flex-col items-center min-w-0 flex-1", + nav: "hidden", + }} + components={{ + DayButton: CustomDayButton, + }} + /> + )} +
+ ) +} + +interface DateSelectorDateSelectorPeriodGridProps { + years: number[] + items: string[] + selectedYear?: number + selectedValue?: number + rangeStart?: { year: number; value: number } + rangeEnd?: { year: number; value: number } + isInRange: (year: number, value: number) => boolean + onSelect: (year: number, value: number) => void + columns: number + className?: string +} + +function DateSelectorPeriodGrid({ + years, + items, + selectedYear, + selectedValue, + rangeStart, + rangeEnd, + isInRange, + onSelect, + columns, + className, +}: DateSelectorDateSelectorPeriodGridProps) { + return ( +
+ {years.map((year) => ( +
+
+ {year} +
+
+ {items.map((item, index) => { + const isSelected = + selectedYear === year && selectedValue === index + const isRangeStart = + rangeStart?.year === year && rangeStart?.value === index + const isRangeEnd = + rangeEnd?.year === year && rangeEnd?.value === index + const inRange = isInRange(year, index) + + return ( + + ) + })} +
+
+ ))} +
+ ) +} + +interface DateSelectorYearListProps { + years: number[] + selectedYear?: number + rangeStart?: { year: number; value: number } + rangeEnd?: { year: number; value: number } + isYearInRange: (year: number) => boolean + onSelect: (year: number) => void + className?: string +} + +function DateSelectorYearList({ + years, + selectedYear, + rangeStart, + rangeEnd, + isYearInRange, + onSelect, + className, +}: DateSelectorYearListProps) { + return ( +
+ {years.map((year) => { + const isSelected = selectedYear === year && !rangeStart && !rangeEnd + const isRangeStart = rangeStart?.year === year + const isRangeEnd = rangeEnd?.year === year + const inRange = isYearInRange(year) + + return ( + + ) + })} +
+ ) +} + +export interface DateSelectorProps { + value?: DateSelectorValue + onChange?: (value: DateSelectorValue) => void + allowRange?: boolean + periodTypes?: DateSelectorPeriodType[] + defaultPeriodType?: DateSelectorPeriodType + defaultFilterType?: DateSelectorFilterType + presetMode?: DateSelectorFilterType + showInput?: boolean + showTwoMonths?: boolean + label?: string + className?: string + yearRange?: number + baseYear?: number + minYear?: number + maxYear?: number + i18n?: Partial + inputHint?: string + dayDateFormat?: string + dayDateFormats?: string[] + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 +} + +export function DateSelector({ + value, + onChange, + allowRange = true, + periodTypes, + defaultPeriodType = "day", + defaultFilterType = "is", + presetMode, + showInput = true, + showTwoMonths = true, + label, + className, + yearRange = 10, + baseYear, + minYear = 2015, + maxYear = 2026, + i18n: i18nOverride, + inputHint, + dayDateFormat = "MM/dd/yyyy", + dayDateFormats, + weekStartsOn, +}: DateSelectorProps) { + const mergedI18n = useMemo( + () => ({ ...DEFAULT_DATE_SELECTOR_I18N, ...i18nOverride }), + [i18nOverride] + ) + + const selector = useDateSelector({ + value, + onChange, + defaultPeriodType, + defaultFilterType, + presetMode, + allowRange, + yearRange, + baseYear, + minYear, + maxYear, + periodTypes, + }) + + const { + periodType, + filterType, + selectedDate, + selectedEndDate, + calendarMonth, + selectedYear, + selectedMonth, + selectedQuarter, + selectedHalfYear, + rangeStart, + rangeEnd, + hoverDate, + years, + currentValue, + setPeriodType, + setFilterType, + setCalendarMonth, + setHoverDate, + clearSelection, + handleDayClick, + handlePeriodSelect, + handleYearSelect, + isInRange, + isYearInRange, + } = selector + + const displayValue = formatDateValue(currentValue, mergedI18n, dayDateFormat) + const [inputValue, setInputValue] = useState(displayValue) + const [isInputFocused, setIsInputFocused] = useState(false) + + // Sync input value when displayValue changes (but not when user is typing) + useEffect(() => { + if (!isInputFocused) { + setInputValue(displayValue) + } + }, [displayValue, isInputFocused]) + + // Compute date formats for parsing + const dateFormats = useMemo(() => { + if (dayDateFormats && dayDateFormats.length > 0) { + // Use provided formats, with dayDateFormat first if not already included + const formats = [...dayDateFormats] + if (!formats.includes(dayDateFormat)) { + formats.unshift(dayDateFormat) + } + return formats + } + // Default formats: use dayDateFormat first, then common alternatives + const defaultFormats = [ + dayDateFormat, + "dd/MM/yyyy", + "yyyy-MM-dd", + "MM-dd-yyyy", + "dd-MM-yyyy", + ] + // Remove duplicates while preserving order + return Array.from(new Set(defaultFormats)) + }, [dayDateFormat, dayDateFormats]) + + // Parse input text to DateSelectorValue + const parseInputValue = useCallback( + (text: string): DateSelectorValue | null => { + if (!text.trim()) return null + + const trimmed = text.trim() + + // Try parsing as year (e.g., "2025") + const yearMatch = trimmed.match(/^\d{4}$/) + if (yearMatch) { + const year = parseInt(yearMatch[0]) + if (year >= 1900 && year <= 2100) { + return { + period: "year", + operator: presetMode ?? filterType, + year, + } + } + } + + // Try parsing as quarter (e.g., "Q4", "Q1 2025") + const quarterMatch = trimmed.match(/^Q([1-4])(?:\s+(\d{4}))?$/i) + if (quarterMatch) { + const quarter = parseInt(quarterMatch[1]) - 1 + const year = quarterMatch[2] + ? parseInt(quarterMatch[2]) + : new Date().getFullYear() + if (year >= 1900 && year <= 2100) { + return { + period: "quarter", + operator: presetMode ?? filterType, + year, + quarter, + } + } + } + + // Try parsing as date using computed formats + for (const dateFormat of dateFormats) { + try { + const parsed = parse(trimmed, dateFormat, new Date()) + if (!isNaN(parsed.getTime())) { + return { + period: "day", + operator: presetMode ?? filterType, + startDate: parsed, + } + } + } catch { + // Continue to next format + } + } + + return null + }, + [filterType, presetMode, dateFormats] + ) + + const handleInputChange = useCallback( + (e: ChangeEvent) => { + const newValue = e.target.value + setInputValue(newValue) + + // Try to parse the input + const parsed = parseInputValue(newValue) + if (parsed) { + onChange?.(parsed) + } + }, + [onChange, parseInputValue] + ) + + const handleInputBlur = useCallback(() => { + setIsInputFocused(false) + // Reset to display value if parsing failed + if (!parseInputValue(inputValue)) { + setInputValue(displayValue) + } + }, [inputValue, displayValue, parseInputValue]) + + return ( + +
+
+ {label && ( +

+ {label} +

+ )} + +
+ {showInput && ( +
+ setIsInputFocused(true)} + onBlur={handleInputBlur} + onChange={handleInputChange} + /> + {(inputHint ? inputValue : displayValue) && ( + + )} +
+ )} + + + {periodType === "day" ? ( +
+ +
+ ) : ( +
+ + {periodType === "month" && ( + + )} + + {periodType === "quarter" && ( + + )} + + {periodType === "half-year" && ( + + )} + + {periodType === "year" && ( + + )} + +
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/components/statistics/date-selector-i18n.ts b/components/statistics/date-selector-i18n.ts new file mode 100644 index 0000000..6bf801f --- /dev/null +++ b/components/statistics/date-selector-i18n.ts @@ -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: "Выберите период…", +} diff --git a/components/statistics/period-selector.tsx b/components/statistics/period-selector.tsx new file mode 100644 index 0000000..4a54f86 --- /dev/null +++ b/components/statistics/period-selector.tsx @@ -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 ( +
+ {PRESETS.map((p) => ( + + ))} + + + } + > + + {formatRangeLabel(range)} + + + + + +
+ ) +} diff --git a/components/statistics/statistics-volume-chart.tsx b/components/statistics/statistics-volume-chart.tsx new file mode 100644 index 0000000..7ad75b4 --- /dev/null +++ b/components/statistics/statistics-volume-chart.tsx @@ -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 ( +
+
{row.label}
+
{formatBytes(row.bytes)}
+
+ ) +} + +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 ( + + +
+

Объём по времени

+ + {grain === "hour" ? "по часам" : "по суткам"} + +
+ {data.length === 0 ? ( +

Нет данных за выбранный период

+ ) : ( + + + + + formatBytes(Number(v))} + tickMargin={8} + width={72} + /> + } /> + + + + )} +
+ + ) +} diff --git a/components/ui/calendar.tsx b/components/ui/calendar.tsx new file mode 100644 index 0000000..0f8950a --- /dev/null +++ b/components/ui/calendar.tsx @@ -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 & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + 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 ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: ({ ...props }) => ( + + ), + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + locale, + ...props +}: React.ComponentProps & { locale?: Partial }) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( +