Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m40s
Docker images / frontend-image (push) Successful in 3m28s
Docker images / updater-image (push) Successful in 50s
Docker images / backend-image (push) Successful in 3m5s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
- Окно периода клампится к текущему моменту: «Сегодня»/«7 д» больше не делят байты на ещё не наступившие часы суток (avgBps/bps были занижены). - Пресет «24 ч» шлёт точные ISO-границы «сейчас−24ч … сейчас» вместо дат «вчера…сегодня» (~48 ч); подпись диапазона и подсветка пресета сверяются по длительности окна. - Таксономия сервисов как на карте: DNS/SSH/BGP/WireGuard/GRE и пустые метки сворачиваются в «Прочее» (normalizeFactService) в разбивке сервисов и pivot country×service. - Тесты: кламп окна, ISO-24ч, будущее «to», невалидный диапазон, нормализация меток.
216 lines
7.3 KiB
TypeScript
216 lines
7.3 KiB
TypeScript
"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 { 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
|
||
}
|
||
|
||
/** Значение периода — дата (YYYY-MM-DD) или полный ISO-таймстамп (пресет «24 ч»). */
|
||
export function parseRangeDate(s: string): Date | 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>
|
||
)
|
||
}
|