feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user