fix(geo): распознать CDN-локации и добавить иконки сервисов
Docker / build (push) Failing after 22s

CDN-значения вида SE (ARN) больше не помечаются как ошибка. У каждого GeoIP-сервиса — бренд-иконка, как в разделе блокировок.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-08-25 15:54:04 +07:00
co-authored by Cursor
parent 235ae03489
commit 7480b9dc11
10 changed files with 328 additions and 30 deletions
@@ -7,8 +7,26 @@ import { normalizeIngestResult, summarizeResults } from './normalize.js'
describe('canonicalizeCountryValue', () => {
it('мапит ISO в ok', () => {
expect(canonicalizeCountryValue('RU')).toEqual({ status: 'ok', country: 'RU' })
expect(canonicalizeCountryValue(' de ')).toEqual({ status: 'ok', country: 'DE' })
expect(canonicalizeCountryValue('RU')).toEqual({ status: 'ok', country: 'RU', iata: null })
expect(canonicalizeCountryValue(' de ')).toEqual({ status: 'ok', country: 'DE', iata: null })
})
it('мапит CDN ISO + IATA', () => {
expect(canonicalizeCountryValue('SE (ARN)')).toEqual({
status: 'ok',
country: 'SE',
iata: 'ARN',
})
expect(canonicalizeCountryValue('RS (BEG)')).toEqual({
status: 'ok',
country: 'RS',
iata: 'BEG',
})
expect(canonicalizeCountryValue('(ARN)')).toEqual({
status: 'ok',
country: null,
iata: 'ARN',
})
})
it('мапит статусы ipregion', () => {
@@ -46,6 +64,31 @@ describe('ipregion normalize', () => {
expect(row.group).toBe('cdn')
})
it('сохраняет CDN ISO + IATA и N/A', () => {
const cf = normalizeIngestResult({
service: 'Cloudflare CDN',
group: 'cdn',
ipv4: 'SE (ARN)',
})
expect(cf.status).toBe('ok')
expect(cf.countryIpv4).toBe('SE (ARN)')
expect(cf.group).toBe('cdn')
const yt = normalizeIngestResult({
service: 'YouTube CDN',
ipv4: 'RS (BEG)',
})
expect(yt.status).toBe('ok')
expect(yt.countryIpv4).toBe('RS (BEG)')
const nflx = normalizeIngestResult({
service: 'Netflix CDN',
ipv4: 'N/A',
})
expect(nflx.status).toBe('na')
expect(nflx.countryIpv4).toBeNull()
})
it('считает summary и partial', () => {
const { summary, runStatus } = summarizeResults([
{ status: 'ok' },
+3 -2
View File
@@ -1,6 +1,7 @@
import {
canonicalizeCountryValue,
emptyIpregionSummary,
formatCanonicalCountry,
inferIpregionGroup,
type IpregionGroup,
type IpregionIngestResult,
@@ -29,8 +30,8 @@ export function normalizeIngestResult(item: IpregionIngestResult): NormalizedIpr
serviceKey,
serviceLabel,
group: item.group ?? inferIpregionGroup(serviceKey),
countryIpv4: ipv4.country,
countryIpv6: ipv6.country,
countryIpv4: formatCanonicalCountry(ipv4),
countryIpv6: formatCanonicalCountry(ipv6),
status,
}
}
@@ -4,7 +4,9 @@ import {
TooltipContent,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
import { canonicalizeCountryValue } from '@cfdm/shared/contracts/ipregion'
import { COUNTRY_BY_CODE } from '@cfdm/shared/geo'
import { countryBadgeText } from './geo-filters'
import { IPREGION_STATUS_LABELS, formatCheckedAt } from './types'
const STATUS_VARIANT: Record<
@@ -20,7 +22,16 @@ const STATUS_VARIANT: Record<
function countryLabel(code: string | null | undefined): string {
if (!code) return ''
return COUNTRY_BY_CODE[code.toUpperCase()]?.name ?? code
const iso = canonicalizeCountryValue(code).country ?? code
return COUNTRY_BY_CODE[iso.toUpperCase()]?.name ?? iso
}
function formatTipCountry(value: string | null | undefined): string | null {
const parsed = canonicalizeCountryValue(value)
const iso = parsed.country
const name = iso ? countryLabel(iso) : ''
const parts = [iso, parsed.iata, name && name !== iso ? name : null].filter(Boolean)
return parts.length ? parts.join(' · ') : null
}
/** Compact ISO cell — preview: https://reui.io/docs/components/base/badge · data-grid-base-4 */
@@ -41,14 +52,15 @@ export function CountryMatrixCell({
checkedAt?: string
onSelect?: () => void
}) {
const iso = countryIpv4 || countryIpv6 || null
const stored = countryIpv4 || countryIpv6 || null
const badgeIso = countryBadgeText(stored)
const statusLabel = status ? (IPREGION_STATUS_LABELS[status] ?? status) : 'Нет результата'
const display = status === 'ok' && iso ? iso : 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 tip = [
serviceLabel,
vpsLabel,
iso ? `${iso}${countryLabel(iso) ? ` · ${countryLabel(iso)}` : ''}` : statusLabel,
formatTipCountry(stored) ?? statusLabel,
countryIpv4 ? `IPv4 ${countryIpv4}` : null,
countryIpv6 ? `IPv6 ${countryIpv6}` : null,
checkedAt ? formatCheckedAt(checkedAt) : null,
@@ -108,6 +108,27 @@ describe('uniqueCountries / mismatch', () => {
expect(uniqueCountries([run()]).sort()).toEqual(['NL', 'US'])
})
it('извлекает ISO из CDN SE (ARN)', () => {
expect(
uniqueCountries([
run({
results: [
{
id: 'r-cdn',
runId: 'iprun-1',
serviceKey: 'cloudflare cdn',
serviceLabel: 'Cloudflare CDN',
group: 'cdn',
countryIpv4: 'SE (ARN)',
countryIpv6: null,
status: 'ok',
},
],
}),
]),
).toEqual(['SE'])
})
it('считает расхождение с инвентарём', () => {
expect(
isGeoMismatch(
@@ -1,3 +1,4 @@
import { canonicalizeCountryValue } from '@cfdm/shared/contracts/ipregion'
import { COUNTRY_BY_CODE, COUNTRY_BY_NAME_RU } from '@cfdm/shared/geo'
import {
IPREGION_CDN_SERVICES,
@@ -14,6 +15,10 @@ import {
const GROUP_RANK: Record<string, number> = { primary: 0, custom: 1, cdn: 2 }
export function resultCountryIso(value: string | null | undefined): string | null {
return canonicalizeCountryValue(value).country
}
export function inventoryCountryCode(run: IpregionRunDto): string | null {
const raw = run.vps?.country?.trim() ?? ''
if (!raw) return null
@@ -23,13 +28,22 @@ export function inventoryCountryCode(run: IpregionRunDto): string | null {
export function countryName(code: string | null | undefined): string {
if (!code) return ''
return COUNTRY_BY_CODE[code.toUpperCase()]?.name ?? code
const iso = resultCountryIso(code) ?? code
return COUNTRY_BY_CODE[iso.toUpperCase()]?.name ?? iso
}
export function countryBadgeText(value: string | null | undefined): string {
const parsed = canonicalizeCountryValue(value)
if (parsed.country && parsed.iata) return parsed.country
if (parsed.country) return parsed.country
if (parsed.iata) return parsed.iata
return value?.trim() || ''
}
export function majorityCountry(run: IpregionRunDto): string | null {
const counts = new Map<string, number>()
for (const row of run.results ?? []) {
const code = row.countryIpv4 || row.countryIpv6
const code = resultCountryIso(row.countryIpv4) || resultCountryIso(row.countryIpv6)
if (row.status !== 'ok' || !code) continue
counts.set(code, (counts.get(code) ?? 0) + 1)
}
@@ -55,8 +69,12 @@ export function uniqueCountries(runs: IpregionRunDto[]): string[] {
const set = new Set<string>()
for (const run of runs) {
for (const row of run.results ?? []) {
if (row.status === 'ok' && row.countryIpv4) set.add(row.countryIpv4)
if (row.status === 'ok' && row.countryIpv6) set.add(row.countryIpv6)
if (row.status === 'ok') {
const v4 = resultCountryIso(row.countryIpv4)
const v6 = resultCountryIso(row.countryIpv6)
if (v4) set.add(v4)
if (v6) set.add(v6)
}
}
}
return [...set].sort()
@@ -3,7 +3,7 @@ import { Link } from '@tanstack/react-router'
import { GlobeIcon, ServerIcon } from 'lucide-react'
import type { DataGridColumn } from '@/components/data-grid-types'
import { dataGridCellStack } from '@/components/data-grid-cells'
import { dataGridCellStack, dataGridCellWithIcon } from '@/components/data-grid-cells'
import { columnDefFromDataGrid, FrameDataGrid } from '@/components/reui-kit'
import {
collectProbeColumns,
@@ -12,6 +12,7 @@ import {
type GeoServiceRow,
} from './geo-filters'
import { CountryMatrixCell } from './country-matrix-cell'
import { resolveServiceIcon, ServiceGlyph } from './service-icons'
import { runHosterLabel, type IpregionRunDto } from './types'
/** DNA data-grid-base-4: auto width + H-scroll + pin start. Preview: https://reui.io/preview/base/data-grid-base-4 */
@@ -73,6 +74,7 @@ export function GeoVpsGrid({
key: `svc:${svc.key}`,
header: svc.label,
headerTitle: svc.title,
icon: resolveServiceIcon(svc.key),
className: MATRIX_CELL,
headerClassName: MATRIX_CELL,
size: 72,
@@ -140,7 +142,11 @@ export function GeoServiceGrid({
size: 180,
minSize: 140,
sortValue: (row) => row.serviceKey,
cell: (row) => dataGridCellStack(row.serviceLabel, row.group),
cell: (row) =>
dataGridCellWithIcon(
<ServiceGlyph serviceKey={row.serviceKey} />,
dataGridCellStack(row.serviceLabel, row.group),
),
},
...probeCols.map(
(probe): DataGridColumn<GeoServiceRow> => ({
@@ -14,6 +14,7 @@ import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge'
import { ipregionRunQueryOptions } from '@/queries/ipregion'
import { countryName } from './geo-filters'
import { ServiceGlyph } from './service-icons'
import {
IPREGION_STATUS_LABELS,
formatCheckedAt,
@@ -101,14 +102,19 @@ export function GeoRunSheet({ run, open, onOpenChange }: GeoRunSheetProps) {
<div className="flex flex-col gap-2">
{(detail.results ?? []).map((item) => (
<div key={item.id} className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{item.serviceLabel}</span>
<span className="text-muted-foreground text-xs">{item.group}</span>
<div className="flex min-w-0 items-center gap-2">
<ServiceGlyph serviceKey={item.serviceKey} className="text-muted-foreground" />
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{item.serviceLabel}</span>
<span className="text-muted-foreground text-xs">{item.group}</span>
</div>
</div>
{item.status === 'ok' && item.countryIpv4 ? (
{item.status === 'ok' && (item.countryIpv4 || item.countryIpv6) ? (
<Badge size="sm" variant="success-light" radius="full">
{item.countryIpv4}
{countryName(item.countryIpv4) ? ` · ${countryName(item.countryIpv4)}` : ''}
{item.countryIpv4 || item.countryIpv6}
{countryName(item.countryIpv4 || item.countryIpv6)
? ` · ${countryName(item.countryIpv4 || item.countryIpv6)}`
: ''}
</Badge>
) : (
<StatusBadge
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { GlobeIcon } from 'lucide-react'
import {
IPREGION_CDN_SERVICES,
IPREGION_CUSTOM_SERVICES,
IPREGION_PRIMARY_SERVICES,
} from '@cfdm/shared/contracts/ipregion'
import { resolveServiceIcon } from './service-icons'
describe('resolveServiceIcon', () => {
it('резолвит все GeoIP-сервисы без fallback Globe', () => {
const keys = [
...IPREGION_PRIMARY_SERVICES,
...IPREGION_CUSTOM_SERVICES,
...IPREGION_CDN_SERVICES,
]
for (const key of keys) {
expect(resolveServiceIcon(key), key).not.toBe(GlobeIcon)
}
})
it('unknown / custom → Globe', () => {
expect(resolveServiceIcon('unknown.example')).toBe(GlobeIcon)
})
})
@@ -0,0 +1,136 @@
import type { ComponentType, SVGProps } from 'react'
import {
AppWindowIcon,
BinaryIcon,
CircleHelpIcon,
ClapperboardIcon,
CodeXmlIcon,
DatabaseIcon,
EarthIcon,
FileJsonIcon,
FlagIcon,
GlobeIcon,
InfoIcon,
LayersIcon,
LibraryIcon,
MapIcon,
MapPinIcon,
MapPinnedIcon,
NetworkIcon,
SearchIcon,
SparklesIcon,
TerminalIcon,
type LucideIcon,
} from 'lucide-react'
import {
siApple,
siCloudflare,
siGoogle,
siGooglegemini,
siJetbrains,
siNetflix,
siPlaystation,
siReddit,
siSpeedtest,
siSpotify,
siSteam,
siTiktok,
siTwitch,
siYoutube,
type SimpleIcon,
} from 'simple-icons'
import { cn } from '@cfdm/ui/lib/utils'
function BrandGlyph({
icon,
className,
}: {
icon: SimpleIcon
className?: string
}) {
return (
<svg
role="img"
viewBox="0 0 24 24"
className={cn('size-3.5 shrink-0', className)}
aria-hidden
>
<title>{icon.title}</title>
<path fill="currentColor" d={icon.path} />
</svg>
)
}
function brandComponent(icon: SimpleIcon): ComponentType<{ className?: string }> {
function BrandIcon({ className }: { className?: string }) {
return <BrandGlyph icon={icon} className={className} />
}
BrandIcon.displayName = `BrandIcon(${icon.slug})`
return BrandIcon
}
const BRAND_ICONS: Record<string, ComponentType<{ className?: string }>> = {
'cloudflare.com': brandComponent(siCloudflare),
'cloudflare cdn': brandComponent(siCloudflare),
google: brandComponent(siGoogle),
'google search captcha': brandComponent(siGoogle),
youtube: brandComponent(siYoutube),
'youtube premium': brandComponent(siYoutube),
'youtube cdn': brandComponent(siYoutube),
twitch: brandComponent(siTwitch),
netflix: brandComponent(siNetflix),
'netflix cdn': brandComponent(siNetflix),
spotify: brandComponent(siSpotify),
'spotify signup': brandComponent(siSpotify),
reddit: brandComponent(siReddit),
'reddit (guest access)': brandComponent(siReddit),
apple: brandComponent(siApple),
steam: brandComponent(siSteam),
tiktok: brandComponent(siTiktok),
jetbrains: brandComponent(siJetbrains),
playstation: brandComponent(siPlaystation),
'gemini supported': brandComponent(siGooglegemini),
'ookla speedtest': brandComponent(siSpeedtest),
}
const GENERIC_ICONS: Record<string, LucideIcon> = {
'maxmind.com': DatabaseIcon,
'rdap.db.ripe.net': NetworkIcon,
'ipinfo.io': InfoIcon,
'ipregistry.co': LibraryIcon,
'ipapi.co': CodeXmlIcon,
'ifconfig.co': TerminalIcon,
'ip2location.io': MapPinnedIcon,
'iplocation.com': MapIcon,
'country.is': FlagIcon,
'geoapify.com': MapPinIcon,
'geojs.io': FileJsonIcon,
'ipapi.is': BinaryIcon,
'ipbase.com': LayersIcon,
'ipquery.io': SearchIcon,
'ipwho.is': CircleHelpIcon,
'ip-api.com': EarthIcon,
chatgpt: SparklesIcon,
'disney+': ClapperboardIcon,
'disney+ access': ClapperboardIcon,
microsoft: AppWindowIcon,
}
export function resolveServiceIcon(
serviceKey: string,
): ComponentType<SVGProps<SVGSVGElement> & { className?: string }> {
const key = serviceKey.trim().toLowerCase()
return BRAND_ICONS[key] ?? GENERIC_ICONS[key] ?? GlobeIcon
}
export function ServiceGlyph({
serviceKey,
className,
}: {
serviceKey: string
className?: string
}) {
const Icon = resolveServiceIcon(serviceKey)
return <Icon className={cn('size-3.5 shrink-0', className)} />
}
+39 -10
View File
@@ -131,19 +131,48 @@ export function emptyIpregionSummary(): IpregionSummary {
}
const ISO_RE = /^[A-Z]{2}$/
const ISO_IATA_RE = /^([A-Z]{2})\s*\(([A-Z]{3})\)$/
const IATA_RE = /^[A-Z]{3}$/
/** ISO `RU` → ok; иначе статусы ipregion (`N/A`, `Denied`, `Rate-limit`, `Server error`). */
export type CanonicalCountry = {
status: IpregionStatus
country: string | null
iata: string | null
}
/** ISO `RU` / CDN `SE (ARN)` → ok; иначе статусы ipregion (`N/A`, `Denied`, `Rate-limit`, `Server error`). */
export function canonicalizeCountryValue(
raw: string | null | undefined,
): { status: IpregionStatus; country: string | null } {
): CanonicalCountry {
const value = raw?.replace(/\s+/g, ' ').trim() ?? ''
if (!value || value === 'null' || /^n\/a$/i.test(value)) {
return { status: 'na', country: null }
if (!value || /^null$/i.test(value) || /^n\/a$/i.test(value)) {
return { status: 'na', country: null, iata: null }
}
if (/^denied$/i.test(value)) return { status: 'denied', country: null }
if (/^rate[- ]?limit$/i.test(value)) return { status: 'rate_limit', country: null }
if (/^server error$/i.test(value)) return { status: 'server_error', country: null }
const iso = value.toUpperCase()
if (ISO_RE.test(iso)) return { status: 'ok', country: iso }
return { status: 'server_error', country: null }
if (/^denied$/i.test(value)) return { status: 'denied', country: null, iata: null }
if (/^rate[- ]?limit$/i.test(value)) return { status: 'rate_limit', country: null, iata: null }
if (/^server error$/i.test(value)) return { status: 'server_error', country: null, iata: null }
const upper = value.toUpperCase()
const withIata = upper.match(ISO_IATA_RE)
if (withIata) {
return { status: 'ok', country: withIata[1]!, iata: withIata[2]! }
}
if (ISO_RE.test(upper)) {
return { status: 'ok', country: upper, iata: null }
}
const iataOnly = upper.match(/^\(([A-Z]{3})\)$/)
if (iataOnly) {
return { status: 'ok', country: null, iata: iataOnly[1]! }
}
if (IATA_RE.test(upper)) {
return { status: 'ok', country: null, iata: upper }
}
return { status: 'server_error', country: null, iata: null }
}
export function formatCanonicalCountry(parsed: CanonicalCountry): string | null {
if (parsed.country && parsed.iata) return `${parsed.country} (${parsed.iata})`
if (parsed.country) return parsed.country
if (parsed.iata) return parsed.iata
return null
}