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()),
)
// --- censorcheck (blocking) & ipregion (GeoIP) via the real ingest API
const ccServices: [string, Record<string, number>][] = [
['youtube.com', { vpn1: 200, vpn2: 200, vpn3: 200, web1: 200 }],
['instagram.com', { vpn1: 200, vpn2: 200, vpn3: 403, web1: 200 }],
['chatgpt.com', { vpn1: 200, vpn2: 200, vpn3: 403, web1: 200 }],
['discord.com', { vpn1: 200, vpn2: 0, vpn3: 200, web1: 200 }],
['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 }],
// --- censorcheck (blocking) & ipregion (GeoIP) via the real ingest API, with a week of history
const HOURS_AGO = [166, 142, 118, 94, 70, 48, 30, 18, 9, 4, 0] // points on the snapshot timeline
const CC_DPI = [
'youtube.com', 'redirector.googlevideo.com', 'discord.com', 'instagram.com', 'facebook.com', 'x.com',
'linkedin.com', 'rutracker.org', 'digitalocean.com', 'amnezia.org', 'getoutline.org', 'mailfence.com',
'flibusta.is', 'rezka.ag', 'api.telegram.org', 'play.google.com',
]
const auth = () => ({ authorization: `Bearer ${mintIngestToken(SECRET)}` })
let n = 0
for (const key of ['vpn1', 'vpn2', 'vpn3', 'web1']) {
const ip = vpsList.find((v) => v.key === key)!.ip as string
n++
await api('POST', '/api/integrations/censorcheck/runs', {
schemaVersion: 1,
runId: `00000000-0000-4000-8000-00000000000${n}`,
probe: { publicIp: ip },
launcherVersion: '1',
censorcheck: { version: '12c5839', mode: 'both' },
results: ccServices.map(([service, codes]) => ({ service, raw: { https: { ipv4: { status: codes[key] } } } })),
}, auth())
const CC_GEO = [
'spotify.com', 'netflix.com', 'patreon.com', 'swagger.io', 'snyk.io', 'mongodb.com', 'autodesk.com',
'graylog.org', 'redis.io', 'copilot.microsoft.com',
]
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'])
/** HTTP code a probe sees: 200 ok, 403 geoblock, -1 DPI block, 0 timeout. step = index in HOURS_AGO. */
function ccCode(key: string, service: string, step: number): number {
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
if (key === 'vpn3') {
if (['spotify.com', 'netflix.com', 'patreon.com', 'copilot.microsoft.com'].includes(service)) return 403
if (['instagram.com', 'facebook.com'].includes(service) && step >= 6) return -1
return 200
}
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
const geoServices: [string, string][] = [
['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'],
['google', 'custom'], ['youtube', 'custom'], ['netflix', 'custom'], ['chatgpt', 'custom'],
['cloudflare cdn', 'cdn'], ['youtube cdn', 'cdn'],
['iplocation.com', 'primary'], ['country.is', 'primary'], ['ipwho.is', 'primary'], ['ip-api.com', 'primary'],
['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)) {
const ip = vpsList.find((v) => v.key === key)!.ip as string
n++
await api('POST', '/api/integrations/ipregion/runs', {
schemaVersion: 1,
runId: `00000000-0000-4000-9000-0000000000${String(n).padStart(2, '0')}`,
probe: { publicIp: ip },
launcherVersion: '1',
ipregion: { version: '7d1c25c' },
results: geoServices.map(([service, group], i) => ({
service,
group,
// a few services see the KZ node elsewhere — the kind of mismatch the matrix highlights
ipv4: key === 'vpn3' && (i === 4 || i === 10) ? 'RU' : key === 'vpn1' && i === 9 ? 'SE' : cc,
ipv6: key === 'web1' ? cc : null,
})),
}, auth())
for (const [step, hoursAgo] of HOURS_AGO.entries()) {
const runId = uuid(9)
await api('POST', '/api/integrations/ipregion/runs', {
schemaVersion: 1,
runId,
probe: { publicIp: ipOf(key) },
launcherVersion: '1',
ipregion: { version: '7d1c25c' },
results: geoServices.map(([service, group], i) => ({
service,
group,
// KZ node: some bases and services still place it in RU — the mismatch the matrix highlights
ipv4:
key === 'vpn3' && (i === 4 || i === 9 || i === 14 || (step >= 7 && i === 12))
? 'RU'
: 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
@@ -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) {
+49 -2
View File
@@ -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 -29
View File
@@ -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}