diff --git a/apps/api/scripts/seed-demo.ts b/apps/api/scripts/seed-demo.ts index d9b5bbc..cd0e150 100644 --- a/apps/api/scripts/seed-demo.ts +++ b/apps/api/scripts/seed-demo.ts @@ -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][] = [ - ['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 = { 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 = { 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 diff --git a/apps/web/src/components/censorcheck/blocking-filters.test.ts b/apps/web/src/components/censorcheck/blocking-filters.test.ts index 5957d99..c9f17e3 100644 --- a/apps/web/src/components/censorcheck/blocking-filters.test.ts +++ b/apps/web/src/components/censorcheck/blocking-filters.test.ts @@ -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') }) }) diff --git a/apps/web/src/components/censorcheck/blocking-filters.ts b/apps/web/src/components/censorcheck/blocking-filters.ts index 6b4f775..1fe832e 100644 --- a/apps/web/src/components/censorcheck/blocking-filters.ts +++ b/apps/web/src/components/censorcheck/blocking-filters.ts @@ -121,9 +121,17 @@ export type MatrixColumn = { title: string } +/** Короткие подписи колонок матрицы: полный хост остаётся в подсказке и карточке прогона. */ +const HOST_ALIASES: Record = { + '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] diff --git a/apps/web/src/components/censorcheck/blocking-grid.tsx b/apps/web/src/components/censorcheck/blocking-grid.tsx index 4979ec2..35493d4 100644 --- a/apps/web/src/components/censorcheck/blocking-grid.tsx +++ b/apps/web/src/components/censorcheck/blocking-grid.tsx @@ -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 { ) : ( Unknown VPS ) - return dataGridCellStack(link, secondary) + return dataGridCellWithIcon( + , + 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[] => { 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 ( +
+ + {available} + из {total} + + +
+ ) + }, + }, ...serviceCols.map( (svc): DataGridColumn => ({ 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 ( 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 ( row.id} diff --git a/apps/web/src/components/censorcheck/blocking-page.tsx b/apps/web/src/components/censorcheck/blocking-page.tsx index 6fe5577..02f5bb9 100644 --- a/apps/web/src/components/censorcheck/blocking-page.tsx +++ b/apps/web/src/components/censorcheck/blocking-page.tsx @@ -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 = ( + <> + + Фильтры + + } + /> + { + const selectedMode = next[0] + if (selectedMode === 'vps' || selectedMode === 'service') setGroup(selectedMode) + }} + aria-label="Группировка" + > + По VPS + По сервису + + + ) + return ( -
- - Фильтры - - } - /> - { - const selectedMode = next[0] - if (selectedMode === 'vps' || selectedMode === 'service') setGroup(selectedMode) - }} - aria-label="Группировка" - > - По VPS - По сервису - -
group === 'vps' ? ( ) : ( = { + ok: 'bg-success', + wn: 'bg-warning', + er: 'bg-destructive', +} +const TONE_TEXT: Record = { 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) => { + 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 ( -
-
- - {isLatest ? 'Сейчас' : formatSnapshotTickLabel(selected.key)} +
+ +
+ + {isLatest ? 'Сейчас' : 'Снимок'} · {formatTickTime(selected.asOf)} + + {isLatest ? 'Актуально' : `${lastIndex - index} назад`} + - - {formatCheckedAt(selected.asOf)} + + {describeTick + ? describeTick(selected) + : `${selected.count} проб · ${selected.issues} ${issueLabel}`}
- - {isLatest ? 'Актуально' : 'Снимок'} - + { + 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) => ( + + {p.label} + + ))} + +
+ + +
- - -
- 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 ( - onIndexChange(tickIndex)} - > - - - - {formatSnapshotTickLabel(tick.key)} - - - - - ) - })} - +
+ {ticks.map((tick, i) => ( + + 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 ? ( + + + {formatTickTime(tick.asOf)} · {tick.count} проб · {tick.issues} {issueLabel} + + + ))} +
+
+ {formatTickTime(ticks[0]!.asOf)} + {ticks.length > 1 ? {formatTickTime(ticks[lastIndex]!.asOf)} : null}
diff --git a/apps/web/src/components/censorcheck/blocking-snapshots.test.ts b/apps/web/src/components/censorcheck/blocking-snapshots.test.ts index 38c8e60..1b9980f 100644 --- a/apps/web/src/components/censorcheck/blocking-snapshots.test.ts +++ b/apps/web/src/components/censorcheck/blocking-snapshots.test.ts @@ -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) }) }) diff --git a/apps/web/src/components/censorcheck/blocking-snapshots.ts b/apps/web/src/components/censorcheck/blocking-snapshots.ts index a35d5fa..489710d 100644 --- a/apps/web/src/components/censorcheck/blocking-snapshots.ts +++ b/apps/web/src/components/censorcheck/blocking-snapshots.ts @@ -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 = { 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( return [...map.values()] } -export function collectSnapshotTicks(runs: SnapshotRun[]): BlockingSnapshotTick[] { - const byDay = new Map() +export function collectSnapshotTicks( + runs: T[], + toneOf: (run: T) => SnapshotTone = () => 'ok', +): BlockingSnapshotTick[] { + const byHour = new Map() 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((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`. */ diff --git a/apps/web/src/components/censorcheck/status-matrix-cell.test.ts b/apps/web/src/components/censorcheck/status-matrix-cell.test.ts index 01c0a37..1be16dc 100644 --- a/apps/web/src/components/censorcheck/status-matrix-cell.test.ts +++ b/apps/web/src/components/censorcheck/status-matrix-cell.test.ts @@ -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) diff --git a/apps/web/src/components/censorcheck/status-matrix-cell.tsx b/apps/web/src/components/censorcheck/status-matrix-cell.tsx index b8d7b1b..70bf358 100644 --- a/apps/web/src/components/censorcheck/status-matrix-cell.tsx +++ b/apps/web/src/components/censorcheck/status-matrix-cell.tsx @@ -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 = { + 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 = ( - - - + + + ) return ( diff --git a/apps/web/src/components/ipregion/country-matrix-cell.tsx b/apps/web/src/components/ipregion/country-matrix-cell.tsx index 8d72e40..03fc388 100644 --- a/apps/web/src/components/ipregion/country-matrix-cell.tsx +++ b/apps/web/src/components/ipregion/country-matrix-cell.tsx @@ -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 = ( - + + {status === 'ok' && badgeIso ? : null} {display} ) diff --git a/apps/web/src/components/ipregion/geo-filters.test.ts b/apps/web/src/components/ipregion/geo-filters.test.ts index f6dcce4..3504c7e 100644 --- a/apps/web/src/components/ipregion/geo-filters.test.ts +++ b/apps/web/src/components/ipregion/geo-filters.test.ts @@ -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) + }) +}) diff --git a/apps/web/src/components/ipregion/geo-filters.ts b/apps/web/src/components/ipregion/geo-filters.ts index b59f0dc..e608971 100644 --- a/apps/web/src/components/ipregion/geo-filters.ts +++ b/apps/web/src/components/ipregion/geo-filters.ts @@ -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() for (const run of runs) { diff --git a/apps/web/src/components/ipregion/geo-grid.tsx b/apps/web/src/components/ipregion/geo-grid.tsx index aafb7f2..696f5c1 100644 --- a/apps/web/src/components/ipregion/geo-grid.tsx +++ b/apps/web/src/components/ipregion/geo-grid.tsx @@ -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 { ) : ( Unknown VPS ) - return dataGridCellStack(link, secondary) + return dataGridCellWithIcon( + , + 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[] => { 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 ( +
+ + {iso ? : null} + {iso ?? '—'} + + {same}/{total} + + + +
+ ) + }, + }, ...serviceCols.map( (svc): DataGridColumn => ({ 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 ( 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 ( row.id} diff --git a/apps/web/src/components/ipregion/geo-page.tsx b/apps/web/src/components/ipregion/geo-page.tsx index c1aaf13..fb696e1 100644 --- a/apps/web/src/components/ipregion/geo-page.tsx +++ b/apps/web/src/components/ipregion/geo-page.tsx @@ -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 = ( + <> + + Фильтры + + } + /> + { + const selectedMode = next[0] + if (selectedMode === 'vps' || selectedMode === 'service') setGroup(selectedMode) + }} + aria-label="Группировка" + > + По VPS + По сервису + + + ) + return ( -
- - Фильтры - - } - /> - { - const selectedMode = next[0] - if (selectedMode === 'vps' || selectedMode === 'service') setGroup(selectedMode) - }} - aria-label="Группировка" - > - По VPS - По сервису - -
group === 'vps' ? ( ) : (