feat(web): шкала времени и наглядные матрицы на страницах блокировок и GeoIP
- шкала по часовым точкам измерений: капсулы с долей проблемных прогонов, пресеты «Сейчас / −1 ч / −6 ч / −24 ч / −7 д», кнопки и клавиши ← → - статусы блокировок — цветные IconTile, короткие подписи сервисов с иконками брендов, колонка «Доступно X из Y» с полосой - GeoIP: флаги стран в ячейках, расхождение с большинством подсвечено, колонка «Страна · совпадает N/M»; расхождения считаются по сервисам - флаг страны VPS в первой колонке, фильтры и вид в шапке матрицы - демо-данные: неделя истории прогонов по полному списку сервисов Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a4a8b9562d
commit
8ae07628c6
15 files changed
+564
-224
No files matched your search
@@ -168,7 +168,8 @@ describe('collectProbeColumns', () => {
|
||||
describe('shortHostLabel', () => {
|
||||
it('отрезает типичный TLD', () => {
|
||||
expect(shortHostLabel('youtube.com')).toBe('youtube')
|
||||
expect(shortHostLabel('api.telegram.org')).toBe('api.telegram')
|
||||
expect(shortHostLabel('api.telegram.org')).toBe('telegram')
|
||||
expect(shortHostLabel('redirector.googlevideo.com')).toBe('googlevideo')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -121,9 +121,17 @@ export type MatrixColumn = {
|
||||
title: string
|
||||
}
|
||||
|
||||
/** Короткие подписи колонок матрицы: полный хост остаётся в подсказке и карточке прогона. */
|
||||
const HOST_ALIASES: Record<string, string> = {
|
||||
'redirector.googlevideo.com': 'googlevideo',
|
||||
'api.telegram.org': 'telegram',
|
||||
'play.google.com': 'google play',
|
||||
'copilot.microsoft.com': 'copilot',
|
||||
}
|
||||
|
||||
export function shortHostLabel(value: string): string {
|
||||
const host = value.trim().split('/')[0] ?? value
|
||||
return host.replace(/\.(com|org|net|io|ag|is)$/i, '')
|
||||
const host = (value.trim().split('/')[0] ?? value).toLowerCase()
|
||||
return HOST_ALIASES[host] ?? host.replace(/\.(com|org|net|io|ag|is)$/i, '')
|
||||
}
|
||||
|
||||
const CANONICAL_SERVICES = [...CENSORCHECK_DPI_HOSTS, ...CENSORCHECK_GEOBLOCK_HOSTS]
|
||||
|
||||
@@ -5,6 +5,8 @@ import { ServerIcon, ShieldAlertIcon } from 'lucide-react'
|
||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||
import { dataGridCellStack, dataGridCellWithIcon } from '@/components/data-grid-cells'
|
||||
import { columnDefFromDataGrid, FrameDataGrid } from '@/components/reui-kit'
|
||||
import { CountryFlag } from '@/components/country-flag'
|
||||
import { Progress } from '@cfdm/ui/components/progress'
|
||||
import {
|
||||
collectProbeColumns,
|
||||
collectServiceColumns,
|
||||
@@ -51,7 +53,10 @@ function vpsIdentityColumn(): DataGridColumn<CensorcheckRunDto> {
|
||||
) : (
|
||||
<span className="font-medium">Unknown VPS</span>
|
||||
)
|
||||
return dataGridCellStack(link, secondary)
|
||||
return dataGridCellWithIcon(
|
||||
<CountryFlag country={row.vps?.country} className="size-5" />,
|
||||
dataGridCellStack(link, secondary),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -60,20 +65,55 @@ export function BlockingVpsGrid({
|
||||
runs,
|
||||
onRowClick,
|
||||
emptyAction,
|
||||
toolbar,
|
||||
}: {
|
||||
runs: CensorcheckRunDto[]
|
||||
onRowClick: (run: CensorcheckRunDto) => void
|
||||
emptyAction?: ReactNode
|
||||
/** Фильтры и вид — в шапке рамки (MikrotikManager v2). */
|
||||
toolbar?: ReactNode
|
||||
}) {
|
||||
const serviceCols = useMemo(() => collectServiceColumns(runs), [runs])
|
||||
const columns = useMemo((): DataGridColumn<CensorcheckRunDto>[] => {
|
||||
return [
|
||||
vpsIdentityColumn(),
|
||||
{
|
||||
key: 'availability',
|
||||
header: 'Доступно',
|
||||
size: 150,
|
||||
minSize: 130,
|
||||
sortValue: (row) => (row.summary.total ? row.summary.available / row.summary.total : 0),
|
||||
sortingFn: 'basic',
|
||||
cell: (row) => {
|
||||
const { available, total, blocked } = row.summary
|
||||
const pct = total ? Math.round((available / total) * 100) : 0
|
||||
// Литералы целиком — Tailwind не видит классы, собранные шаблонной строкой.
|
||||
const bar =
|
||||
blocked > 0
|
||||
? '**:data-[slot=progress-indicator]:bg-destructive'
|
||||
: pct < 100
|
||||
? '**:data-[slot=progress-indicator]:bg-warning'
|
||||
: '**:data-[slot=progress-indicator]:bg-success'
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="text-sm font-medium tabular-nums">
|
||||
{available}
|
||||
<span className="text-muted-foreground text-xs font-normal"> из {total}</span>
|
||||
</span>
|
||||
<Progress
|
||||
value={pct}
|
||||
aria-label={`Доступно ${available} из ${total}`}
|
||||
className={bar}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
...serviceCols.map(
|
||||
(svc): DataGridColumn<CensorcheckRunDto> => ({
|
||||
key: `svc:${svc.key}`,
|
||||
header: svc.label,
|
||||
headerTitle: svc.title,
|
||||
headerTitle: svc.label,
|
||||
icon: resolveServiceIcon(svc.key),
|
||||
className: MATRIX_CELL,
|
||||
headerClassName: MATRIX_CELL,
|
||||
@@ -100,6 +140,9 @@ export function BlockingVpsGrid({
|
||||
|
||||
return (
|
||||
<FrameDataGrid
|
||||
title="Текущие пробы"
|
||||
description="Доступность сервисов с каждого VPS"
|
||||
toolbar={toolbar}
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={runs}
|
||||
rowId={(row) => row.id}
|
||||
@@ -120,11 +163,14 @@ export function BlockingServiceGrid({
|
||||
runs,
|
||||
onProbeClick,
|
||||
emptyAction,
|
||||
toolbar,
|
||||
}: {
|
||||
groups: BlockingServiceRow[]
|
||||
runs: CensorcheckRunDto[]
|
||||
onProbeClick: (run: CensorcheckRunDto) => void
|
||||
emptyAction?: ReactNode
|
||||
/** Фильтры и вид — в шапке рамки (MikrotikManager v2). */
|
||||
toolbar?: ReactNode
|
||||
}) {
|
||||
const probeCols = useMemo(() => collectProbeColumns(runs), [runs])
|
||||
const runById = useMemo(() => new Map(runs.map((row) => [row.id, row])), [runs])
|
||||
@@ -180,6 +226,9 @@ export function BlockingServiceGrid({
|
||||
|
||||
return (
|
||||
<FrameDataGrid
|
||||
title="Текущие пробы"
|
||||
description="Доступность сервисов с каждого VPS"
|
||||
toolbar={toolbar}
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={groups}
|
||||
rowId={(row) => row.id}
|
||||
|
||||
@@ -131,7 +131,15 @@ export function BlockingPage() {
|
||||
() => mergeCensorcheckRuns(currentQuery.data?.items ?? [], historyQuery.data?.items ?? []),
|
||||
[currentQuery.data?.items, historyQuery.data?.items],
|
||||
)
|
||||
const ticks = useMemo(() => collectSnapshotTicks(allRuns), [allRuns])
|
||||
const ticks = useMemo(
|
||||
() =>
|
||||
collectSnapshotTicks(allRuns, (run) => {
|
||||
const s = run.summary
|
||||
if (s.blocked > 0) return 'er'
|
||||
return s.denied + s.timeout + s.error > 0 ? 'wn' : 'ok'
|
||||
}),
|
||||
[allRuns],
|
||||
)
|
||||
const resolvedIndex = resolveSnapshotIndex(ticks.length, snapshotIndex)
|
||||
const snapshotRuns = useMemo(() => {
|
||||
const asOf = ticks[resolvedIndex]?.asOf
|
||||
@@ -184,6 +192,35 @@ export function BlockingPage() {
|
||||
</>
|
||||
)
|
||||
|
||||
const matrixToolbar = (
|
||||
<>
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={FILTER_FIELDS}
|
||||
onChange={setFilters}
|
||||
trigger={
|
||||
<Button type="button" variant="outline">
|
||||
Фильтры
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<ToggleGroup
|
||||
variant="outline"
|
||||
size="sm"
|
||||
spacing={0}
|
||||
value={[group]}
|
||||
onValueChange={(next) => {
|
||||
const selectedMode = next[0]
|
||||
if (selectedMode === 'vps' || selectedMode === 'service') setGroup(selectedMode)
|
||||
}}
|
||||
aria-label="Группировка"
|
||||
>
|
||||
<ToggleGroupItem value="vps">По VPS</ToggleGroupItem>
|
||||
<ToggleGroupItem value="service">По сервису</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
@@ -237,33 +274,8 @@ export function BlockingPage() {
|
||||
ticks={ticks}
|
||||
index={resolvedIndex}
|
||||
onIndexChange={setSnapshotIndex}
|
||||
issueLabel="с ограничениями"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={FILTER_FIELDS}
|
||||
onChange={setFilters}
|
||||
trigger={
|
||||
<Button type="button" variant="outline">
|
||||
Фильтры
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<ToggleGroup
|
||||
variant="outline"
|
||||
size="sm"
|
||||
spacing={0}
|
||||
value={[group]}
|
||||
onValueChange={(next) => {
|
||||
const selectedMode = next[0]
|
||||
if (selectedMode === 'vps' || selectedMode === 'service') setGroup(selectedMode)
|
||||
}}
|
||||
aria-label="Группировка"
|
||||
>
|
||||
<ToggleGroupItem value="vps">По VPS</ToggleGroupItem>
|
||||
<ToggleGroupItem value="service">По сервису</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<QueryState
|
||||
data={filtered}
|
||||
isLoading={currentQuery.isLoading}
|
||||
@@ -279,12 +291,14 @@ export function BlockingPage() {
|
||||
{(rows) =>
|
||||
group === 'vps' ? (
|
||||
<BlockingVpsGrid
|
||||
toolbar={matrixToolbar}
|
||||
runs={rows}
|
||||
onRowClick={setSelected}
|
||||
emptyAction={copyLauncher}
|
||||
/>
|
||||
) : (
|
||||
<BlockingServiceGrid
|
||||
toolbar={matrixToolbar}
|
||||
groups={serviceGroups}
|
||||
runs={rows}
|
||||
onProbeClick={setSelected}
|
||||
|
||||
@@ -1,107 +1,189 @@
|
||||
import type { KeyboardEvent } from 'react'
|
||||
import { ChevronLeftIcon, ChevronRightIcon, HistoryIcon } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import {
|
||||
Timeline,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
} from '@/components/reui/timeline'
|
||||
import { Slider } from '@cfdm/ui/components/slider'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@cfdm/ui/components/toggle-group'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@cfdm/ui/components/tooltip'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
import {
|
||||
formatSnapshotTickLabel,
|
||||
type BlockingSnapshotTick,
|
||||
} from './blocking-snapshots'
|
||||
import { formatCheckedAt } from './types'
|
||||
import { tickIndexAtOrBefore, type BlockingSnapshotTick, type SnapshotTone } from './blocking-snapshots'
|
||||
|
||||
/** DNA: c-timeline-12 + shadcn Slider. Preview: https://reui.io/preview/base/components/c-timeline-12 */
|
||||
const HOUR = 60 * 60 * 1000
|
||||
const PRESETS = [
|
||||
{ id: 'now', label: 'Сейчас', ms: 0 },
|
||||
{ id: '1h', label: '−1 ч', ms: HOUR },
|
||||
{ id: '6h', label: '−6 ч', ms: 6 * HOUR },
|
||||
{ id: '24h', label: '−24 ч', ms: 24 * HOUR },
|
||||
{ id: '7d', label: '−7 д', ms: 7 * 24 * HOUR },
|
||||
] as const
|
||||
|
||||
/** Верхняя часть капсулы — доля прогонов часа с проблемой, цветом худшего результата. */
|
||||
const TONE_FILL: Record<SnapshotTone, string> = {
|
||||
ok: 'bg-success',
|
||||
wn: 'bg-warning',
|
||||
er: 'bg-destructive',
|
||||
}
|
||||
const TONE_TEXT: Record<SnapshotTone, string> = { ok: 'text-success', wn: 'text-warning', er: 'text-destructive' }
|
||||
|
||||
function formatTickTime(iso: string, withDate = true): string {
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return iso
|
||||
return date.toLocaleString('ru-RU', {
|
||||
...(withDate ? { day: 'numeric', month: 'short' } : {}),
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Шкала времени по точкам измерений (час = точка). Лента капсул по образцу ReUI chart-34
|
||||
* (Cron Run History): высота — число проб, цвет — худший результат часа. Timeline из ReUI
|
||||
* статичен, поэтому навигация собрана из Button / ToggleGroup / Tooltip.
|
||||
*/
|
||||
export function BlockingSnapshotScrubber({
|
||||
ticks,
|
||||
index,
|
||||
onIndexChange,
|
||||
describeTick,
|
||||
issueLabel = 'с проблемами',
|
||||
}: {
|
||||
ticks: BlockingSnapshotTick[]
|
||||
index: number
|
||||
onIndexChange: (index: number) => void
|
||||
/** Подпись под временем выбранной точки, напр. «4 пробы · 2 блокировки». */
|
||||
describeTick?: (tick: BlockingSnapshotTick) => string
|
||||
issueLabel?: string
|
||||
}) {
|
||||
if (ticks.length === 0) return null
|
||||
|
||||
const selected = ticks[index] ?? ticks[ticks.length - 1]!
|
||||
const lastIndex = ticks.length - 1
|
||||
const selected = ticks[index] ?? ticks[lastIndex]!
|
||||
const isLatest = index >= lastIndex
|
||||
const max = Math.max(0, lastIndex)
|
||||
const maxCount = Math.max(...ticks.map((t) => t.count), 1)
|
||||
const latestMs = new Date(ticks[lastIndex]!.asOf).getTime()
|
||||
const go = (next: number) => onIndexChange(Math.min(lastIndex, Math.max(0, next)))
|
||||
|
||||
const handleSlider = (next: number | readonly number[]) => {
|
||||
const value = Array.isArray(next) ? next[0] : next
|
||||
if (typeof value === 'number') onIndexChange(value)
|
||||
const presetIndex = (ms: number) => (ms === 0 ? lastIndex : tickIndexAtOrBefore(ticks, latestMs - ms))
|
||||
// Подсвечиваем только «Сейчас»: «−N ч» — переход к ближайшей точке, не режим.
|
||||
const activePreset = isLatest ? 'now' : undefined
|
||||
|
||||
const onStripKey = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'ArrowLeft') go(index - 1)
|
||||
else if (event.key === 'ArrowRight') go(index + 1)
|
||||
else if (event.key === 'Home') go(0)
|
||||
else if (event.key === 'End') go(lastIndex)
|
||||
else return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame dense variant="default" spacing="sm" className="w-full min-w-0">
|
||||
<FramePanel className="flex flex-col gap-3 px-(--frame-panel-header-px) py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<span className="text-sm font-medium">
|
||||
{isLatest ? 'Сейчас' : formatSnapshotTickLabel(selected.key)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<IconTile variant="soft" size="sm" className={TONE_TEXT[selected.tone]} aria-hidden="true">
|
||||
<HistoryIcon />
|
||||
</IconTile>
|
||||
<div className="flex min-w-0 flex-1 basis-48 flex-col gap-px">
|
||||
<span className="flex items-center gap-2 text-sm font-medium">
|
||||
{isLatest ? 'Сейчас' : 'Снимок'} · {formatTickTime(selected.asOf)}
|
||||
<Badge size="sm" variant={isLatest ? 'success-light' : 'outline'}>
|
||||
{isLatest ? 'Актуально' : `${lastIndex - index} назад`}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatCheckedAt(selected.asOf)}
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{describeTick
|
||||
? describeTick(selected)
|
||||
: `${selected.count} проб · ${selected.issues} ${issueLabel}`}
|
||||
</span>
|
||||
</div>
|
||||
<Badge size="sm" variant={isLatest ? 'success' : 'outline'}>
|
||||
{isLatest ? 'Актуально' : 'Снимок'}
|
||||
</Badge>
|
||||
<ToggleGroup
|
||||
variant="outline"
|
||||
size="sm"
|
||||
spacing={0}
|
||||
value={activePreset ? [activePreset] : []}
|
||||
onValueChange={(next) => {
|
||||
const preset = PRESETS.find((p) => p.id === next[0])
|
||||
if (!preset) return
|
||||
const target = presetIndex(preset.ms)
|
||||
go(target < 0 ? 0 : target)
|
||||
}}
|
||||
aria-label="Быстрый переход по времени"
|
||||
>
|
||||
{PRESETS.map((p) => (
|
||||
<ToggleGroupItem key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => go(index - 1)}
|
||||
disabled={index <= 0}
|
||||
aria-label="Предыдущее измерение"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => go(index + 1)}
|
||||
disabled={isLatest}
|
||||
aria-label="Следующее измерение"
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Slider
|
||||
value={[index]}
|
||||
min={0}
|
||||
max={max}
|
||||
step={1}
|
||||
disabled={ticks.length <= 1}
|
||||
onValueChange={handleSlider}
|
||||
aria-label="Снимок проверок на дату"
|
||||
/>
|
||||
|
||||
<div className="min-w-0 overflow-x-auto">
|
||||
<Timeline
|
||||
orientation="horizontal"
|
||||
value={index + 1}
|
||||
onValueChange={(step) => onIndexChange(Math.max(0, step - 1))}
|
||||
className="min-w-max"
|
||||
>
|
||||
{ticks.map((tick, tickIndex) => {
|
||||
const showLabel =
|
||||
ticks.length <= 8 ||
|
||||
tickIndex === 0 ||
|
||||
tickIndex === lastIndex ||
|
||||
tickIndex === index
|
||||
return (
|
||||
<TimelineItem
|
||||
key={tick.key}
|
||||
step={tickIndex + 1}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onIndexChange(tickIndex)}
|
||||
>
|
||||
<TimelineHeader>
|
||||
<TimelineSeparator />
|
||||
<TimelineDate
|
||||
className={cn(
|
||||
'text-[11px] tabular-nums',
|
||||
showLabel ? undefined : 'invisible',
|
||||
)}
|
||||
>
|
||||
{formatSnapshotTickLabel(tick.key)}
|
||||
</TimelineDate>
|
||||
<TimelineIndicator className="size-2.5" />
|
||||
</TimelineHeader>
|
||||
</TimelineItem>
|
||||
)
|
||||
})}
|
||||
</Timeline>
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="Точки измерений: ← → для перехода"
|
||||
tabIndex={0}
|
||||
onKeyDown={onStripKey}
|
||||
className="focus-visible:ring-ring/50 flex h-10 items-end justify-between gap-1 rounded-md outline-none focus-visible:ring-2"
|
||||
>
|
||||
{ticks.map((tick, i) => (
|
||||
<Tooltip key={tick.key}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={`${formatTickTime(tick.asOf)}: ${tick.count} проб, ${tick.issues} ${issueLabel}`}
|
||||
aria-pressed={i === index}
|
||||
onClick={() => go(i)}
|
||||
className={cn(
|
||||
'bg-success/60 hover:bg-success/80 relative flex min-w-1.5 max-w-16 flex-1 flex-col overflow-hidden rounded-sm transition-colors',
|
||||
i === index && 'ring-foreground ring-offset-background ring-2 ring-offset-1',
|
||||
)}
|
||||
style={{ height: `${30 + (70 * tick.count) / maxCount}%` }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{tick.issues > 0 ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn('block w-full', TONE_FILL[tick.tone])}
|
||||
style={{ height: `${Math.max(12, (100 * tick.issues) / tick.count)}%` }}
|
||||
/>
|
||||
) : null}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{formatTickTime(tick.asOf)} · {tick.count} проб · {tick.issues} {issueLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex justify-between text-[11px] tabular-nums">
|
||||
<span>{formatTickTime(ticks[0]!.asOf)}</span>
|
||||
{ticks.length > 1 ? <span>{formatTickTime(ticks[lastIndex]!.asOf)}</span> : null}
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
latestRunsAsOf,
|
||||
mergeCensorcheckRuns,
|
||||
resolveSnapshotIndex,
|
||||
snapshotDayKey,
|
||||
tickIndexAtOrBefore,
|
||||
} from './blocking-snapshots'
|
||||
import type { CensorcheckRunDto } from './types'
|
||||
|
||||
@@ -48,18 +48,30 @@ describe('mergeCensorcheckRuns', () => {
|
||||
})
|
||||
|
||||
describe('collectSnapshotTicks', () => {
|
||||
it('группирует по дню и берёт max createdAt', () => {
|
||||
it('группирует по часу и берёт max createdAt', () => {
|
||||
const ticks = collectSnapshotTicks(
|
||||
[
|
||||
run({ id: '1', createdAt: '2026-08-20T08:05:00.000Z' }),
|
||||
run({ id: '2', createdAt: '2026-08-20T08:40:00.000Z', summary: { ...run().summary, blocked: 2 } }),
|
||||
run({ id: '3', createdAt: '2026-08-20T18:00:00.000Z' }),
|
||||
],
|
||||
(r) => (r.summary.blocked > 0 ? 'er' : 'ok'),
|
||||
)
|
||||
expect(ticks.map((tick) => tick.key)).toEqual(['2026-08-20T08', '2026-08-20T18'])
|
||||
expect(ticks[0]?.count).toBe(2)
|
||||
expect(ticks[0]?.asOf).toBe('2026-08-20T08:40:00.000Z')
|
||||
expect(ticks[0]?.tone).toBe('er')
|
||||
expect(ticks[0]?.issues).toBe(1)
|
||||
expect(ticks[1]?.tone).toBe('ok')
|
||||
})
|
||||
|
||||
it('tickIndexAtOrBefore находит точку для пресета «−N ч»', () => {
|
||||
const ticks = collectSnapshotTicks([
|
||||
run({ id: '1', createdAt: '2026-08-20T08:00:00.000Z' }),
|
||||
run({ id: '2', createdAt: '2026-08-20T18:00:00.000Z' }),
|
||||
run({ id: '3', createdAt: '2026-08-21T12:00:00.000Z' }),
|
||||
run({ id: '2', createdAt: '2026-08-20T12:00:00.000Z' }),
|
||||
])
|
||||
expect(ticks.map((tick) => tick.key)).toEqual([
|
||||
snapshotDayKey('2026-08-20T08:00:00.000Z'),
|
||||
snapshotDayKey('2026-08-21T12:00:00.000Z'),
|
||||
])
|
||||
expect(ticks[0]?.count).toBe(2)
|
||||
expect(ticks[0]?.asOf).toBe('2026-08-20T18:00:00.000Z')
|
||||
expect(tickIndexAtOrBefore(ticks, Date.parse('2026-08-20T10:00:00.000Z'))).toBe(0)
|
||||
expect(tickIndexAtOrBefore(ticks, Date.parse('2026-08-20T07:00:00.000Z'))).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -4,10 +4,26 @@ export type SnapshotRun = {
|
||||
probePublicIp: string
|
||||
}
|
||||
|
||||
export type SnapshotTone = 'ok' | 'wn' | 'er'
|
||||
|
||||
export type BlockingSnapshotTick = {
|
||||
/** Час измерения `YYYY-MM-DDTHH` (UTC) — точка на шкале времени. */
|
||||
key: string
|
||||
asOf: string
|
||||
count: number
|
||||
/** Худший результат среди прогонов этого часа — цвет столбика на шкале. */
|
||||
tone: SnapshotTone
|
||||
/** Сколько прогонов часа с проблемой (блокировка / расхождение). */
|
||||
issues: number
|
||||
}
|
||||
|
||||
const TONE_RANK: Record<SnapshotTone, number> = { ok: 0, wn: 1, er: 2 }
|
||||
|
||||
/** Ключ точки шкалы: прогоны группируются по часу — несколько проверок за день видны отдельно. */
|
||||
export function snapshotHourKey(iso: string): string {
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return iso.slice(0, 13)
|
||||
return date.toISOString().slice(0, 13)
|
||||
}
|
||||
|
||||
export function snapshotDayKey(iso: string): string {
|
||||
@@ -38,25 +54,42 @@ export function mergeCensorcheckRuns<T extends { id: string }>(
|
||||
return [...map.values()]
|
||||
}
|
||||
|
||||
export function collectSnapshotTicks(runs: SnapshotRun[]): BlockingSnapshotTick[] {
|
||||
const byDay = new Map<string, SnapshotRun[]>()
|
||||
export function collectSnapshotTicks<T extends SnapshotRun>(
|
||||
runs: T[],
|
||||
toneOf: (run: T) => SnapshotTone = () => 'ok',
|
||||
): BlockingSnapshotTick[] {
|
||||
const byHour = new Map<string, T[]>()
|
||||
for (const run of runs) {
|
||||
const key = snapshotDayKey(run.createdAt)
|
||||
const list = byDay.get(key)
|
||||
const key = snapshotHourKey(run.createdAt)
|
||||
const list = byHour.get(key)
|
||||
if (list) list.push(run)
|
||||
else byDay.set(key, [run])
|
||||
else byHour.set(key, [run])
|
||||
}
|
||||
|
||||
return [...byDay.entries()]
|
||||
return [...byHour.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, list]) => ({
|
||||
key,
|
||||
count: list.length,
|
||||
asOf: list.reduce(
|
||||
(latest, run) => (run.createdAt > latest ? run.createdAt : latest),
|
||||
list[0]!.createdAt,
|
||||
),
|
||||
}))
|
||||
.map(([key, list]) => {
|
||||
const tones = list.map(toneOf)
|
||||
return {
|
||||
key,
|
||||
count: list.length,
|
||||
tone: tones.reduce<SnapshotTone>((worst, t) => (TONE_RANK[t] > TONE_RANK[worst] ? t : worst), 'ok'),
|
||||
issues: tones.filter((t) => t !== 'ok').length,
|
||||
asOf: list.reduce(
|
||||
(latest, run) => (run.createdAt > latest ? run.createdAt : latest),
|
||||
list[0]!.createdAt,
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Индекс последней точки не позже `atMs` (для пресетов «−1 ч», «−24 ч»); -1 — раньше первой точки. */
|
||||
export function tickIndexAtOrBefore(ticks: BlockingSnapshotTick[], atMs: number): number {
|
||||
let found = -1
|
||||
ticks.forEach((tick, i) => {
|
||||
if (new Date(tick.asOf).getTime() <= atMs) found = i
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
/** Latest run per probe IP at or before `asOf`. */
|
||||
|
||||
@@ -13,9 +13,9 @@ import { MATRIX_STATUS } from './status-matrix-cell'
|
||||
describe('MATRIX_STATUS', () => {
|
||||
it('статусы — lucide-иконки, не текст ОК/Блок', () => {
|
||||
expect(MATRIX_STATUS.available.icon).toBe(CheckIcon)
|
||||
expect(MATRIX_STATUS.available.variant).toBe('success-light')
|
||||
expect(MATRIX_STATUS.available.tone).toBe('text-success')
|
||||
expect(MATRIX_STATUS.blocked.icon).toBe(XIcon)
|
||||
expect(MATRIX_STATUS.blocked.variant).toBe('destructive-light')
|
||||
expect(MATRIX_STATUS.blocked.tone).toBe('text-destructive')
|
||||
expect(MATRIX_STATUS.denied.icon).toBe(BanIcon)
|
||||
expect(MATRIX_STATUS.timeout.icon).toBe(ClockIcon)
|
||||
expect(MATRIX_STATUS.redirected.icon).toBe(CornerUpRightIcon)
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
XIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -17,17 +17,17 @@ import {
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
import { CENSORCHECK_STATUS_LABELS, formatCheckedAt } from './types'
|
||||
|
||||
/** Compact timesheet-style cell — preview: https://reui.io/preview/base/data-grid-base-4 */
|
||||
export const MATRIX_STATUS: Record<
|
||||
string,
|
||||
{ icon: LucideIcon; variant: 'success-light' | 'destructive-light' | 'destructive-outline' | 'warning-light' | 'info-light' | 'warning-outline' }
|
||||
> = {
|
||||
available: { icon: CheckIcon, variant: 'success-light' },
|
||||
blocked: { icon: XIcon, variant: 'destructive-light' },
|
||||
denied: { icon: BanIcon, variant: 'destructive-outline' },
|
||||
timeout: { icon: ClockIcon, variant: 'warning-light' },
|
||||
redirected: { icon: CornerUpRightIcon, variant: 'info-light' },
|
||||
error: { icon: CircleAlertIcon, variant: 'warning-outline' },
|
||||
/**
|
||||
* Статус-тайл ячейки матрицы: ReUI IconTile `soft` перекрашивается одним text-* классом
|
||||
* (так велит API IconTile — без ad-hoc bg). Preview: https://reui.io/docs/components/base/icon-tile
|
||||
*/
|
||||
export const MATRIX_STATUS: Record<string, { icon: LucideIcon; tone: string }> = {
|
||||
available: { icon: CheckIcon, tone: 'text-success' },
|
||||
blocked: { icon: XIcon, tone: 'text-destructive' },
|
||||
denied: { icon: BanIcon, tone: 'text-destructive' },
|
||||
timeout: { icon: ClockIcon, tone: 'text-warning' },
|
||||
redirected: { icon: CornerUpRightIcon, tone: 'text-info' },
|
||||
error: { icon: CircleAlertIcon, tone: 'text-warning' },
|
||||
}
|
||||
|
||||
export function StatusMatrixCell({
|
||||
@@ -48,7 +48,7 @@ export function StatusMatrixCell({
|
||||
const full = status ? (CENSORCHECK_STATUS_LABELS[status] ?? status) : 'Нет результата'
|
||||
const mapped = status ? MATRIX_STATUS[status] : undefined
|
||||
const Icon = mapped?.icon ?? MinusIcon
|
||||
const variant = mapped?.variant ?? 'outline'
|
||||
const tone = mapped?.tone ?? 'text-muted-foreground'
|
||||
const tip = [
|
||||
serviceLabel,
|
||||
vpsLabel,
|
||||
@@ -60,9 +60,9 @@ export function StatusMatrixCell({
|
||||
.join(' · ')
|
||||
|
||||
const badge = (
|
||||
<Badge variant={variant} size="sm" radius="full" aria-label={full}>
|
||||
<Icon className="size-3" />
|
||||
</Badge>
|
||||
<IconTile variant={mapped ? 'soft' : 'outline'} size="xs" radius="full" className={tone} aria-label={full}>
|
||||
<Icon />
|
||||
</IconTile>
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { CountryFlag } from '@/components/country-flag'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -43,7 +44,10 @@ export function CountryMatrixCell({
|
||||
vpsLabel,
|
||||
checkedAt,
|
||||
onSelect,
|
||||
expected,
|
||||
}: {
|
||||
/** Ожидаемая страна (большинство сервисов в строке) — отличие подсвечивается как расхождение. */
|
||||
expected?: string | null
|
||||
status?: string | null
|
||||
countryIpv4?: string | null
|
||||
countryIpv6?: string | null
|
||||
@@ -56,8 +60,17 @@ export function CountryMatrixCell({
|
||||
const badgeIso = countryBadgeText(stored)
|
||||
const statusLabel = status ? (IPREGION_STATUS_LABELS[status] ?? status) : 'Нет результата'
|
||||
const display = status === 'ok' && badgeIso ? badgeIso : status ? (IPREGION_STATUS_LABELS[status] ?? status) : '—'
|
||||
const variant = status ? (STATUS_VARIANT[status] ?? 'outline') : 'outline'
|
||||
const mismatch = status === 'ok' && Boolean(badgeIso) && Boolean(expected) && badgeIso !== expected
|
||||
const variant =
|
||||
status === 'ok' && badgeIso
|
||||
? mismatch
|
||||
? 'warning-light'
|
||||
: 'outline'
|
||||
: status
|
||||
? (STATUS_VARIANT[status] ?? 'outline')
|
||||
: 'outline'
|
||||
const tip = [
|
||||
mismatch ? `Расхождение: большинство сервисов видят ${expected}` : null,
|
||||
serviceLabel,
|
||||
vpsLabel,
|
||||
formatTipCountry(stored) ?? statusLabel,
|
||||
@@ -69,7 +82,8 @@ export function CountryMatrixCell({
|
||||
.join(' · ')
|
||||
|
||||
const badge = (
|
||||
<Badge variant={variant} size="sm" radius="full" aria-label={tip}>
|
||||
<Badge variant={variant} size="sm" radius="full" aria-label={tip} className="gap-1 tabular-nums">
|
||||
{status === 'ok' && badgeIso ? <CountryFlag code={badgeIso} className="size-3.5" /> : null}
|
||||
{display}
|
||||
</Badge>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
import {
|
||||
collectServiceColumns,
|
||||
countryAgreement,
|
||||
filterIpregionRuns,
|
||||
hasGeoDisagreement,
|
||||
isGeoMismatch,
|
||||
uniqueCountries,
|
||||
} from './geo-filters'
|
||||
@@ -178,3 +180,15 @@ describe('uniqueCountries / mismatch', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasGeoDisagreement', () => {
|
||||
it('сервис, видящий другую страну, — расхождение даже при совпадении с инвентарём', () => {
|
||||
const base = run().results![0]!
|
||||
const agree = run({ results: [base, { ...base, id: 'r3', serviceKey: 'ipinfo.io' }] })
|
||||
expect(hasGeoDisagreement(agree)).toBe(false)
|
||||
expect(countryAgreement(agree)).toEqual({ iso: 'NL', same: 2, total: 2 })
|
||||
const split = run({ results: [base, { ...base, id: 'r3' }, { ...base, id: 'r4', countryIpv4: 'RU' }] })
|
||||
expect(countryAgreement(split)).toEqual({ iso: 'NL', same: 2, total: 3 })
|
||||
expect(hasGeoDisagreement(split)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -65,6 +65,27 @@ export function isGeoMismatch(run: IpregionRunDto): boolean {
|
||||
return inventory !== geo
|
||||
}
|
||||
|
||||
/** Сколько ok-ответов совпадают с большинством: «совпадает N из M» в колонке «Страна». */
|
||||
export function countryAgreement(run: IpregionRunDto): { iso: string | null; same: number; total: number } {
|
||||
const iso = majorityCountry(run)
|
||||
let same = 0
|
||||
let total = 0
|
||||
for (const row of run.results ?? []) {
|
||||
const code = resultCountryIso(row.countryIpv4) || resultCountryIso(row.countryIpv6)
|
||||
if (row.status !== 'ok' || !code) continue
|
||||
total += 1
|
||||
if (code === iso) same += 1
|
||||
}
|
||||
return { iso, same, total }
|
||||
}
|
||||
|
||||
/** Расхождение для KPI и шкалы: страна ≠ инвентарю или хоть один сервис видит другую страну. */
|
||||
export function hasGeoDisagreement(run: IpregionRunDto): boolean {
|
||||
if (isGeoMismatch(run)) return true
|
||||
const { same, total } = countryAgreement(run)
|
||||
return same < total
|
||||
}
|
||||
|
||||
export function uniqueCountries(runs: IpregionRunDto[]): string[] {
|
||||
const set = new Set<string>()
|
||||
for (const run of runs) {
|
||||
|
||||
@@ -5,9 +5,13 @@ import { GlobeIcon, ServerIcon } from 'lucide-react'
|
||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||
import { dataGridCellStack, dataGridCellWithIcon } from '@/components/data-grid-cells'
|
||||
import { columnDefFromDataGrid, FrameDataGrid } from '@/components/reui-kit'
|
||||
import { CountryFlag } from '@/components/country-flag'
|
||||
import { Progress } from '@cfdm/ui/components/progress'
|
||||
import {
|
||||
collectProbeColumns,
|
||||
collectServiceColumns,
|
||||
countryAgreement,
|
||||
majorityCountry,
|
||||
resultByService,
|
||||
type GeoServiceRow,
|
||||
} from './geo-filters'
|
||||
@@ -51,7 +55,10 @@ function vpsIdentityColumn(): DataGridColumn<IpregionRunDto> {
|
||||
) : (
|
||||
<span className="font-medium">Unknown VPS</span>
|
||||
)
|
||||
return dataGridCellStack(link, secondary)
|
||||
return dataGridCellWithIcon(
|
||||
<CountryFlag country={row.vps?.country} className="size-5" />,
|
||||
dataGridCellStack(link, secondary),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -60,20 +67,50 @@ export function GeoVpsGrid({
|
||||
runs,
|
||||
onRowClick,
|
||||
emptyAction,
|
||||
toolbar,
|
||||
}: {
|
||||
runs: IpregionRunDto[]
|
||||
onRowClick: (run: IpregionRunDto) => void
|
||||
emptyAction?: ReactNode
|
||||
/** Фильтры и вид — в шапке рамки (MikrotikManager v2). */
|
||||
toolbar?: ReactNode
|
||||
}) {
|
||||
const serviceCols = useMemo(() => collectServiceColumns(runs), [runs])
|
||||
const columns = useMemo((): DataGridColumn<IpregionRunDto>[] => {
|
||||
return [
|
||||
vpsIdentityColumn(),
|
||||
{
|
||||
key: 'consensus',
|
||||
header: 'Страна',
|
||||
size: 170,
|
||||
minSize: 150,
|
||||
sortValue: (row) => majorityCountry(row) ?? '',
|
||||
cell: (row) => {
|
||||
const { iso, same, total } = countryAgreement(row)
|
||||
const pct = total ? Math.round((same / total) * 100) : 0
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium tabular-nums">
|
||||
{iso ? <CountryFlag code={iso} /> : null}
|
||||
{iso ?? '—'}
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
{same}/{total}
|
||||
</span>
|
||||
</span>
|
||||
<Progress
|
||||
value={pct}
|
||||
aria-label={`Совпадает ${same} из ${total}`}
|
||||
className={pct < 100 ? '**:data-[slot=progress-indicator]:bg-warning' : '**:data-[slot=progress-indicator]:bg-success'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
...serviceCols.map(
|
||||
(svc): DataGridColumn<IpregionRunDto> => ({
|
||||
key: `svc:${svc.key}`,
|
||||
header: svc.label,
|
||||
headerTitle: svc.title,
|
||||
headerTitle: svc.label,
|
||||
icon: resolveServiceIcon(svc.key),
|
||||
className: MATRIX_CELL,
|
||||
headerClassName: MATRIX_CELL,
|
||||
@@ -85,6 +122,7 @@ export function GeoVpsGrid({
|
||||
const item = resultByService(row, svc.key)
|
||||
return (
|
||||
<CountryMatrixCell
|
||||
expected={majorityCountry(row)}
|
||||
status={item?.status}
|
||||
countryIpv4={item?.countryIpv4}
|
||||
countryIpv6={item?.countryIpv6}
|
||||
@@ -101,6 +139,9 @@ export function GeoVpsGrid({
|
||||
|
||||
return (
|
||||
<FrameDataGrid
|
||||
title="Страны по сервисам"
|
||||
description="Какую страну видят GeoIP-базы и сервисы"
|
||||
toolbar={toolbar}
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={runs}
|
||||
rowId={(row) => row.id}
|
||||
@@ -121,11 +162,14 @@ export function GeoServiceGrid({
|
||||
runs,
|
||||
onProbeClick,
|
||||
emptyAction,
|
||||
toolbar,
|
||||
}: {
|
||||
groups: GeoServiceRow[]
|
||||
runs: IpregionRunDto[]
|
||||
onProbeClick: (run: IpregionRunDto) => void
|
||||
emptyAction?: ReactNode
|
||||
/** Фильтры и вид — в шапке рамки (MikrotikManager v2). */
|
||||
toolbar?: ReactNode
|
||||
}) {
|
||||
const probeCols = useMemo(() => collectProbeColumns(runs), [runs])
|
||||
const runById = useMemo(() => new Map(runs.map((row) => [row.id, row])), [runs])
|
||||
@@ -184,6 +228,9 @@ export function GeoServiceGrid({
|
||||
|
||||
return (
|
||||
<FrameDataGrid
|
||||
title="Страны по сервисам"
|
||||
description="Какую страну видят GeoIP-базы и сервисы"
|
||||
toolbar={toolbar}
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={groups}
|
||||
rowId={(row) => row.id}
|
||||
|
||||
@@ -38,7 +38,7 @@ import { GeoServiceGrid, GeoVpsGrid } from './geo-grid'
|
||||
import { GeoRunSheet } from './geo-run-sheet'
|
||||
import {
|
||||
filterIpregionRuns,
|
||||
isGeoMismatch,
|
||||
hasGeoDisagreement,
|
||||
serviceMatrixRows,
|
||||
uniqueCountries,
|
||||
} from './geo-filters'
|
||||
@@ -135,7 +135,10 @@ export function GeoPage() {
|
||||
() => mergeCensorcheckRuns(currentQuery.data?.items ?? [], historyQuery.data?.items ?? []),
|
||||
[currentQuery.data?.items, historyQuery.data?.items],
|
||||
)
|
||||
const ticks = useMemo(() => collectSnapshotTicks(allRuns), [allRuns])
|
||||
const ticks = useMemo(
|
||||
() => collectSnapshotTicks(allRuns, (run) => (hasGeoDisagreement(run) ? 'wn' : 'ok')),
|
||||
[allRuns],
|
||||
)
|
||||
const resolvedIndex = resolveSnapshotIndex(ticks.length, snapshotIndex)
|
||||
const snapshotRuns = useMemo(() => {
|
||||
const asOf = ticks[resolvedIndex]?.asOf
|
||||
@@ -150,7 +153,7 @@ export function GeoPage() {
|
||||
|
||||
const matched = filtered.filter((row) => row.matchedVpsId).length
|
||||
const countries = uniqueCountries(filtered).length
|
||||
const mismatches = filtered.filter(isGeoMismatch).length
|
||||
const mismatches = filtered.filter(hasGeoDisagreement).length
|
||||
|
||||
const copyLauncher = (
|
||||
<>
|
||||
@@ -189,6 +192,35 @@ export function GeoPage() {
|
||||
</>
|
||||
)
|
||||
|
||||
const matrixToolbar = (
|
||||
<>
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={FILTER_FIELDS}
|
||||
onChange={setFilters}
|
||||
trigger={
|
||||
<Button type="button" variant="outline">
|
||||
Фильтры
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<ToggleGroup
|
||||
variant="outline"
|
||||
size="sm"
|
||||
spacing={0}
|
||||
value={[group]}
|
||||
onValueChange={(next) => {
|
||||
const selectedMode = next[0]
|
||||
if (selectedMode === 'vps' || selectedMode === 'service') setGroup(selectedMode)
|
||||
}}
|
||||
aria-label="Группировка"
|
||||
>
|
||||
<ToggleGroupItem value="vps">По VPS</ToggleGroupItem>
|
||||
<ToggleGroupItem value="service">По сервису</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
@@ -242,33 +274,8 @@ export function GeoPage() {
|
||||
ticks={ticks}
|
||||
index={resolvedIndex}
|
||||
onIndexChange={setSnapshotIndex}
|
||||
issueLabel="с расхождениями"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={FILTER_FIELDS}
|
||||
onChange={setFilters}
|
||||
trigger={
|
||||
<Button type="button" variant="outline">
|
||||
Фильтры
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<ToggleGroup
|
||||
variant="outline"
|
||||
size="sm"
|
||||
spacing={0}
|
||||
value={[group]}
|
||||
onValueChange={(next) => {
|
||||
const selectedMode = next[0]
|
||||
if (selectedMode === 'vps' || selectedMode === 'service') setGroup(selectedMode)
|
||||
}}
|
||||
aria-label="Группировка"
|
||||
>
|
||||
<ToggleGroupItem value="vps">По VPS</ToggleGroupItem>
|
||||
<ToggleGroupItem value="service">По сервису</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<QueryState
|
||||
data={filtered}
|
||||
isLoading={currentQuery.isLoading}
|
||||
@@ -284,12 +291,14 @@ export function GeoPage() {
|
||||
{(rows) =>
|
||||
group === 'vps' ? (
|
||||
<GeoVpsGrid
|
||||
toolbar={matrixToolbar}
|
||||
runs={rows}
|
||||
onRowClick={setSelected}
|
||||
emptyAction={copyLauncher}
|
||||
/>
|
||||
) : (
|
||||
<GeoServiceGrid
|
||||
toolbar={matrixToolbar}
|
||||
groups={serviceGroups}
|
||||
runs={rows}
|
||||
onProbeClick={setSelected}
|
||||
|
||||
Reference in new issue
Block a user