diff --git a/apps/api/src/services/ipregion/normalize.test.ts b/apps/api/src/services/ipregion/normalize.test.ts index 94f93c6..961b112 100644 --- a/apps/api/src/services/ipregion/normalize.test.ts +++ b/apps/api/src/services/ipregion/normalize.test.ts @@ -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' }, diff --git a/apps/api/src/services/ipregion/normalize.ts b/apps/api/src/services/ipregion/normalize.ts index a35022e..7722884 100644 --- a/apps/api/src/services/ipregion/normalize.ts +++ b/apps/api/src/services/ipregion/normalize.ts @@ -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, } } diff --git a/apps/web/src/components/ipregion/country-matrix-cell.tsx b/apps/web/src/components/ipregion/country-matrix-cell.tsx index d0e5180..8d72e40 100644 --- a/apps/web/src/components/ipregion/country-matrix-cell.tsx +++ b/apps/web/src/components/ipregion/country-matrix-cell.tsx @@ -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, diff --git a/apps/web/src/components/ipregion/geo-filters.test.ts b/apps/web/src/components/ipregion/geo-filters.test.ts index ca0aa70..f6dcce4 100644 --- a/apps/web/src/components/ipregion/geo-filters.test.ts +++ b/apps/web/src/components/ipregion/geo-filters.test.ts @@ -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( diff --git a/apps/web/src/components/ipregion/geo-filters.ts b/apps/web/src/components/ipregion/geo-filters.ts index 5396c5c..b59f0dc 100644 --- a/apps/web/src/components/ipregion/geo-filters.ts +++ b/apps/web/src/components/ipregion/geo-filters.ts @@ -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 = { 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() 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() 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() diff --git a/apps/web/src/components/ipregion/geo-grid.tsx b/apps/web/src/components/ipregion/geo-grid.tsx index adba13a..aafb7f2 100644 --- a/apps/web/src/components/ipregion/geo-grid.tsx +++ b/apps/web/src/components/ipregion/geo-grid.tsx @@ -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( + , + dataGridCellStack(row.serviceLabel, row.group), + ), }, ...probeCols.map( (probe): DataGridColumn => ({ diff --git a/apps/web/src/components/ipregion/geo-run-sheet.tsx b/apps/web/src/components/ipregion/geo-run-sheet.tsx index 64f3951..d29730c 100644 --- a/apps/web/src/components/ipregion/geo-run-sheet.tsx +++ b/apps/web/src/components/ipregion/geo-run-sheet.tsx @@ -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) {
{(detail.results ?? []).map((item) => (
-
- {item.serviceLabel} - {item.group} +
+ +
+ {item.serviceLabel} + {item.group} +
- {item.status === 'ok' && item.countryIpv4 ? ( + {item.status === 'ok' && (item.countryIpv4 || item.countryIpv6) ? ( - {item.countryIpv4} - {countryName(item.countryIpv4) ? ` · ${countryName(item.countryIpv4)}` : ''} + {item.countryIpv4 || item.countryIpv6} + {countryName(item.countryIpv4 || item.countryIpv6) + ? ` · ${countryName(item.countryIpv4 || item.countryIpv6)}` + : ''} ) : ( { + 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) + }) +}) diff --git a/apps/web/src/components/ipregion/service-icons.tsx b/apps/web/src/components/ipregion/service-icons.tsx new file mode 100644 index 0000000..92dc4be --- /dev/null +++ b/apps/web/src/components/ipregion/service-icons.tsx @@ -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 ( + + {icon.title} + + + ) +} + +function brandComponent(icon: SimpleIcon): ComponentType<{ className?: string }> { + function BrandIcon({ className }: { className?: string }) { + return + } + BrandIcon.displayName = `BrandIcon(${icon.slug})` + return BrandIcon +} + +const BRAND_ICONS: Record> = { + '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 = { + '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 & { 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 +} diff --git a/packages/shared/src/contracts/ipregion.ts b/packages/shared/src/contracts/ipregion.ts index f9a620c..eb38b9c 100644 --- a/packages/shared/src/contracts/ipregion.ts +++ b/packages/shared/src/contracts/ipregion.ts @@ -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 }