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:
DenozordecandClaude Opus 5.5 committed 2026-11-07 03:06:21 +07:00
1 parent a4a8b9562d
commit 8ae07628c6
15 files changed
+564 -224

No files matched your search

+78 -42
View File
@@ -174,56 +174,92 @@ for (const [key, status] of Object.entries(health)) {
.run(`nlog-demo-${i}`, event, channel, status, message, new Date(Date.now() - (i + 1) * 47 * 60_000).toISOString()), .run(`nlog-demo-${i}`, event, channel, status, message, new Date(Date.now() - (i + 1) * 47 * 60_000).toISOString()),
) )
// --- censorcheck (blocking) & ipregion (GeoIP) via the real ingest API // --- censorcheck (blocking) & ipregion (GeoIP) via the real ingest API, with a week of history
const ccServices: [string, Record<string, number>][] = [ const HOURS_AGO = [166, 142, 118, 94, 70, 48, 30, 18, 9, 4, 0] // points on the snapshot timeline
['youtube.com', { vpn1: 200, vpn2: 200, vpn3: 200, web1: 200 }], const CC_DPI = [
['instagram.com', { vpn1: 200, vpn2: 200, vpn3: 403, web1: 200 }], 'youtube.com', 'redirector.googlevideo.com', 'discord.com', 'instagram.com', 'facebook.com', 'x.com',
['chatgpt.com', { vpn1: 200, vpn2: 200, vpn3: 403, web1: 200 }], 'linkedin.com', 'rutracker.org', 'digitalocean.com', 'amnezia.org', 'getoutline.org', 'mailfence.com',
['discord.com', { vpn1: 200, vpn2: 0, vpn3: 200, web1: 200 }], 'flibusta.is', 'rezka.ag', 'api.telegram.org', 'play.google.com',
['x.com', { vpn1: 200, vpn2: 200, vpn3: 200, web1: 200 }],
['netflix.com', { vpn1: 302, vpn2: 200, vpn3: 403, web1: 200 }],
['spotify.com', { vpn1: 200, vpn2: 200, vpn3: 403, web1: 200 }],
['linkedin.com', { vpn1: 200, vpn2: 200, vpn3: 200, web1: 200 }],
] ]
const auth = () => ({ authorization: `Bearer ${mintIngestToken(SECRET)}` }) const CC_GEO = [
let n = 0 'spotify.com', 'netflix.com', 'patreon.com', 'swagger.io', 'snyk.io', 'mongodb.com', 'autodesk.com',
for (const key of ['vpn1', 'vpn2', 'vpn3', 'web1']) { 'graylog.org', 'redis.io', 'copilot.microsoft.com',
const ip = vpsList.find((v) => v.key === key)!.ip as string ]
n++ const RU_DPI = new Set(['youtube.com', 'redirector.googlevideo.com', 'discord.com', 'instagram.com', 'facebook.com', 'x.com', 'linkedin.com', 'rutracker.org', 'flibusta.is', 'rezka.ag', 'amnezia.org', 'getoutline.org'])
await api('POST', '/api/integrations/censorcheck/runs', { /** HTTP code a probe sees: 200 ok, 403 geoblock, -1 DPI block, 0 timeout. step = index in HOURS_AGO. */
schemaVersion: 1, function ccCode(key: string, service: string, step: number): number {
runId: `00000000-0000-4000-8000-00000000000${n}`, if (key === 'mon') return RU_DPI.has(service) ? (service === 'youtube.com' && step < 4 ? 200 : -1) : CC_GEO.includes(service) && ['netflix.com', 'copilot.microsoft.com'].includes(service) ? 403 : 200
probe: { publicIp: ip }, if (key === 'vpn3') {
launcherVersion: '1', if (['spotify.com', 'netflix.com', 'patreon.com', 'copilot.microsoft.com'].includes(service)) return 403
censorcheck: { version: '12c5839', mode: 'both' }, if (['instagram.com', 'facebook.com'].includes(service) && step >= 6) return -1
results: ccServices.map(([service, codes]) => ({ service, raw: { https: { ipv4: { status: codes[key] } } } })), return 200
}, auth()) }
if (key === 'vpn2' && service === 'discord.com' && step % 3 === 1) return 0
if (key === 'vpn1' && service === 'netflix.com') return 302
return 200
} }
const geo: Record<string, string> = { vpn1: 'FI', vpn2: 'DE', vpn3: 'KZ', web1: 'NL', us1: 'US' } const auth = () => ({ authorization: `Bearer ${mintIngestToken(SECRET)}` })
const ipOf = (key: string) => vpsList.find((v) => v.key === key)!.ip as string
const backdate = (table: string, runId: string, hoursAgo: number) => {
const at = new Date(Date.now() - hoursAgo * 3600_000 - Math.floor(Math.random() * 20) * 60_000).toISOString()
sqlite.prepare(`UPDATE ${table} SET createdAt = ?, completedAt = ? WHERE runId = ?`).run(at, at, runId)
}
let n = 0
const uuid = (kind: number) => `00000000-0000-4000-${kind}000-${String(++n).padStart(12, '0')}`
for (const key of ['web1', 'vpn1', 'vpn2', 'vpn3', 'mon', 'us1']) {
for (const [step, hoursAgo] of HOURS_AGO.entries()) {
const runId = uuid(8)
await api('POST', '/api/integrations/censorcheck/runs', {
schemaVersion: 1,
runId,
probe: { publicIp: ipOf(key) },
launcherVersion: '1',
censorcheck: { version: '12c5839', mode: 'both' },
results: [...CC_DPI, ...CC_GEO].map((service) => ({
service,
raw: { https: { ipv4: { status: ccCode(key, service, step) } } },
})),
}, auth())
backdate('censorcheck_runs', runId, hoursAgo)
}
}
// Keys from IPREGION_*_SERVICES so the matrix columns line up with the real vernette/ipregion list // Keys from IPREGION_*_SERVICES so the matrix columns line up with the real vernette/ipregion list
const geoServices: [string, string][] = [ const geoServices: [string, string][] = [
['maxmind.com', 'primary'], ['rdap.db.ripe.net', 'primary'], ['ipinfo.io', 'primary'], ['cloudflare.com', 'primary'], ['maxmind.com', 'primary'], ['rdap.db.ripe.net', 'primary'], ['ipinfo.io', 'primary'], ['cloudflare.com', 'primary'],
['ipregistry.co', 'primary'], ['ipapi.co', 'primary'], ['ifconfig.co', 'primary'], ['ip2location.io', 'primary'], ['ipregistry.co', 'primary'], ['ipapi.co', 'primary'], ['ifconfig.co', 'primary'], ['ip2location.io', 'primary'],
['google', 'custom'], ['youtube', 'custom'], ['netflix', 'custom'], ['chatgpt', 'custom'], ['iplocation.com', 'primary'], ['country.is', 'primary'], ['ipwho.is', 'primary'], ['ip-api.com', 'primary'],
['cloudflare cdn', 'cdn'], ['youtube cdn', 'cdn'], ['google', 'custom'], ['youtube', 'custom'], ['netflix', 'custom'], ['chatgpt', 'custom'], ['spotify', 'custom'],
['apple', 'custom'], ['steam', 'custom'], ['tiktok', 'custom'],
['cloudflare cdn', 'cdn'], ['youtube cdn', 'cdn'], ['netflix cdn', 'cdn'],
] ]
const geo: Record<string, string> = { web1: 'NL', vpn1: 'FI', vpn2: 'DE', vpn3: 'KZ', mon: 'RU', us1: 'US' }
for (const [key, cc] of Object.entries(geo)) { for (const [key, cc] of Object.entries(geo)) {
const ip = vpsList.find((v) => v.key === key)!.ip as string for (const [step, hoursAgo] of HOURS_AGO.entries()) {
n++ const runId = uuid(9)
await api('POST', '/api/integrations/ipregion/runs', { await api('POST', '/api/integrations/ipregion/runs', {
schemaVersion: 1, schemaVersion: 1,
runId: `00000000-0000-4000-9000-0000000000${String(n).padStart(2, '0')}`, runId,
probe: { publicIp: ip }, probe: { publicIp: ipOf(key) },
launcherVersion: '1', launcherVersion: '1',
ipregion: { version: '7d1c25c' }, ipregion: { version: '7d1c25c' },
results: geoServices.map(([service, group], i) => ({ results: geoServices.map(([service, group], i) => ({
service, service,
group, group,
// a few services see the KZ node elsewhere — the kind of mismatch the matrix highlights // KZ node: some bases and services still place it in RU — the mismatch the matrix highlights
ipv4: key === 'vpn3' && (i === 4 || i === 10) ? 'RU' : key === 'vpn1' && i === 9 ? 'SE' : cc, ipv4:
ipv6: key === 'web1' ? cc : null, key === 'vpn3' && (i === 4 || i === 9 || i === 14 || (step >= 7 && i === 12))
})), ? 'RU'
}, auth()) : key === 'vpn1' && service === 'youtube'
? 'SE'
: key === 'us1' && service === 'tiktok'
? 'N/A'
: cc,
ipv6: key === 'web1' ? cc : null,
})),
}, auth())
backdate('ipregion_runs', runId, hoursAgo)
}
} }
// --- topology diagram // --- topology diagram
@@ -168,7 +168,8 @@ describe('collectProbeColumns', () => {
describe('shortHostLabel', () => { describe('shortHostLabel', () => {
it('отрезает типичный TLD', () => { it('отрезает типичный TLD', () => {
expect(shortHostLabel('youtube.com')).toBe('youtube') 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 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 { export function shortHostLabel(value: string): string {
const host = value.trim().split('/')[0] ?? value const host = (value.trim().split('/')[0] ?? value).toLowerCase()
return host.replace(/\.(com|org|net|io|ag|is)$/i, '') return HOST_ALIASES[host] ?? host.replace(/\.(com|org|net|io|ag|is)$/i, '')
} }
const CANONICAL_SERVICES = [...CENSORCHECK_DPI_HOSTS, ...CENSORCHECK_GEOBLOCK_HOSTS] 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 type { DataGridColumn } from '@/components/data-grid-types'
import { dataGridCellStack, dataGridCellWithIcon } from '@/components/data-grid-cells' import { dataGridCellStack, dataGridCellWithIcon } from '@/components/data-grid-cells'
import { columnDefFromDataGrid, FrameDataGrid } from '@/components/reui-kit' import { columnDefFromDataGrid, FrameDataGrid } from '@/components/reui-kit'
import { CountryFlag } from '@/components/country-flag'
import { Progress } from '@cfdm/ui/components/progress'
import { import {
collectProbeColumns, collectProbeColumns,
collectServiceColumns, collectServiceColumns,
@@ -51,7 +53,10 @@ function vpsIdentityColumn(): DataGridColumn<CensorcheckRunDto> {
) : ( ) : (
<span className="font-medium">Unknown VPS</span> <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, runs,
onRowClick, onRowClick,
emptyAction, emptyAction,
toolbar,
}: { }: {
runs: CensorcheckRunDto[] runs: CensorcheckRunDto[]
onRowClick: (run: CensorcheckRunDto) => void onRowClick: (run: CensorcheckRunDto) => void
emptyAction?: ReactNode emptyAction?: ReactNode
/** Фильтры и вид — в шапке рамки (MikrotikManager v2). */
toolbar?: ReactNode
}) { }) {
const serviceCols = useMemo(() => collectServiceColumns(runs), [runs]) const serviceCols = useMemo(() => collectServiceColumns(runs), [runs])
const columns = useMemo((): DataGridColumn<CensorcheckRunDto>[] => { const columns = useMemo((): DataGridColumn<CensorcheckRunDto>[] => {
return [ return [
vpsIdentityColumn(), 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( ...serviceCols.map(
(svc): DataGridColumn<CensorcheckRunDto> => ({ (svc): DataGridColumn<CensorcheckRunDto> => ({
key: `svc:${svc.key}`, key: `svc:${svc.key}`,
header: svc.label, header: svc.label,
headerTitle: svc.title, headerTitle: svc.label,
icon: resolveServiceIcon(svc.key), icon: resolveServiceIcon(svc.key),
className: MATRIX_CELL, className: MATRIX_CELL,
headerClassName: MATRIX_CELL, headerClassName: MATRIX_CELL,
@@ -100,6 +140,9 @@ export function BlockingVpsGrid({
return ( return (
<FrameDataGrid <FrameDataGrid
title="Текущие пробы"
description="Доступность сервисов с каждого VPS"
toolbar={toolbar}
columns={columnDefFromDataGrid(columns)} columns={columnDefFromDataGrid(columns)}
data={runs} data={runs}
rowId={(row) => row.id} rowId={(row) => row.id}
@@ -120,11 +163,14 @@ export function BlockingServiceGrid({
runs, runs,
onProbeClick, onProbeClick,
emptyAction, emptyAction,
toolbar,
}: { }: {
groups: BlockingServiceRow[] groups: BlockingServiceRow[]
runs: CensorcheckRunDto[] runs: CensorcheckRunDto[]
onProbeClick: (run: CensorcheckRunDto) => void onProbeClick: (run: CensorcheckRunDto) => void
emptyAction?: ReactNode emptyAction?: ReactNode
/** Фильтры и вид — в шапке рамки (MikrotikManager v2). */
toolbar?: ReactNode
}) { }) {
const probeCols = useMemo(() => collectProbeColumns(runs), [runs]) const probeCols = useMemo(() => collectProbeColumns(runs), [runs])
const runById = useMemo(() => new Map(runs.map((row) => [row.id, row])), [runs]) const runById = useMemo(() => new Map(runs.map((row) => [row.id, row])), [runs])
@@ -180,6 +226,9 @@ export function BlockingServiceGrid({
return ( return (
<FrameDataGrid <FrameDataGrid
title="Текущие пробы"
description="Доступность сервисов с каждого VPS"
toolbar={toolbar}
columns={columnDefFromDataGrid(columns)} columns={columnDefFromDataGrid(columns)}
data={groups} data={groups}
rowId={(row) => row.id} rowId={(row) => row.id}
@@ -131,7 +131,15 @@ export function BlockingPage() {
() => mergeCensorcheckRuns(currentQuery.data?.items ?? [], historyQuery.data?.items ?? []), () => mergeCensorcheckRuns(currentQuery.data?.items ?? [], historyQuery.data?.items ?? []),
[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 resolvedIndex = resolveSnapshotIndex(ticks.length, snapshotIndex)
const snapshotRuns = useMemo(() => { const snapshotRuns = useMemo(() => {
const asOf = ticks[resolvedIndex]?.asOf 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 ( return (
<PageShell> <PageShell>
<PageHeader <PageHeader
@@ -237,33 +274,8 @@ export function BlockingPage() {
ticks={ticks} ticks={ticks}
index={resolvedIndex} index={resolvedIndex}
onIndexChange={setSnapshotIndex} 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 <QueryState
data={filtered} data={filtered}
isLoading={currentQuery.isLoading} isLoading={currentQuery.isLoading}
@@ -279,12 +291,14 @@ export function BlockingPage() {
{(rows) => {(rows) =>
group === 'vps' ? ( group === 'vps' ? (
<BlockingVpsGrid <BlockingVpsGrid
toolbar={matrixToolbar}
runs={rows} runs={rows}
onRowClick={setSelected} onRowClick={setSelected}
emptyAction={copyLauncher} emptyAction={copyLauncher}
/> />
) : ( ) : (
<BlockingServiceGrid <BlockingServiceGrid
toolbar={matrixToolbar}
groups={serviceGroups} groups={serviceGroups}
runs={rows} runs={rows}
onProbeClick={setSelected} 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 { Badge } from '@/components/reui/badge'
import { Frame, FramePanel } from '@/components/reui/frame' import { Frame, FramePanel } from '@/components/reui/frame'
import { import { IconTile } from '@/components/reui/icon-tile'
Timeline, import { Button } from '@cfdm/ui/components/button'
TimelineDate, import { ToggleGroup, ToggleGroupItem } from '@cfdm/ui/components/toggle-group'
TimelineHeader, import { Tooltip, TooltipContent, TooltipTrigger } from '@cfdm/ui/components/tooltip'
TimelineIndicator,
TimelineItem,
TimelineSeparator,
} from '@/components/reui/timeline'
import { Slider } from '@cfdm/ui/components/slider'
import { cn } from '@cfdm/ui/lib/utils' import { cn } from '@cfdm/ui/lib/utils'
import { import { tickIndexAtOrBefore, type BlockingSnapshotTick, type SnapshotTone } from './blocking-snapshots'
formatSnapshotTickLabel,
type BlockingSnapshotTick,
} from './blocking-snapshots'
import { formatCheckedAt } from './types'
/** 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({ export function BlockingSnapshotScrubber({
ticks, ticks,
index, index,
onIndexChange, onIndexChange,
describeTick,
issueLabel = 'с проблемами',
}: { }: {
ticks: BlockingSnapshotTick[] ticks: BlockingSnapshotTick[]
index: number index: number
onIndexChange: (index: number) => void onIndexChange: (index: number) => void
/** Подпись под временем выбранной точки, напр. «4 пробы · 2 блокировки». */
describeTick?: (tick: BlockingSnapshotTick) => string
issueLabel?: string
}) { }) {
if (ticks.length === 0) return null if (ticks.length === 0) return null
const selected = ticks[index] ?? ticks[ticks.length - 1]!
const lastIndex = ticks.length - 1 const lastIndex = ticks.length - 1
const selected = ticks[index] ?? ticks[lastIndex]!
const isLatest = index >= 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 presetIndex = (ms: number) => (ms === 0 ? lastIndex : tickIndexAtOrBefore(ticks, latestMs - ms))
const value = Array.isArray(next) ? next[0] : next // Подсвечиваем только «Сейчас»: «−N ч» — переход к ближайшей точке, не режим.
if (typeof value === 'number') onIndexChange(value) 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 ( return (
<Frame dense variant="default" spacing="sm" className="w-full min-w-0"> <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"> <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 flex-wrap items-center gap-3">
<div className="flex min-w-0 flex-col gap-px"> <IconTile variant="soft" size="sm" className={TONE_TEXT[selected.tone]} aria-hidden="true">
<span className="text-sm font-medium"> <HistoryIcon />
{isLatest ? 'Сейчас' : formatSnapshotTickLabel(selected.key)} </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>
<span className="text-muted-foreground text-xs tabular-nums"> <span className="text-muted-foreground text-xs">
{formatCheckedAt(selected.asOf)} {describeTick
? describeTick(selected)
: `${selected.count} проб · ${selected.issues} ${issueLabel}`}
</span> </span>
</div> </div>
<Badge size="sm" variant={isLatest ? 'success' : 'outline'}> <ToggleGroup
{isLatest ? 'Актуально' : 'Снимок'} variant="outline"
</Badge> 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> </div>
<Slider <div
value={[index]} role="toolbar"
min={0} aria-label="Точки измерений: ← → для перехода"
max={max} tabIndex={0}
step={1} onKeyDown={onStripKey}
disabled={ticks.length <= 1} className="focus-visible:ring-ring/50 flex h-10 items-end justify-between gap-1 rounded-md outline-none focus-visible:ring-2"
onValueChange={handleSlider} >
aria-label="Снимок проверок на дату" {ticks.map((tick, i) => (
/> <Tooltip key={tick.key}>
<TooltipTrigger
<div className="min-w-0 overflow-x-auto"> render={
<Timeline <button
orientation="horizontal" type="button"
value={index + 1} tabIndex={-1}
onValueChange={(step) => onIndexChange(Math.max(0, step - 1))} aria-label={`${formatTickTime(tick.asOf)}: ${tick.count} проб, ${tick.issues} ${issueLabel}`}
className="min-w-max" aria-pressed={i === index}
> onClick={() => go(i)}
{ticks.map((tick, tickIndex) => { className={cn(
const showLabel = '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',
ticks.length <= 8 || i === index && 'ring-foreground ring-offset-background ring-2 ring-offset-1',
tickIndex === 0 || )}
tickIndex === lastIndex || style={{ height: `${30 + (70 * tick.count) / maxCount}%` }}
tickIndex === index />
return ( }
<TimelineItem >
key={tick.key} {tick.issues > 0 ? (
step={tickIndex + 1} <span
className="cursor-pointer" aria-hidden="true"
onClick={() => onIndexChange(tickIndex)} className={cn('block w-full', TONE_FILL[tick.tone])}
> style={{ height: `${Math.max(12, (100 * tick.issues) / tick.count)}%` }}
<TimelineHeader> />
<TimelineSeparator /> ) : null}
<TimelineDate </TooltipTrigger>
className={cn( <TooltipContent>
'text-[11px] tabular-nums', {formatTickTime(tick.asOf)} · {tick.count} проб · {tick.issues} {issueLabel}
showLabel ? undefined : 'invisible', </TooltipContent>
)} </Tooltip>
> ))}
{formatSnapshotTickLabel(tick.key)} </div>
</TimelineDate> <div className="text-muted-foreground flex justify-between text-[11px] tabular-nums">
<TimelineIndicator className="size-2.5" /> <span>{formatTickTime(ticks[0]!.asOf)}</span>
</TimelineHeader> {ticks.length > 1 ? <span>{formatTickTime(ticks[lastIndex]!.asOf)}</span> : null}
</TimelineItem>
)
})}
</Timeline>
</div> </div>
</FramePanel> </FramePanel>
</Frame> </Frame>
@@ -5,7 +5,7 @@ import {
latestRunsAsOf, latestRunsAsOf,
mergeCensorcheckRuns, mergeCensorcheckRuns,
resolveSnapshotIndex, resolveSnapshotIndex,
snapshotDayKey, tickIndexAtOrBefore,
} from './blocking-snapshots' } from './blocking-snapshots'
import type { CensorcheckRunDto } from './types' import type { CensorcheckRunDto } from './types'
@@ -48,18 +48,30 @@ describe('mergeCensorcheckRuns', () => {
}) })
describe('collectSnapshotTicks', () => { 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([ const ticks = collectSnapshotTicks([
run({ id: '1', createdAt: '2026-08-20T08:00:00.000Z' }), run({ id: '1', createdAt: '2026-08-20T08:00:00.000Z' }),
run({ id: '2', createdAt: '2026-08-20T18:00:00.000Z' }), run({ id: '2', createdAt: '2026-08-20T12:00:00.000Z' }),
run({ id: '3', createdAt: '2026-08-21T12:00:00.000Z' }),
]) ])
expect(ticks.map((tick) => tick.key)).toEqual([ expect(tickIndexAtOrBefore(ticks, Date.parse('2026-08-20T10:00:00.000Z'))).toBe(0)
snapshotDayKey('2026-08-20T08:00:00.000Z'), expect(tickIndexAtOrBefore(ticks, Date.parse('2026-08-20T07:00:00.000Z'))).toBe(-1)
snapshotDayKey('2026-08-21T12:00:00.000Z'),
])
expect(ticks[0]?.count).toBe(2)
expect(ticks[0]?.asOf).toBe('2026-08-20T18:00:00.000Z')
}) })
}) })
@@ -4,10 +4,26 @@ export type SnapshotRun = {
probePublicIp: string probePublicIp: string
} }
export type SnapshotTone = 'ok' | 'wn' | 'er'
export type BlockingSnapshotTick = { export type BlockingSnapshotTick = {
/** Час измерения `YYYY-MM-DDTHH` (UTC) — точка на шкале времени. */
key: string key: string
asOf: string asOf: string
count: number 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 { export function snapshotDayKey(iso: string): string {
@@ -38,25 +54,42 @@ export function mergeCensorcheckRuns<T extends { id: string }>(
return [...map.values()] return [...map.values()]
} }
export function collectSnapshotTicks(runs: SnapshotRun[]): BlockingSnapshotTick[] { export function collectSnapshotTicks<T extends SnapshotRun>(
const byDay = new Map<string, SnapshotRun[]>() runs: T[],
toneOf: (run: T) => SnapshotTone = () => 'ok',
): BlockingSnapshotTick[] {
const byHour = new Map<string, T[]>()
for (const run of runs) { for (const run of runs) {
const key = snapshotDayKey(run.createdAt) const key = snapshotHourKey(run.createdAt)
const list = byDay.get(key) const list = byHour.get(key)
if (list) list.push(run) 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)) .sort(([left], [right]) => left.localeCompare(right))
.map(([key, list]) => ({ .map(([key, list]) => {
key, const tones = list.map(toneOf)
count: list.length, return {
asOf: list.reduce( key,
(latest, run) => (run.createdAt > latest ? run.createdAt : latest), count: list.length,
list[0]!.createdAt, 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`. */ /** Latest run per probe IP at or before `asOf`. */
@@ -13,9 +13,9 @@ import { MATRIX_STATUS } from './status-matrix-cell'
describe('MATRIX_STATUS', () => { describe('MATRIX_STATUS', () => {
it('статусы — lucide-иконки, не текст ОК/Блок', () => { it('статусы — lucide-иконки, не текст ОК/Блок', () => {
expect(MATRIX_STATUS.available.icon).toBe(CheckIcon) 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.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.denied.icon).toBe(BanIcon)
expect(MATRIX_STATUS.timeout.icon).toBe(ClockIcon) expect(MATRIX_STATUS.timeout.icon).toBe(ClockIcon)
expect(MATRIX_STATUS.redirected.icon).toBe(CornerUpRightIcon) expect(MATRIX_STATUS.redirected.icon).toBe(CornerUpRightIcon)
@@ -9,7 +9,7 @@ import {
XIcon, XIcon,
} from 'lucide-react' } from 'lucide-react'
import { Badge } from '@/components/reui/badge' import { IconTile } from '@/components/reui/icon-tile'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -17,17 +17,17 @@ import {
} from '@cfdm/ui/components/tooltip' } from '@cfdm/ui/components/tooltip'
import { CENSORCHECK_STATUS_LABELS, formatCheckedAt } from './types' 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< * Статус-тайл ячейки матрицы: ReUI IconTile `soft` перекрашивается одним text-* классом
string, * (так велит API IconTile — без ad-hoc bg). Preview: https://reui.io/docs/components/base/icon-tile
{ icon: LucideIcon; variant: 'success-light' | 'destructive-light' | 'destructive-outline' | 'warning-light' | 'info-light' | 'warning-outline' } */
> = { export const MATRIX_STATUS: Record<string, { icon: LucideIcon; tone: string }> = {
available: { icon: CheckIcon, variant: 'success-light' }, available: { icon: CheckIcon, tone: 'text-success' },
blocked: { icon: XIcon, variant: 'destructive-light' }, blocked: { icon: XIcon, tone: 'text-destructive' },
denied: { icon: BanIcon, variant: 'destructive-outline' }, denied: { icon: BanIcon, tone: 'text-destructive' },
timeout: { icon: ClockIcon, variant: 'warning-light' }, timeout: { icon: ClockIcon, tone: 'text-warning' },
redirected: { icon: CornerUpRightIcon, variant: 'info-light' }, redirected: { icon: CornerUpRightIcon, tone: 'text-info' },
error: { icon: CircleAlertIcon, variant: 'warning-outline' }, error: { icon: CircleAlertIcon, tone: 'text-warning' },
} }
export function StatusMatrixCell({ export function StatusMatrixCell({
@@ -48,7 +48,7 @@ export function StatusMatrixCell({
const full = status ? (CENSORCHECK_STATUS_LABELS[status] ?? status) : 'Нет результата' const full = status ? (CENSORCHECK_STATUS_LABELS[status] ?? status) : 'Нет результата'
const mapped = status ? MATRIX_STATUS[status] : undefined const mapped = status ? MATRIX_STATUS[status] : undefined
const Icon = mapped?.icon ?? MinusIcon const Icon = mapped?.icon ?? MinusIcon
const variant = mapped?.variant ?? 'outline' const tone = mapped?.tone ?? 'text-muted-foreground'
const tip = [ const tip = [
serviceLabel, serviceLabel,
vpsLabel, vpsLabel,
@@ -60,9 +60,9 @@ export function StatusMatrixCell({
.join(' · ') .join(' · ')
const badge = ( const badge = (
<Badge variant={variant} size="sm" radius="full" aria-label={full}> <IconTile variant={mapped ? 'soft' : 'outline'} size="xs" radius="full" className={tone} aria-label={full}>
<Icon className="size-3" /> <Icon />
</Badge> </IconTile>
) )
return ( return (
@@ -1,4 +1,5 @@
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { CountryFlag } from '@/components/country-flag'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -43,7 +44,10 @@ export function CountryMatrixCell({
vpsLabel, vpsLabel,
checkedAt, checkedAt,
onSelect, onSelect,
expected,
}: { }: {
/** Ожидаемая страна (большинство сервисов в строке) — отличие подсвечивается как расхождение. */
expected?: string | null
status?: string | null status?: string | null
countryIpv4?: string | null countryIpv4?: string | null
countryIpv6?: string | null countryIpv6?: string | null
@@ -56,8 +60,17 @@ export function CountryMatrixCell({
const badgeIso = countryBadgeText(stored) const badgeIso = countryBadgeText(stored)
const statusLabel = status ? (IPREGION_STATUS_LABELS[status] ?? status) : 'Нет результата' const statusLabel = status ? (IPREGION_STATUS_LABELS[status] ?? status) : 'Нет результата'
const display = status === 'ok' && badgeIso ? badgeIso : 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 = [ const tip = [
mismatch ? `Расхождение: большинство сервисов видят ${expected}` : null,
serviceLabel, serviceLabel,
vpsLabel, vpsLabel,
formatTipCountry(stored) ?? statusLabel, formatTipCountry(stored) ?? statusLabel,
@@ -69,7 +82,8 @@ export function CountryMatrixCell({
.join(' · ') .join(' · ')
const badge = ( 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} {display}
</Badge> </Badge>
) )
@@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'
import type { Filter } from '@/components/reui/filters' import type { Filter } from '@/components/reui/filters'
import { import {
collectServiceColumns, collectServiceColumns,
countryAgreement,
filterIpregionRuns, filterIpregionRuns,
hasGeoDisagreement,
isGeoMismatch, isGeoMismatch,
uniqueCountries, uniqueCountries,
} from './geo-filters' } from './geo-filters'
@@ -178,3 +180,15 @@ describe('uniqueCountries / mismatch', () => {
).toBe(false) ).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 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[] { export function uniqueCountries(runs: IpregionRunDto[]): string[] {
const set = new Set<string>() const set = new Set<string>()
for (const run of runs) { for (const run of runs) {
+49 -2
View File
@@ -5,9 +5,13 @@ import { GlobeIcon, ServerIcon } from 'lucide-react'
import type { DataGridColumn } from '@/components/data-grid-types' import type { DataGridColumn } from '@/components/data-grid-types'
import { dataGridCellStack, dataGridCellWithIcon } from '@/components/data-grid-cells' import { dataGridCellStack, dataGridCellWithIcon } from '@/components/data-grid-cells'
import { columnDefFromDataGrid, FrameDataGrid } from '@/components/reui-kit' import { columnDefFromDataGrid, FrameDataGrid } from '@/components/reui-kit'
import { CountryFlag } from '@/components/country-flag'
import { Progress } from '@cfdm/ui/components/progress'
import { import {
collectProbeColumns, collectProbeColumns,
collectServiceColumns, collectServiceColumns,
countryAgreement,
majorityCountry,
resultByService, resultByService,
type GeoServiceRow, type GeoServiceRow,
} from './geo-filters' } from './geo-filters'
@@ -51,7 +55,10 @@ function vpsIdentityColumn(): DataGridColumn<IpregionRunDto> {
) : ( ) : (
<span className="font-medium">Unknown VPS</span> <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, runs,
onRowClick, onRowClick,
emptyAction, emptyAction,
toolbar,
}: { }: {
runs: IpregionRunDto[] runs: IpregionRunDto[]
onRowClick: (run: IpregionRunDto) => void onRowClick: (run: IpregionRunDto) => void
emptyAction?: ReactNode emptyAction?: ReactNode
/** Фильтры и вид — в шапке рамки (MikrotikManager v2). */
toolbar?: ReactNode
}) { }) {
const serviceCols = useMemo(() => collectServiceColumns(runs), [runs]) const serviceCols = useMemo(() => collectServiceColumns(runs), [runs])
const columns = useMemo((): DataGridColumn<IpregionRunDto>[] => { const columns = useMemo((): DataGridColumn<IpregionRunDto>[] => {
return [ return [
vpsIdentityColumn(), 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( ...serviceCols.map(
(svc): DataGridColumn<IpregionRunDto> => ({ (svc): DataGridColumn<IpregionRunDto> => ({
key: `svc:${svc.key}`, key: `svc:${svc.key}`,
header: svc.label, header: svc.label,
headerTitle: svc.title, headerTitle: svc.label,
icon: resolveServiceIcon(svc.key), icon: resolveServiceIcon(svc.key),
className: MATRIX_CELL, className: MATRIX_CELL,
headerClassName: MATRIX_CELL, headerClassName: MATRIX_CELL,
@@ -85,6 +122,7 @@ export function GeoVpsGrid({
const item = resultByService(row, svc.key) const item = resultByService(row, svc.key)
return ( return (
<CountryMatrixCell <CountryMatrixCell
expected={majorityCountry(row)}
status={item?.status} status={item?.status}
countryIpv4={item?.countryIpv4} countryIpv4={item?.countryIpv4}
countryIpv6={item?.countryIpv6} countryIpv6={item?.countryIpv6}
@@ -101,6 +139,9 @@ export function GeoVpsGrid({
return ( return (
<FrameDataGrid <FrameDataGrid
title="Страны по сервисам"
description="Какую страну видят GeoIP-базы и сервисы"
toolbar={toolbar}
columns={columnDefFromDataGrid(columns)} columns={columnDefFromDataGrid(columns)}
data={runs} data={runs}
rowId={(row) => row.id} rowId={(row) => row.id}
@@ -121,11 +162,14 @@ export function GeoServiceGrid({
runs, runs,
onProbeClick, onProbeClick,
emptyAction, emptyAction,
toolbar,
}: { }: {
groups: GeoServiceRow[] groups: GeoServiceRow[]
runs: IpregionRunDto[] runs: IpregionRunDto[]
onProbeClick: (run: IpregionRunDto) => void onProbeClick: (run: IpregionRunDto) => void
emptyAction?: ReactNode emptyAction?: ReactNode
/** Фильтры и вид — в шапке рамки (MikrotikManager v2). */
toolbar?: ReactNode
}) { }) {
const probeCols = useMemo(() => collectProbeColumns(runs), [runs]) const probeCols = useMemo(() => collectProbeColumns(runs), [runs])
const runById = useMemo(() => new Map(runs.map((row) => [row.id, row])), [runs]) const runById = useMemo(() => new Map(runs.map((row) => [row.id, row])), [runs])
@@ -184,6 +228,9 @@ export function GeoServiceGrid({
return ( return (
<FrameDataGrid <FrameDataGrid
title="Страны по сервисам"
description="Какую страну видят GeoIP-базы и сервисы"
toolbar={toolbar}
columns={columnDefFromDataGrid(columns)} columns={columnDefFromDataGrid(columns)}
data={groups} data={groups}
rowId={(row) => row.id} rowId={(row) => row.id}
+38 -29
View File
@@ -38,7 +38,7 @@ import { GeoServiceGrid, GeoVpsGrid } from './geo-grid'
import { GeoRunSheet } from './geo-run-sheet' import { GeoRunSheet } from './geo-run-sheet'
import { import {
filterIpregionRuns, filterIpregionRuns,
isGeoMismatch, hasGeoDisagreement,
serviceMatrixRows, serviceMatrixRows,
uniqueCountries, uniqueCountries,
} from './geo-filters' } from './geo-filters'
@@ -135,7 +135,10 @@ export function GeoPage() {
() => mergeCensorcheckRuns(currentQuery.data?.items ?? [], historyQuery.data?.items ?? []), () => mergeCensorcheckRuns(currentQuery.data?.items ?? [], historyQuery.data?.items ?? []),
[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 resolvedIndex = resolveSnapshotIndex(ticks.length, snapshotIndex)
const snapshotRuns = useMemo(() => { const snapshotRuns = useMemo(() => {
const asOf = ticks[resolvedIndex]?.asOf const asOf = ticks[resolvedIndex]?.asOf
@@ -150,7 +153,7 @@ export function GeoPage() {
const matched = filtered.filter((row) => row.matchedVpsId).length const matched = filtered.filter((row) => row.matchedVpsId).length
const countries = uniqueCountries(filtered).length const countries = uniqueCountries(filtered).length
const mismatches = filtered.filter(isGeoMismatch).length const mismatches = filtered.filter(hasGeoDisagreement).length
const copyLauncher = ( 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 ( return (
<PageShell> <PageShell>
<PageHeader <PageHeader
@@ -242,33 +274,8 @@ export function GeoPage() {
ticks={ticks} ticks={ticks}
index={resolvedIndex} index={resolvedIndex}
onIndexChange={setSnapshotIndex} 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 <QueryState
data={filtered} data={filtered}
isLoading={currentQuery.isLoading} isLoading={currentQuery.isLoading}
@@ -284,12 +291,14 @@ export function GeoPage() {
{(rows) => {(rows) =>
group === 'vps' ? ( group === 'vps' ? (
<GeoVpsGrid <GeoVpsGrid
toolbar={matrixToolbar}
runs={rows} runs={rows}
onRowClick={setSelected} onRowClick={setSelected}
emptyAction={copyLauncher} emptyAction={copyLauncher}
/> />
) : ( ) : (
<GeoServiceGrid <GeoServiceGrid
toolbar={matrixToolbar}
groups={serviceGroups} groups={serviceGroups}
runs={rows} runs={rows}
onProbeClick={setSelected} onProbeClick={setSelected}