feat(mcp): подготовить сервер и доступ к приёмке

This commit is contained in:
Denozordec committed 2026-11-03 03:12:03 +07:00
commit bf3d50e1ff
3835 files changed
+537122

No files matched your search

@@ -0,0 +1,57 @@
"use client"
import { Frame, FrameHeader, FramePanel, FrameTitle } from "@/components/reui/frame"
import {
StatisticsBreakdownDataGrid,
type StatisticsSliceKind,
} from "@/components/data-grids/statistics-breakdown-data-grid"
import { STATISTICS_DIMS } from "@/lib/statistics-dims"
import type { StatisticsBreakdownRow, StatisticsDto } from "@mmapp/contracts/statistics"
const MINI_ROWS = 12
function rowsForKind(data: StatisticsDto, kind: StatisticsSliceKind): StatisticsBreakdownRow[] {
if (kind === "users") return data.users
if (kind === "servers") return data.servers
if (kind === "interfaces") return data.interfaces
if (kind === "countries") return data.countries
if (kind === "services") return data.services
return data.asns
}
export function BreakdownDashboard({
data,
hidden,
selectedIdFor,
onRowClick,
isLoading,
}: {
data: StatisticsDto
hidden: Set<StatisticsSliceKind>
selectedIdFor: (kind: StatisticsSliceKind) => string | undefined
onRowClick: (kind: StatisticsSliceKind, row: StatisticsBreakdownRow) => void
isLoading?: boolean
}) {
const dims = STATISTICS_DIMS.filter((d) => !hidden.has(d.id))
return (
<div className="grid grid-cols-1 gap-4 p-4 md:grid-cols-2">
{dims.map((d) => (
<Frame key={d.id} dense>
<FrameHeader className="border-b">
<FrameTitle>{d.label}</FrameTitle>
</FrameHeader>
<FramePanel className="max-h-80 overflow-auto p-0">
<StatisticsBreakdownDataGrid
rows={rowsForKind(data, d.id).slice(0, MINI_ROWS)}
kind={d.id}
selectedId={selectedIdFor(d.id)}
onRowClick={(row) => onRowClick(d.id, row)}
isLoading={isLoading}
density="mini"
/>
</FramePanel>
</Frame>
))}
</div>
)
}
@@ -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: "Выберите период…",
}
@@ -0,0 +1,73 @@
"use client"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { STATISTICS_DIMS } from "@/lib/statistics-dims"
import type { StatisticsPivotDim } from "@mmapp/contracts/statistics"
import type { StatisticsSliceKind } from "@/components/data-grids/statistics-breakdown-data-grid"
export function DimensionSelect({
value,
onChange,
label,
}: {
value: StatisticsSliceKind
onChange: (value: StatisticsSliceKind) => void
label?: string
}) {
const current = STATISTICS_DIMS.find((d) => d.id === value)
return (
<label className="flex items-center gap-2">
{label ? <span className="text-muted-foreground text-xs whitespace-nowrap">{label}</span> : null}
<Select value={value} onValueChange={(v) => onChange(String(v ?? value) as StatisticsSliceKind)}>
<SelectTrigger aria-label={label ?? "Измерение"} size="sm" className="min-w-40">
<SelectValue>{current?.label ?? "Измерение"}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
{STATISTICS_DIMS.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.label}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
)
}
export function PivotDimSelect({
value,
onChange,
exclude,
label,
}: {
value: StatisticsPivotDim
onChange: (value: StatisticsPivotDim) => void
exclude?: StatisticsPivotDim
label: string
}) {
const options = STATISTICS_DIMS.filter((d) => d.pivot !== exclude)
const current = STATISTICS_DIMS.find((d) => d.pivot === value)
return (
<label className="flex items-center gap-2">
<span className="text-muted-foreground text-xs whitespace-nowrap">{label}</span>
<Select value={value} onValueChange={(v) => onChange(String(v ?? value) as StatisticsPivotDim)}>
<SelectTrigger aria-label={label ?? "Измерение"} size="sm" className="min-w-40">
<SelectValue>{current?.label ?? label}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
{options.map((d) => (
<SelectItem key={d.pivot} value={d.pivot}>
{d.label}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
)
}
+220
View File
@@ -0,0 +1,220 @@
"use client"
import { useCallback, useMemo, useState } 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 { isCalendarDate, isStatisticsDate } from "@/lib/statistics-range"
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 {
if (!isCalendarDate(s)) return null
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
if (!m) return null
const d = new Date(0)
d.setFullYear(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
d.setHours(0, 0, 0, 0)
return Number.isNaN(d.getTime()) ? null : d
}
/** Значение периода — дата (YYYY-MM-DD) или полный ISO-таймстамп (пресет «24 ч»). */
export function parseRangeDate(s: string): Date | null {
if (!isStatisticsDate(s)) return null
if (s.includes("T")) {
const d = new Date(s)
return Number.isNaN(d.getTime()) ? null : d
}
return parseYmd(s)
}
export function rangeForPreset(preset: PeriodPreset, now = new Date()): DateRangeYmd {
const to = formatYmd(now)
if (preset === "today") return { from: to, to }
if (preset === "24h") {
// Последние 24 часа от сейчас: полный ISO, иначе date-only «вчера…сегодня» даёт до ~48 ч.
return {
from: new Date(now.getTime() - 24 * 3600_000).toISOString(),
to: now.toISOString(),
}
}
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: parseRangeDate(range.from) ?? undefined,
endDate: parseRangeDate(range.to) ?? undefined,
}
}
function formatRangeLabel(range: DateRangeYmd): string {
const from = parseRangeDate(range.from)
const to = parseRangeDate(range.to)
if (!from || !to) return "Период"
if (range.from.includes("T") || range.to.includes("T")) {
return formatYmd(from) === formatYmd(to)
? `${format(from, "d MMM yyyy HH:mm", { locale: ru })} – ${format(to, "HH:mm", { locale: ru })}`
: `${format(from, "d MMM HH:mm", { locale: ru })} – ${format(to, "d MMM HH:mm", { locale: ru })}`
}
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 [open, setOpen] = useState(false)
const selectorValue = useMemo(() => rangeToSelector(range), [range])
const handleSelectorChange = useCallback(
(value: DateSelectorValue) => {
const next = dateSelectorToRange(value)
if (!next) return
if (next.from === range.from && next.to === range.to) return
onChange(next)
setOpen(false)
},
[onChange, range.from, range.to],
)
const activePreset = PRESETS.find((p) => {
// «24 ч» хранится ISO-таймстампами: сверяем разбором (длительность окна), а не строками.
if (p.id === "24h") {
const f = parseRangeDate(range.from)
const t = parseRangeDate(range.to)
return Boolean(f && t && Math.abs((t.getTime() - f.getTime()) - 24 * 3600_000) < 60_000)
}
const r = rangeForPreset(p.id)
return r.from === range.from && r.to === range.to
})?.id
return (
<div className="flex flex-wrap items-center gap-1.5">
{PRESETS.map((p) => (
<Button
key={p.id}
type="button"
variant={activePreset === p.id ? "secondary" : "ghost"}
size="sm"
onClick={() => onChange(rangeForPreset(p.id))}
>
{p.label}
</Button>
))}
<Popover open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger
render={
<Button type="button" variant="outline" size="sm" className="min-w-40 justify-between" />
}
>
<CalendarIcon className="size-3.5" />
<span className="tabular-nums">{formatRangeLabel(range)}</span>
</PopoverTrigger>
<PopoverContent align="end" className="w-auto min-w-[32rem] max-w-[min(100vw-2rem,42rem)] p-3">
<DateSelector
value={selectorValue}
onChange={handleSelectorChange}
allowRange
defaultPeriodType="day"
defaultFilterType="between"
periodTypes={["day", "month", "year"]}
showTwoMonths
weekStartsOn={1}
maxYear={2035}
dayDateFormat="dd.MM.yyyy"
i18n={DATE_SELECTOR_RU}
className="sm:w-[470px]"
/>
</PopoverContent>
</Popover>
</div>
)
}
+38
View File
@@ -0,0 +1,38 @@
"use client"
import { XIcon } from "lucide-react"
import { Badge } from "@/components/reui/badge"
import { Button } from "@/components/ui/button"
export interface SliceChip {
key: string
label: string
}
export function SliceChips({
chips,
onRemove,
}: {
chips: SliceChip[]
onRemove: (key: string) => void
}) {
if (!chips.length) return null
return (
<div className="flex flex-wrap items-center gap-1.5 border-b px-5 py-2">
{chips.map((chip) => (
<Badge key={chip.key} variant="outline" size="sm" className="gap-1 pr-0.5">
{chip.label}
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`Снять ${chip.label}`}
onClick={() => onRemove(chip.key)}
>
<XIcon />
</Button>
</Badge>
))}
</div>
)
}
@@ -0,0 +1,27 @@
"use client"
import { useState } from "react"
import { QueryFilters } from "@/components/data-grids/query-filters"
import { Button } from "@/components/ui/button"
import { STATISTICS_FILTER_FIELDS } from "@/lib/data-filters/statistics-filter-fields"
import { filterQueryToSlices, slicesToFilterQuery, type StatisticsSlices } from "@/lib/data-filters/statistics-filter-query"
/** Parent keys this editor by URL slices so external navigation resets drafts. */
export function StatisticsFilters({ slices, onApply }: {
slices: StatisticsSlices
onApply: (slices: StatisticsSlices) => void
}) {
const [query, setQuery] = useState(() => slicesToFilterQuery(slices))
const result = filterQueryToSlices(query)
return <div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<QueryFilters fields={STATISTICS_FILTER_FIELDS} query={query} onQueryChange={setQuery} />
<Button size="sm" variant="outline" disabled={result.status !== "valid"}
onClick={() => { if (result.status === "valid") onApply(result.slices) }}>
Применить срез
</Button>
</div>
{result.status === "invalid" && <p role="alert" className="text-xs text-destructive">{result.error}</p>}
{result.status === "draft" && <p role="status" className="text-xs text-muted-foreground">Завершите условие. Текущий срез сохранён.</p>}
</div>
}
@@ -0,0 +1,98 @@
"use client"
import { useMemo } from "react"
import { CompactDataGrid, type CompactDataGridColumn } from "@/components/data-grids/compact-data-grid"
import { formatBytes } from "@/lib/fmt-rate"
import { cn } from "@/lib/utils"
import type { StatisticsPivotDto } from "@mmapp/contracts/statistics"
interface PivotGridRow {
id: string
label: string
total: number
[key: string]: string | number
}
export function StatisticsPivotGrid({
data,
onCellClick,
isLoading,
}: {
data: StatisticsPivotDto
onCellClick?: (rowId: string, colId: string) => void
isLoading?: boolean
}) {
const rows: PivotGridRow[] = useMemo(
() =>
data.rows.map((r) => {
const next: PivotGridRow = { id: r.id, label: r.label, total: r.total }
for (const col of data.columns) {
next[`c:${col.id}`] = r.cells[col.id] ?? 0
}
return next
}),
[data],
)
const columns: CompactDataGridColumn<PivotGridRow>[] = [
{
id: "label",
size: 140,
header: "Измерение",
accessorKey: "label",
cell: (row) => <span className="truncate font-medium">{row.label}</span>,
},
...data.columns.map((col) => ({
id: `c:${col.id}`,
size: 110,
header: col.label,
accessorKey: `c:${col.id}` as const,
cell: (row: PivotGridRow) => {
const value = Number(row[`c:${col.id}`] ?? 0)
const label = data.metric === "packets" ? value.toLocaleString("ru-RU") : formatBytes(value)
if (col.id === "__other__" || row.id === "__other__") {
return <span className="tabular-nums text-muted-foreground">{label}</span>
}
return (
<button
type="button"
aria-label={`${row.label}, ${col.label}: ${label}. Показать срез`}
className={cn(
"tabular-nums text-left hover:underline",
col.id === "__other__" || row.id === "__other__" ? "text-muted-foreground" : "",
)}
onClick={(e) => {
e.stopPropagation()
onCellClick?.(row.id, col.id)
}}
>
{label}
</button>
)
},
})),
{
id: "total",
size: 110,
header: "Итого",
accessorKey: "total",
cell: (row) => (
<span className="tabular-nums font-medium">
{data.metric === "packets" ? row.total.toLocaleString("ru-RU") : formatBytes(row.total)}
</span>
),
},
]
return (
<CompactDataGrid
data={rows}
horizontalScroll
pinnedColumnId="label"
columns={columns}
isLoading={isLoading}
emptyTitle="Нет данных сводной"
emptyDescription="Выберите разные измерения строк и колонок."
/>
)
}
@@ -0,0 +1,105 @@
"use client"
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"
import { Frame, FramePanel } from "@/components/reui/frame"
import { ChartContainer, ChartTooltip, type ChartConfig } from "@/components/ui/chart"
import { formatBytes } from "@/lib/fmt-rate"
import type { StatisticsSeriesPoint } from "@mmapp/contracts/statistics"
/**
* Объём трафика за период — adapt ReUI chart-23 (байты, не live bps).
* @see https://reui.io/preview/base/chart-23
*/
const chartConfig = {
bytes: { label: "Объём", color: "var(--chart-1)" },
} satisfies ChartConfig
function formatTick(iso: string, grain: "hour" | "day"): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso.slice(0, 10)
if (grain === "hour") {
return `${String(d.getHours()).padStart(2, "0")}:00`
}
return d.toLocaleDateString("ru-RU", { day: "2-digit", month: "short" })
}
function CustomTooltip({
active,
payload,
}: {
active?: boolean
payload?: { payload: { label: string; bytes: number } }[]
}) {
if (!active || !payload?.length) return null
const row = payload[0]?.payload
if (!row) return null
return (
<div className="flex min-w-[120px] flex-col gap-1.5 rounded-lg bg-popover p-3 text-popover-foreground shadow-lg ring-1 ring-foreground/10">
<div className="text-[10px] font-medium tracking-wider uppercase opacity-70">{row.label}</div>
<div className="text-sm font-semibold tabular-nums">{formatBytes(row.bytes)}</div>
</div>
)
}
export function StatisticsVolumeChart({
series,
grain,
}: {
series: StatisticsSeriesPoint[]
grain: "hour" | "day"
}) {
const data = series.map((p) => ({
t: p.t,
bytes: p.bytes,
label: formatTick(p.t, grain),
}))
const tickEvery = Math.max(1, Math.ceil(data.length / 8))
return (
<Frame className="w-full">
<FramePanel className="flex flex-col gap-4">
<div className="flex items-baseline justify-between gap-2">
<h2 className="text-sm font-medium">Объём по времени</h2>
<span className="text-muted-foreground text-xs">
{grain === "hour" ? "по часам" : "по суткам"}
</span>
</div>
{data.length === 0 ? (
<p className="text-muted-foreground py-10 text-center text-sm">Нет данных за выбранный период</p>
) : (
<ChartContainer config={chartConfig} className="-ms-4 aspect-auto h-[220px] w-full">
<AreaChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
<CartesianGrid strokeDasharray="4 8" vertical={false} stroke="var(--border)" />
<XAxis
dataKey="label"
axisLine={false}
tickLine={false}
tick={{ fontSize: 11 }}
tickMargin={10}
interval={tickEvery - 1}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 11 }}
tickFormatter={(v: number) => formatBytes(Number(v))}
tickMargin={8}
width={72}
/>
<ChartTooltip content={<CustomTooltip />} />
<Area
dataKey="bytes"
type="monotone"
stroke="var(--chart-1)"
fill="var(--chart-1)"
fillOpacity={0.15}
strokeWidth={2}
/>
</AreaChart>
</ChartContainer>
)}
</FramePanel>
</Frame>
)
}