CDN-значения вида SE (ARN) больше не помечаются как ошибка. У каждого GeoIP-сервиса — бренд-иконка, как в разделе блокировок. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -7,8 +7,26 @@ import { normalizeIngestResult, summarizeResults } from './normalize.js'
|
|||||||
|
|
||||||
describe('canonicalizeCountryValue', () => {
|
describe('canonicalizeCountryValue', () => {
|
||||||
it('мапит ISO в ok', () => {
|
it('мапит ISO в ok', () => {
|
||||||
expect(canonicalizeCountryValue('RU')).toEqual({ status: 'ok', country: 'RU' })
|
expect(canonicalizeCountryValue('RU')).toEqual({ status: 'ok', country: 'RU', iata: null })
|
||||||
expect(canonicalizeCountryValue(' de ')).toEqual({ status: 'ok', country: 'DE' })
|
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', () => {
|
it('мапит статусы ipregion', () => {
|
||||||
@@ -46,6 +64,31 @@ describe('ipregion normalize', () => {
|
|||||||
expect(row.group).toBe('cdn')
|
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', () => {
|
it('считает summary и partial', () => {
|
||||||
const { summary, runStatus } = summarizeResults([
|
const { summary, runStatus } = summarizeResults([
|
||||||
{ status: 'ok' },
|
{ status: 'ok' },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
canonicalizeCountryValue,
|
canonicalizeCountryValue,
|
||||||
emptyIpregionSummary,
|
emptyIpregionSummary,
|
||||||
|
formatCanonicalCountry,
|
||||||
inferIpregionGroup,
|
inferIpregionGroup,
|
||||||
type IpregionGroup,
|
type IpregionGroup,
|
||||||
type IpregionIngestResult,
|
type IpregionIngestResult,
|
||||||
@@ -29,8 +30,8 @@ export function normalizeIngestResult(item: IpregionIngestResult): NormalizedIpr
|
|||||||
serviceKey,
|
serviceKey,
|
||||||
serviceLabel,
|
serviceLabel,
|
||||||
group: item.group ?? inferIpregionGroup(serviceKey),
|
group: item.group ?? inferIpregionGroup(serviceKey),
|
||||||
countryIpv4: ipv4.country,
|
countryIpv4: formatCanonicalCountry(ipv4),
|
||||||
countryIpv6: ipv6.country,
|
countryIpv6: formatCanonicalCountry(ipv6),
|
||||||
status,
|
status,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@cfdm/ui/components/tooltip'
|
} from '@cfdm/ui/components/tooltip'
|
||||||
|
import { canonicalizeCountryValue } from '@cfdm/shared/contracts/ipregion'
|
||||||
import { COUNTRY_BY_CODE } from '@cfdm/shared/geo'
|
import { COUNTRY_BY_CODE } from '@cfdm/shared/geo'
|
||||||
|
import { countryBadgeText } from './geo-filters'
|
||||||
import { IPREGION_STATUS_LABELS, formatCheckedAt } from './types'
|
import { IPREGION_STATUS_LABELS, formatCheckedAt } from './types'
|
||||||
|
|
||||||
const STATUS_VARIANT: Record<
|
const STATUS_VARIANT: Record<
|
||||||
@@ -20,7 +22,16 @@ const STATUS_VARIANT: Record<
|
|||||||
|
|
||||||
function countryLabel(code: string | null | undefined): string {
|
function countryLabel(code: string | null | undefined): string {
|
||||||
if (!code) return ''
|
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 */
|
/** Compact ISO cell — preview: https://reui.io/docs/components/base/badge · data-grid-base-4 */
|
||||||
@@ -41,14 +52,15 @@ export function CountryMatrixCell({
|
|||||||
checkedAt?: string
|
checkedAt?: string
|
||||||
onSelect?: () => void
|
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 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 variant = status ? (STATUS_VARIANT[status] ?? 'outline') : 'outline'
|
||||||
const tip = [
|
const tip = [
|
||||||
serviceLabel,
|
serviceLabel,
|
||||||
vpsLabel,
|
vpsLabel,
|
||||||
iso ? `${iso}${countryLabel(iso) ? ` · ${countryLabel(iso)}` : ''}` : statusLabel,
|
formatTipCountry(stored) ?? statusLabel,
|
||||||
countryIpv4 ? `IPv4 ${countryIpv4}` : null,
|
countryIpv4 ? `IPv4 ${countryIpv4}` : null,
|
||||||
countryIpv6 ? `IPv6 ${countryIpv6}` : null,
|
countryIpv6 ? `IPv6 ${countryIpv6}` : null,
|
||||||
checkedAt ? formatCheckedAt(checkedAt) : null,
|
checkedAt ? formatCheckedAt(checkedAt) : null,
|
||||||
|
|||||||
@@ -108,6 +108,27 @@ describe('uniqueCountries / mismatch', () => {
|
|||||||
expect(uniqueCountries([run()]).sort()).toEqual(['NL', 'US'])
|
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('считает расхождение с инвентарём', () => {
|
it('считает расхождение с инвентарём', () => {
|
||||||
expect(
|
expect(
|
||||||
isGeoMismatch(
|
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 { COUNTRY_BY_CODE, COUNTRY_BY_NAME_RU } from '@cfdm/shared/geo'
|
||||||
import {
|
import {
|
||||||
IPREGION_CDN_SERVICES,
|
IPREGION_CDN_SERVICES,
|
||||||
@@ -14,6 +15,10 @@ import {
|
|||||||
|
|
||||||
const GROUP_RANK: Record<string, number> = { primary: 0, custom: 1, cdn: 2 }
|
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 {
|
export function inventoryCountryCode(run: IpregionRunDto): string | null {
|
||||||
const raw = run.vps?.country?.trim() ?? ''
|
const raw = run.vps?.country?.trim() ?? ''
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
@@ -23,13 +28,22 @@ export function inventoryCountryCode(run: IpregionRunDto): string | null {
|
|||||||
|
|
||||||
export function countryName(code: string | null | undefined): string {
|
export function countryName(code: string | null | undefined): string {
|
||||||
if (!code) return ''
|
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 {
|
export function majorityCountry(run: IpregionRunDto): string | null {
|
||||||
const counts = new Map<string, number>()
|
const counts = new Map<string, number>()
|
||||||
for (const row of run.results ?? []) {
|
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
|
if (row.status !== 'ok' || !code) continue
|
||||||
counts.set(code, (counts.get(code) ?? 0) + 1)
|
counts.set(code, (counts.get(code) ?? 0) + 1)
|
||||||
}
|
}
|
||||||
@@ -55,8 +69,12 @@ export function uniqueCountries(runs: IpregionRunDto[]): string[] {
|
|||||||
const set = new Set<string>()
|
const set = new Set<string>()
|
||||||
for (const run of runs) {
|
for (const run of runs) {
|
||||||
for (const row of run.results ?? []) {
|
for (const row of run.results ?? []) {
|
||||||
if (row.status === 'ok' && row.countryIpv4) set.add(row.countryIpv4)
|
if (row.status === 'ok') {
|
||||||
if (row.status === 'ok' && row.countryIpv6) set.add(row.countryIpv6)
|
const v4 = resultCountryIso(row.countryIpv4)
|
||||||
|
const v6 = resultCountryIso(row.countryIpv6)
|
||||||
|
if (v4) set.add(v4)
|
||||||
|
if (v6) set.add(v6)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...set].sort()
|
return [...set].sort()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Link } from '@tanstack/react-router'
|
|||||||
import { GlobeIcon, ServerIcon } from 'lucide-react'
|
import { GlobeIcon, ServerIcon } from 'lucide-react'
|
||||||
|
|
||||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
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 { columnDefFromDataGrid, FrameDataGrid } from '@/components/reui-kit'
|
||||||
import {
|
import {
|
||||||
collectProbeColumns,
|
collectProbeColumns,
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type GeoServiceRow,
|
type GeoServiceRow,
|
||||||
} from './geo-filters'
|
} from './geo-filters'
|
||||||
import { CountryMatrixCell } from './country-matrix-cell'
|
import { CountryMatrixCell } from './country-matrix-cell'
|
||||||
|
import { resolveServiceIcon, ServiceGlyph } from './service-icons'
|
||||||
import { runHosterLabel, type IpregionRunDto } from './types'
|
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 */
|
/** 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}`,
|
key: `svc:${svc.key}`,
|
||||||
header: svc.label,
|
header: svc.label,
|
||||||
headerTitle: svc.title,
|
headerTitle: svc.title,
|
||||||
|
icon: resolveServiceIcon(svc.key),
|
||||||
className: MATRIX_CELL,
|
className: MATRIX_CELL,
|
||||||
headerClassName: MATRIX_CELL,
|
headerClassName: MATRIX_CELL,
|
||||||
size: 72,
|
size: 72,
|
||||||
@@ -140,7 +142,11 @@ export function GeoServiceGrid({
|
|||||||
size: 180,
|
size: 180,
|
||||||
minSize: 140,
|
minSize: 140,
|
||||||
sortValue: (row) => row.serviceKey,
|
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(
|
...probeCols.map(
|
||||||
(probe): DataGridColumn<GeoServiceRow> => ({
|
(probe): DataGridColumn<GeoServiceRow> => ({
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { StatusBadge } from '@/components/status-badge'
|
|||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { ipregionRunQueryOptions } from '@/queries/ipregion'
|
import { ipregionRunQueryOptions } from '@/queries/ipregion'
|
||||||
import { countryName } from './geo-filters'
|
import { countryName } from './geo-filters'
|
||||||
|
import { ServiceGlyph } from './service-icons'
|
||||||
import {
|
import {
|
||||||
IPREGION_STATUS_LABELS,
|
IPREGION_STATUS_LABELS,
|
||||||
formatCheckedAt,
|
formatCheckedAt,
|
||||||
@@ -101,14 +102,19 @@ export function GeoRunSheet({ run, open, onOpenChange }: GeoRunSheetProps) {
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{(detail.results ?? []).map((item) => (
|
{(detail.results ?? []).map((item) => (
|
||||||
<div key={item.id} className="flex items-center justify-between gap-3">
|
<div key={item.id} className="flex items-center justify-between gap-3">
|
||||||
|
<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">
|
<div className="flex min-w-0 flex-col">
|
||||||
<span className="truncate text-sm font-medium">{item.serviceLabel}</span>
|
<span className="truncate text-sm font-medium">{item.serviceLabel}</span>
|
||||||
<span className="text-muted-foreground text-xs">{item.group}</span>
|
<span className="text-muted-foreground text-xs">{item.group}</span>
|
||||||
</div>
|
</div>
|
||||||
{item.status === 'ok' && item.countryIpv4 ? (
|
</div>
|
||||||
|
{item.status === 'ok' && (item.countryIpv4 || item.countryIpv6) ? (
|
||||||
<Badge size="sm" variant="success-light" radius="full">
|
<Badge size="sm" variant="success-light" radius="full">
|
||||||
{item.countryIpv4}
|
{item.countryIpv4 || item.countryIpv6}
|
||||||
{countryName(item.countryIpv4) ? ` · ${countryName(item.countryIpv4)}` : ''}
|
{countryName(item.countryIpv4 || item.countryIpv6)
|
||||||
|
? ` · ${countryName(item.countryIpv4 || item.countryIpv6)}`
|
||||||
|
: ''}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<StatusBadge
|
<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)} />
|
||||||
|
}
|
||||||
@@ -131,19 +131,48 @@ export function emptyIpregionSummary(): IpregionSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ISO_RE = /^[A-Z]{2}$/
|
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(
|
export function canonicalizeCountryValue(
|
||||||
raw: string | null | undefined,
|
raw: string | null | undefined,
|
||||||
): { status: IpregionStatus; country: string | null } {
|
): CanonicalCountry {
|
||||||
const value = raw?.replace(/\s+/g, ' ').trim() ?? ''
|
const value = raw?.replace(/\s+/g, ' ').trim() ?? ''
|
||||||
if (!value || value === 'null' || /^n\/a$/i.test(value)) {
|
if (!value || /^null$/i.test(value) || /^n\/a$/i.test(value)) {
|
||||||
return { status: 'na', country: null }
|
return { status: 'na', country: null, iata: null }
|
||||||
}
|
}
|
||||||
if (/^denied$/i.test(value)) return { status: 'denied', 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 }
|
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 }
|
if (/^server error$/i.test(value)) return { status: 'server_error', country: null, iata: null }
|
||||||
const iso = value.toUpperCase()
|
|
||||||
if (ISO_RE.test(iso)) return { status: 'ok', country: iso }
|
const upper = value.toUpperCase()
|
||||||
return { status: 'server_error', country: null }
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user