Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
import type { CensorcheckRunDto } from './types'
|
||||
export type SnapshotRun = {
|
||||
id: string
|
||||
createdAt: string
|
||||
probePublicIp: string
|
||||
}
|
||||
|
||||
export type BlockingSnapshotTick = {
|
||||
key: string
|
||||
@@ -24,18 +28,18 @@ export function formatSnapshotTickLabel(dayKey: string): string {
|
||||
})
|
||||
}
|
||||
|
||||
export function mergeCensorcheckRuns(
|
||||
current: CensorcheckRunDto[],
|
||||
history: CensorcheckRunDto[],
|
||||
): CensorcheckRunDto[] {
|
||||
const map = new Map<string, CensorcheckRunDto>()
|
||||
export function mergeCensorcheckRuns<T extends { id: string }>(
|
||||
current: T[],
|
||||
history: T[],
|
||||
): T[] {
|
||||
const map = new Map<string, T>()
|
||||
for (const run of history) map.set(run.id, run)
|
||||
for (const run of current) map.set(run.id, run)
|
||||
return [...map.values()]
|
||||
}
|
||||
|
||||
export function collectSnapshotTicks(runs: CensorcheckRunDto[]): BlockingSnapshotTick[] {
|
||||
const byDay = new Map<string, CensorcheckRunDto[]>()
|
||||
export function collectSnapshotTicks(runs: SnapshotRun[]): BlockingSnapshotTick[] {
|
||||
const byDay = new Map<string, SnapshotRun[]>()
|
||||
for (const run of runs) {
|
||||
const key = snapshotDayKey(run.createdAt)
|
||||
const list = byDay.get(key)
|
||||
@@ -56,12 +60,12 @@ export function collectSnapshotTicks(runs: CensorcheckRunDto[]): BlockingSnapsho
|
||||
}
|
||||
|
||||
/** Latest run per probe IP at or before `asOf`. */
|
||||
export function latestRunsAsOf(
|
||||
runs: CensorcheckRunDto[],
|
||||
export function latestRunsAsOf<T extends SnapshotRun>(
|
||||
runs: T[],
|
||||
asOf: string,
|
||||
): CensorcheckRunDto[] {
|
||||
): T[] {
|
||||
if (!asOf) return []
|
||||
const byIp = new Map<string, CensorcheckRunDto>()
|
||||
const byIp = new Map<string, T>()
|
||||
for (const run of runs) {
|
||||
if (run.createdAt > asOf) continue
|
||||
const previous = byIp.get(run.probePublicIp)
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
LayoutDashboardIcon,
|
||||
SearchIcon,
|
||||
ShieldAlertIcon,
|
||||
GlobeIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
@@ -69,6 +70,10 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
|
||||
<ShieldAlertIcon />
|
||||
<span>Статус блокировок</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => go('/geo')}>
|
||||
<GlobeIcon />
|
||||
<span>GeoIP</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="VPS">
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
import { COUNTRY_BY_CODE } from '@cfdm/shared/geo'
|
||||
import { IPREGION_STATUS_LABELS, formatCheckedAt } from './types'
|
||||
|
||||
const STATUS_VARIANT: Record<
|
||||
string,
|
||||
'success-light' | 'outline' | 'destructive-outline' | 'warning-light' | 'warning-outline'
|
||||
> = {
|
||||
ok: 'success-light',
|
||||
na: 'outline',
|
||||
denied: 'destructive-outline',
|
||||
rate_limit: 'warning-light',
|
||||
server_error: 'warning-outline',
|
||||
}
|
||||
|
||||
function countryLabel(code: string | null | undefined): string {
|
||||
if (!code) return ''
|
||||
return COUNTRY_BY_CODE[code.toUpperCase()]?.name ?? code
|
||||
}
|
||||
|
||||
/** Compact ISO cell — preview: https://reui.io/docs/components/base/badge · data-grid-base-4 */
|
||||
export function CountryMatrixCell({
|
||||
status,
|
||||
countryIpv4,
|
||||
countryIpv6,
|
||||
serviceLabel,
|
||||
vpsLabel,
|
||||
checkedAt,
|
||||
onSelect,
|
||||
}: {
|
||||
status?: string | null
|
||||
countryIpv4?: string | null
|
||||
countryIpv6?: string | null
|
||||
serviceLabel: string
|
||||
vpsLabel: string
|
||||
checkedAt?: string
|
||||
onSelect?: () => void
|
||||
}) {
|
||||
const iso = countryIpv4 || countryIpv6 || null
|
||||
const statusLabel = status ? (IPREGION_STATUS_LABELS[status] ?? status) : 'Нет результата'
|
||||
const display = status === 'ok' && iso ? iso : 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,
|
||||
countryIpv4 ? `IPv4 ${countryIpv4}` : null,
|
||||
countryIpv6 ? `IPv6 ${countryIpv6}` : null,
|
||||
checkedAt ? formatCheckedAt(checkedAt) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
const badge = (
|
||||
<Badge variant={variant} size="sm" radius="full" aria-label={tip}>
|
||||
{display}
|
||||
</Badge>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex"
|
||||
onClick={(event) => {
|
||||
if (!onSelect) return
|
||||
event.stopPropagation()
|
||||
onSelect()
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{badge}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tip}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
import {
|
||||
collectServiceColumns,
|
||||
filterIpregionRuns,
|
||||
isGeoMismatch,
|
||||
uniqueCountries,
|
||||
} from './geo-filters'
|
||||
import { runHosterLabel, type IpregionRunDto } from './types'
|
||||
|
||||
const run = (overrides: Partial<IpregionRunDto> = {}): IpregionRunDto => ({
|
||||
id: 'iprun-1',
|
||||
spaceId: 'space-main',
|
||||
runId: '11111111-1111-4111-8111-111111111111',
|
||||
probePublicIp: '203.0.113.10',
|
||||
claimedPublicIp: null,
|
||||
matchedVpsId: 'vps-1',
|
||||
status: 'complete',
|
||||
schemaVersion: 1,
|
||||
launcherVersion: '1',
|
||||
ipregionVersion: '1',
|
||||
summary: {
|
||||
total: 2,
|
||||
ok: 2,
|
||||
na: 0,
|
||||
denied: 0,
|
||||
rate_limit: 0,
|
||||
server_error: 0,
|
||||
},
|
||||
createdAt: '2026-08-22T00:00:00.000Z',
|
||||
completedAt: '2026-08-22T00:00:00.000Z',
|
||||
observedSourceIp: '203.0.113.10',
|
||||
vps: {
|
||||
id: 'vps-1',
|
||||
ip: '203.0.113.10',
|
||||
dns: 'edge.example.com',
|
||||
providerId: 'p1',
|
||||
providerName: 'Hoster',
|
||||
country: 'Нидерланды',
|
||||
city: 'Amsterdam',
|
||||
datacenter: 'AMS',
|
||||
vcpu: 2,
|
||||
ramGb: 4,
|
||||
diskGb: 40,
|
||||
},
|
||||
results: [
|
||||
{
|
||||
id: 'r1',
|
||||
runId: 'iprun-1',
|
||||
serviceKey: 'maxmind.com',
|
||||
serviceLabel: 'maxmind.com',
|
||||
group: 'primary',
|
||||
countryIpv4: 'NL',
|
||||
countryIpv6: null,
|
||||
status: 'ok',
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
runId: 'iprun-1',
|
||||
serviceKey: 'google',
|
||||
serviceLabel: 'Google',
|
||||
group: 'custom',
|
||||
countryIpv4: 'US',
|
||||
countryIpv6: null,
|
||||
status: 'ok',
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('filterIpregionRuns', () => {
|
||||
it('фильтрует по ISO страны', () => {
|
||||
const filters: Filter[] = [
|
||||
{ id: '1', field: 'country', operator: 'contains', values: ['NL'] },
|
||||
]
|
||||
expect(filterIpregionRuns([run()], filters)).toHaveLength(1)
|
||||
expect(
|
||||
filterIpregionRuns([run()], [
|
||||
{ id: '1', field: 'country', operator: 'contains', values: ['JP'] },
|
||||
]),
|
||||
).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('runHosterLabel', () => {
|
||||
it('предпочитает имя из инвентаря', () => {
|
||||
expect(runHosterLabel(run())).toBe('Hoster')
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectServiceColumns', () => {
|
||||
it('ставит primary, затем custom, затем cdn', () => {
|
||||
const cols = collectServiceColumns([run()])
|
||||
expect(cols[0]?.key).toBe('maxmind.com')
|
||||
expect(cols[0]?.group).toBe('primary')
|
||||
const google = cols.find((col) => col.key === 'google')
|
||||
const maxmind = cols.find((col) => col.key === 'maxmind.com')
|
||||
const cdn = cols.find((col) => col.key === 'cloudflare cdn')
|
||||
expect(google).toBeDefined()
|
||||
expect(cdn).toBeDefined()
|
||||
expect(cols.indexOf(maxmind!)).toBeLessThan(cols.indexOf(google!))
|
||||
expect(cols.indexOf(google!)).toBeLessThan(cols.indexOf(cdn!))
|
||||
})
|
||||
})
|
||||
|
||||
describe('uniqueCountries / mismatch', () => {
|
||||
it('собирает уникальные ISO', () => {
|
||||
expect(uniqueCountries([run()]).sort()).toEqual(['NL', 'US'])
|
||||
})
|
||||
|
||||
it('считает расхождение с инвентарём', () => {
|
||||
expect(
|
||||
isGeoMismatch(
|
||||
run({
|
||||
results: [
|
||||
{
|
||||
id: 'r1',
|
||||
runId: 'iprun-1',
|
||||
serviceKey: 'maxmind.com',
|
||||
serviceLabel: 'maxmind.com',
|
||||
group: 'primary',
|
||||
countryIpv4: 'US',
|
||||
countryIpv6: null,
|
||||
status: 'ok',
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
runId: 'iprun-1',
|
||||
serviceKey: 'google',
|
||||
serviceLabel: 'Google',
|
||||
group: 'custom',
|
||||
countryIpv4: 'US',
|
||||
countryIpv6: null,
|
||||
status: 'ok',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
isGeoMismatch(
|
||||
run({
|
||||
results: [
|
||||
{
|
||||
id: 'r1',
|
||||
runId: 'iprun-1',
|
||||
serviceKey: 'maxmind.com',
|
||||
serviceLabel: 'maxmind.com',
|
||||
group: 'primary',
|
||||
countryIpv4: 'NL',
|
||||
countryIpv6: null,
|
||||
status: 'ok',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,278 @@
|
||||
import { COUNTRY_BY_CODE, COUNTRY_BY_NAME_RU } from '@cfdm/shared/geo'
|
||||
import {
|
||||
IPREGION_CDN_SERVICES,
|
||||
IPREGION_CUSTOM_SERVICES,
|
||||
IPREGION_PRIMARY_SERVICES,
|
||||
} from '@cfdm/shared/contracts/ipregion'
|
||||
import { getActiveFilters } from '@/components/reui-kit'
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
import {
|
||||
runSearchText,
|
||||
type IpregionResultDto,
|
||||
type IpregionRunDto,
|
||||
} from './types'
|
||||
|
||||
const GROUP_RANK: Record<string, number> = { primary: 0, custom: 1, cdn: 2 }
|
||||
|
||||
export function inventoryCountryCode(run: IpregionRunDto): string | null {
|
||||
const raw = run.vps?.country?.trim() ?? ''
|
||||
if (!raw) return null
|
||||
if (/^[A-Za-z]{2}$/.test(raw)) return raw.toUpperCase()
|
||||
return COUNTRY_BY_NAME_RU[raw.toLowerCase()]?.code ?? COUNTRY_BY_CODE[raw.toUpperCase()]?.code ?? null
|
||||
}
|
||||
|
||||
export function countryName(code: string | null | undefined): string {
|
||||
if (!code) return ''
|
||||
return COUNTRY_BY_CODE[code.toUpperCase()]?.name ?? code
|
||||
}
|
||||
|
||||
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
|
||||
if (row.status !== 'ok' || !code) continue
|
||||
counts.set(code, (counts.get(code) ?? 0) + 1)
|
||||
}
|
||||
let best: string | null = null
|
||||
let bestCount = 0
|
||||
for (const [code, count] of counts) {
|
||||
if (count > bestCount) {
|
||||
best = code
|
||||
bestCount = count
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export function isGeoMismatch(run: IpregionRunDto): boolean {
|
||||
const inventory = inventoryCountryCode(run)
|
||||
const geo = majorityCountry(run)
|
||||
if (!inventory || !geo) return false
|
||||
return inventory !== geo
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
return [...set].sort()
|
||||
}
|
||||
|
||||
export function filterIpregionRuns(runs: IpregionRunDto[], filters: Filter[]): IpregionRunDto[] {
|
||||
const active = getActiveFilters(filters)
|
||||
if (active.length === 0) return runs
|
||||
|
||||
return runs.filter((run) => {
|
||||
for (const filter of active) {
|
||||
const values = filter.values.map((value) => String(value))
|
||||
if (filter.field === 'status') {
|
||||
const statuses = (run.results ?? []).map((row) => row.status)
|
||||
const hit = values.some((value) => statuses.includes(value))
|
||||
if (filter.operator === 'is_not_any_of' ? hit : !hit) return false
|
||||
continue
|
||||
}
|
||||
if (filter.field === 'service') {
|
||||
const hay = (run.results ?? [])
|
||||
.map((row) => `${row.serviceKey} ${row.serviceLabel}`)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
const hit = values.some(
|
||||
(value) =>
|
||||
hay.includes(value.toLowerCase()) ||
|
||||
(run.results ?? []).some((row) => row.serviceKey === value.toLowerCase()),
|
||||
)
|
||||
if (!hit) return false
|
||||
continue
|
||||
}
|
||||
if (filter.field === 'hoster') {
|
||||
const name = `${run.vps?.providerName ?? ''} ${run.detectedHoster ?? ''}`.toLowerCase()
|
||||
const hit = values.some((value) => name.includes(value.toLowerCase()) || name === value.toLowerCase())
|
||||
if (!hit) return false
|
||||
continue
|
||||
}
|
||||
if (filter.field === 'country') {
|
||||
const codes = [
|
||||
inventoryCountryCode(run) ?? '',
|
||||
majorityCountry(run) ?? '',
|
||||
...(run.results ?? []).flatMap((row) => [row.countryIpv4 ?? '', row.countryIpv6 ?? '']),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
const names = countryName(majorityCountry(run)).toLowerCase()
|
||||
const hay = `${codes} ${names} ${(run.vps?.country ?? '').toLowerCase()}`
|
||||
const hit = values.some((value) => hay.includes(value.toLowerCase()))
|
||||
if (!hit) return false
|
||||
continue
|
||||
}
|
||||
if (filter.field === 'matched') {
|
||||
const matched = run.matchedVpsId ? 'matched' : 'unmatched'
|
||||
if (!values.includes(matched)) return false
|
||||
continue
|
||||
}
|
||||
if (filter.field === 'q') {
|
||||
const hay = runSearchText(run)
|
||||
const hit = values.some((token) => hay.includes(token.toLowerCase()))
|
||||
if (!hit) return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export type GeoServiceRow = {
|
||||
id: string
|
||||
serviceKey: string
|
||||
serviceLabel: string
|
||||
group: string
|
||||
probes: Array<{
|
||||
runId: string
|
||||
probePublicIp: string
|
||||
matchedVpsId: string | null
|
||||
dns: string
|
||||
country: string | null
|
||||
status: string
|
||||
countryIpv4: string | null
|
||||
countryIpv6: string | null
|
||||
createdAt: string
|
||||
vpsId: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export function groupRunsByService(runs: IpregionRunDto[]): GeoServiceRow[] {
|
||||
const map = new Map<string, GeoServiceRow>()
|
||||
for (const run of runs) {
|
||||
for (const result of run.results ?? []) {
|
||||
const existing = map.get(result.serviceKey)
|
||||
const probe = {
|
||||
runId: run.id,
|
||||
probePublicIp: run.probePublicIp,
|
||||
matchedVpsId: run.matchedVpsId,
|
||||
dns: run.vps?.dns ?? '',
|
||||
country: result.countryIpv4,
|
||||
status: result.status,
|
||||
countryIpv4: result.countryIpv4,
|
||||
countryIpv6: result.countryIpv6,
|
||||
createdAt: run.createdAt,
|
||||
vpsId: run.matchedVpsId,
|
||||
}
|
||||
if (existing) {
|
||||
existing.probes.push(probe)
|
||||
} else {
|
||||
map.set(result.serviceKey, {
|
||||
id: result.serviceKey,
|
||||
serviceKey: result.serviceKey,
|
||||
serviceLabel: result.serviceLabel,
|
||||
group: result.group,
|
||||
probes: [probe],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...map.values()].sort((a, b) => {
|
||||
const rank = (GROUP_RANK[a.group] ?? 9) - (GROUP_RANK[b.group] ?? 9)
|
||||
if (rank !== 0) return rank
|
||||
return a.serviceKey.localeCompare(b.serviceKey)
|
||||
})
|
||||
}
|
||||
|
||||
export type MatrixColumn = {
|
||||
key: string
|
||||
label: string
|
||||
title: string
|
||||
group: string
|
||||
}
|
||||
|
||||
export function shortServiceLabel(value: string): string {
|
||||
const host = value.trim()
|
||||
if (host.length <= 14) return host
|
||||
return host.replace(/\.(com|org|net|io|co)$/i, '')
|
||||
}
|
||||
|
||||
function canonicalKeys(): Array<{ key: string; group: string; label: string }> {
|
||||
return [
|
||||
...IPREGION_PRIMARY_SERVICES.map((key) => ({ key, group: 'primary', label: key })),
|
||||
...IPREGION_CUSTOM_SERVICES.map((key) => ({ key, group: 'custom', label: key })),
|
||||
...IPREGION_CDN_SERVICES.map((key) => ({ key, group: 'cdn', label: key })),
|
||||
]
|
||||
}
|
||||
|
||||
export function collectServiceColumns(runs: IpregionRunDto[]): MatrixColumn[] {
|
||||
const canonical = canonicalKeys()
|
||||
const canonicalSet = new Set(canonical.map((item) => item.key))
|
||||
const extras: MatrixColumn[] = []
|
||||
for (const run of runs) {
|
||||
for (const result of run.results ?? []) {
|
||||
if (canonicalSet.has(result.serviceKey)) continue
|
||||
if (extras.some((col) => col.key === result.serviceKey)) continue
|
||||
extras.push({
|
||||
key: result.serviceKey,
|
||||
label: shortServiceLabel(result.serviceLabel),
|
||||
title: result.serviceLabel,
|
||||
group: result.group,
|
||||
})
|
||||
}
|
||||
}
|
||||
extras.sort((a, b) => {
|
||||
const rank = (GROUP_RANK[a.group] ?? 9) - (GROUP_RANK[b.group] ?? 9)
|
||||
if (rank !== 0) return rank
|
||||
return a.key.localeCompare(b.key)
|
||||
})
|
||||
const extrasByGroup = {
|
||||
primary: extras.filter((col) => col.group === 'primary'),
|
||||
custom: extras.filter((col) => col.group === 'custom'),
|
||||
cdn: extras.filter((col) => col.group === 'cdn'),
|
||||
}
|
||||
const fromCanonical = (group: string, keys: readonly string[]) =>
|
||||
keys.map((key) => ({
|
||||
key,
|
||||
label: shortServiceLabel(key),
|
||||
title: key,
|
||||
group,
|
||||
}))
|
||||
return [
|
||||
...fromCanonical('primary', IPREGION_PRIMARY_SERVICES),
|
||||
...extrasByGroup.primary,
|
||||
...fromCanonical('custom', IPREGION_CUSTOM_SERVICES),
|
||||
...extrasByGroup.custom,
|
||||
...fromCanonical('cdn', IPREGION_CDN_SERVICES),
|
||||
...extrasByGroup.cdn,
|
||||
]
|
||||
}
|
||||
|
||||
export function collectProbeColumns(runs: IpregionRunDto[]): MatrixColumn[] {
|
||||
return runs.map((run) => {
|
||||
const title = run.vps?.dns || run.probePublicIp
|
||||
return {
|
||||
key: run.id,
|
||||
label: shortServiceLabel(title),
|
||||
title,
|
||||
group: 'probe',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function resultByService(
|
||||
run: IpregionRunDto,
|
||||
serviceKey: string,
|
||||
): IpregionResultDto | undefined {
|
||||
return (run.results ?? []).find((row) => row.serviceKey === serviceKey)
|
||||
}
|
||||
|
||||
export function serviceMatrixRows(runs: IpregionRunDto[]): GeoServiceRow[] {
|
||||
const grouped = new Map(groupRunsByService(runs).map((row) => [row.serviceKey, row]))
|
||||
return collectServiceColumns(runs).map((col) => {
|
||||
const existing = grouped.get(col.key)
|
||||
if (existing) return existing
|
||||
return {
|
||||
id: col.key,
|
||||
serviceKey: col.key,
|
||||
serviceLabel: col.title,
|
||||
group: col.group,
|
||||
probes: [],
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useMemo, type ReactNode } from 'react'
|
||||
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 { columnDefFromDataGrid, FrameDataGrid } from '@/components/reui-kit'
|
||||
import {
|
||||
collectProbeColumns,
|
||||
collectServiceColumns,
|
||||
resultByService,
|
||||
type GeoServiceRow,
|
||||
} from './geo-filters'
|
||||
import { CountryMatrixCell } from './country-matrix-cell'
|
||||
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 */
|
||||
export const GEO_MATRIX_GRID = {
|
||||
tableWidth: 'auto' as const,
|
||||
horizontalScroll: true,
|
||||
}
|
||||
|
||||
const MATRIX_CELL = 'w-16 min-w-16 px-1 text-center'
|
||||
|
||||
function vpsIdentityColumn(): DataGridColumn<IpregionRunDto> {
|
||||
return {
|
||||
key: 'vps',
|
||||
header: 'VPS / IP',
|
||||
headerTitle: 'VPS / IP',
|
||||
icon: ServerIcon,
|
||||
enableHiding: false,
|
||||
enablePinning: true,
|
||||
size: 240,
|
||||
minSize: 200,
|
||||
sortValue: (row) => row.vps?.dns || row.probePublicIp,
|
||||
cell: (row) => {
|
||||
const title = row.vps?.dns || row.probePublicIp
|
||||
const ip = row.probePublicIp
|
||||
const hoster = runHosterLabel(row)
|
||||
const secondary = hoster ? `${ip} · ${hoster}` : ip
|
||||
const link = row.matchedVpsId ? (
|
||||
<Link
|
||||
to="/vps/$vpsId"
|
||||
params={{ vpsId: row.matchedVpsId }}
|
||||
className="hover:text-primary font-medium"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-medium">Unknown VPS</span>
|
||||
)
|
||||
return dataGridCellStack(link, secondary)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function GeoVpsGrid({
|
||||
runs,
|
||||
onRowClick,
|
||||
emptyAction,
|
||||
}: {
|
||||
runs: IpregionRunDto[]
|
||||
onRowClick: (run: IpregionRunDto) => void
|
||||
emptyAction?: ReactNode
|
||||
}) {
|
||||
const serviceCols = useMemo(() => collectServiceColumns(runs), [runs])
|
||||
const columns = useMemo((): DataGridColumn<IpregionRunDto>[] => {
|
||||
return [
|
||||
vpsIdentityColumn(),
|
||||
...serviceCols.map(
|
||||
(svc): DataGridColumn<IpregionRunDto> => ({
|
||||
key: `svc:${svc.key}`,
|
||||
header: svc.label,
|
||||
headerTitle: svc.title,
|
||||
className: MATRIX_CELL,
|
||||
headerClassName: MATRIX_CELL,
|
||||
size: 72,
|
||||
minSize: 64,
|
||||
sortable: true,
|
||||
sortValue: (row) => resultByService(row, svc.key)?.countryIpv4 ?? resultByService(row, svc.key)?.status ?? '',
|
||||
cell: (row) => {
|
||||
const item = resultByService(row, svc.key)
|
||||
return (
|
||||
<CountryMatrixCell
|
||||
status={item?.status}
|
||||
countryIpv4={item?.countryIpv4}
|
||||
countryIpv6={item?.countryIpv6}
|
||||
serviceLabel={svc.title}
|
||||
vpsLabel={row.vps?.dns || row.probePublicIp}
|
||||
checkedAt={row.createdAt}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
),
|
||||
]
|
||||
}, [serviceCols])
|
||||
|
||||
return (
|
||||
<FrameDataGrid
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={runs}
|
||||
rowId={(row) => row.id}
|
||||
dense
|
||||
pagination={runs.length > 10}
|
||||
pinLeftColumnIds={['vps']}
|
||||
{...GEO_MATRIX_GRID}
|
||||
emptyTitle="Нет проверок"
|
||||
emptyDescription="Запустите launcher на VPS, чтобы увидеть страны GeoIP."
|
||||
emptyAction={emptyAction}
|
||||
onRowClick={onRowClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function GeoServiceGrid({
|
||||
groups,
|
||||
runs,
|
||||
onProbeClick,
|
||||
emptyAction,
|
||||
}: {
|
||||
groups: GeoServiceRow[]
|
||||
runs: IpregionRunDto[]
|
||||
onProbeClick: (run: IpregionRunDto) => void
|
||||
emptyAction?: ReactNode
|
||||
}) {
|
||||
const probeCols = useMemo(() => collectProbeColumns(runs), [runs])
|
||||
const runById = useMemo(() => new Map(runs.map((row) => [row.id, row])), [runs])
|
||||
|
||||
const columns = useMemo((): DataGridColumn<GeoServiceRow>[] => {
|
||||
return [
|
||||
{
|
||||
key: 'service',
|
||||
header: 'Сервис',
|
||||
headerTitle: 'Сервис',
|
||||
icon: GlobeIcon,
|
||||
enableHiding: false,
|
||||
enablePinning: true,
|
||||
size: 180,
|
||||
minSize: 140,
|
||||
sortValue: (row) => row.serviceKey,
|
||||
cell: (row) => dataGridCellStack(row.serviceLabel, row.group),
|
||||
},
|
||||
...probeCols.map(
|
||||
(probe): DataGridColumn<GeoServiceRow> => ({
|
||||
key: `probe:${probe.key}`,
|
||||
header: probe.label,
|
||||
headerTitle: probe.title,
|
||||
className: MATRIX_CELL,
|
||||
headerClassName: MATRIX_CELL,
|
||||
size: 72,
|
||||
minSize: 64,
|
||||
sortable: true,
|
||||
sortValue: (row) =>
|
||||
row.probes.find((item) => item.runId === probe.key)?.countryIpv4 ??
|
||||
row.probes.find((item) => item.runId === probe.key)?.status ??
|
||||
'',
|
||||
cell: (row) => {
|
||||
const item = row.probes.find((probeRow) => probeRow.runId === probe.key)
|
||||
const run = runById.get(probe.key)
|
||||
return (
|
||||
<CountryMatrixCell
|
||||
status={item?.status}
|
||||
countryIpv4={item?.countryIpv4}
|
||||
countryIpv6={item?.countryIpv6}
|
||||
serviceLabel={row.serviceKey}
|
||||
vpsLabel={probe.title}
|
||||
checkedAt={item?.createdAt}
|
||||
onSelect={run ? () => onProbeClick(run) : undefined}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
),
|
||||
]
|
||||
}, [onProbeClick, probeCols, runById])
|
||||
|
||||
return (
|
||||
<FrameDataGrid
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={groups}
|
||||
rowId={(row) => row.id}
|
||||
dense
|
||||
pagination={groups.length > 10}
|
||||
pinLeftColumnIds={['service']}
|
||||
{...GEO_MATRIX_GRID}
|
||||
emptyTitle="Нет сервисов"
|
||||
emptyAction={emptyAction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
CopyIcon,
|
||||
GlobeIcon,
|
||||
MapPinIcon,
|
||||
ServerIcon,
|
||||
ShieldAlertIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@cfdm/ui/components/toggle-group'
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { KpiStatGrid, ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit'
|
||||
import { Filters, type Filter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { copyText } from '@/lib/clipboard'
|
||||
import { useSpaceId } from '@/lib/space'
|
||||
import {
|
||||
ipregionCurrentQueryOptions,
|
||||
ipregionHistoryQueryOptions,
|
||||
} from '@/queries/ipregion'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||
import { BlockingSnapshotScrubber } from '@/components/censorcheck/blocking-snapshot-scrubber'
|
||||
import {
|
||||
collectSnapshotTicks,
|
||||
latestRunsAsOf,
|
||||
mergeCensorcheckRuns,
|
||||
resolveSnapshotIndex,
|
||||
} from '@/components/censorcheck/blocking-snapshots'
|
||||
import { GeoServiceGrid, GeoVpsGrid } from './geo-grid'
|
||||
import { GeoRunSheet } from './geo-run-sheet'
|
||||
import {
|
||||
filterIpregionRuns,
|
||||
isGeoMismatch,
|
||||
serviceMatrixRows,
|
||||
uniqueCountries,
|
||||
} from './geo-filters'
|
||||
import {
|
||||
IPREGION_STATUS_LABELS,
|
||||
LAUNCHER_CMD,
|
||||
formatCheckedAt,
|
||||
type IpregionRunDto,
|
||||
} from './types'
|
||||
|
||||
type GroupMode = 'vps' | 'service'
|
||||
type TabId = 'current' | 'history'
|
||||
|
||||
const FILTER_FIELDS: FilterFieldConfig[] = [
|
||||
{ key: 'q', label: 'Поиск', type: 'text', defaultOperator: 'contains', placeholder: 'IP, DNS, хостер, ISO' },
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Статус',
|
||||
type: 'multiselect',
|
||||
defaultOperator: 'is_any_of',
|
||||
options: [
|
||||
{ value: 'ok', label: 'Страна' },
|
||||
{ value: 'na', label: 'N/A' },
|
||||
{ value: 'denied', label: 'Отказ' },
|
||||
{ value: 'rate_limit', label: 'Лимит' },
|
||||
{ value: 'server_error', label: 'Ошибка' },
|
||||
],
|
||||
},
|
||||
{ key: 'service', label: 'Сервис', type: 'text', defaultOperator: 'is_any_of' },
|
||||
{ key: 'hoster', label: 'Хостер', type: 'text', defaultOperator: 'contains' },
|
||||
{ key: 'country', label: 'Страна', type: 'text', defaultOperator: 'contains' },
|
||||
{
|
||||
key: 'matched',
|
||||
label: 'Привязка',
|
||||
type: 'select',
|
||||
defaultOperator: 'is',
|
||||
options: [
|
||||
{ value: 'matched', label: 'Известный VPS' },
|
||||
{ value: 'unmatched', label: 'Unknown VPS' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const historyColumns: DataGridColumn<IpregionRunDto>[] = [
|
||||
{
|
||||
key: 'ip',
|
||||
header: 'IP',
|
||||
sortValue: (row) => row.probePublicIp,
|
||||
cell: (row) => row.probePublicIp,
|
||||
},
|
||||
{
|
||||
key: 'vps',
|
||||
header: 'VPS',
|
||||
sortValue: (row) => row.vps?.dns ?? '',
|
||||
cell: (row) => row.vps?.dns || (row.matchedVpsId ? row.matchedVpsId : 'Unknown VPS'),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Статус',
|
||||
cell: (row) => (
|
||||
<StatusBadge status={row.status} label={IPREGION_STATUS_LABELS[row.status] ?? row.status} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'summary',
|
||||
header: 'OK / всего',
|
||||
sortValue: (row) => row.summary.ok,
|
||||
sortingFn: 'basic',
|
||||
cell: (row) => `${row.summary.ok} / ${row.summary.total}`,
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
header: 'Проверено',
|
||||
sortValue: (row) => row.createdAt,
|
||||
cell: (row) => formatCheckedAt(row.createdAt),
|
||||
},
|
||||
]
|
||||
|
||||
export function GeoPage() {
|
||||
const { spaceId } = useSpaceId()
|
||||
const [tab, setTab] = useState<TabId>('current')
|
||||
const [group, setGroup] = useState<GroupMode>('vps')
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
const [selected, setSelected] = useState<IpregionRunDto | null>(null)
|
||||
const [snapshotIndex, setSnapshotIndex] = useState<number | null>(null)
|
||||
|
||||
const currentQuery = useQuery(ipregionCurrentQueryOptions(spaceId))
|
||||
const historyQuery = useQuery(ipregionHistoryQueryOptions({ limit: 200 }, spaceId))
|
||||
|
||||
const allRuns = useMemo(
|
||||
() => mergeCensorcheckRuns(currentQuery.data?.items ?? [], historyQuery.data?.items ?? []),
|
||||
[currentQuery.data?.items, historyQuery.data?.items],
|
||||
)
|
||||
const ticks = useMemo(() => collectSnapshotTicks(allRuns), [allRuns])
|
||||
const resolvedIndex = resolveSnapshotIndex(ticks.length, snapshotIndex)
|
||||
const snapshotRuns = useMemo(() => {
|
||||
const asOf = ticks[resolvedIndex]?.asOf
|
||||
if (!asOf) return currentQuery.data?.items ?? []
|
||||
return latestRunsAsOf(allRuns, asOf)
|
||||
}, [allRuns, currentQuery.data?.items, resolvedIndex, ticks])
|
||||
const filtered = useMemo(
|
||||
() => filterIpregionRuns(snapshotRuns, filters),
|
||||
[snapshotRuns, filters],
|
||||
)
|
||||
const serviceGroups = useMemo(() => serviceMatrixRows(filtered), [filtered])
|
||||
|
||||
const matched = filtered.filter((row) => row.matchedVpsId).length
|
||||
const countries = uniqueCountries(filtered).length
|
||||
const mismatches = filtered.filter(isGeoMismatch).length
|
||||
|
||||
const copyLauncher = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void copyText(LAUNCHER_CMD, 'Команда скопирована')}
|
||||
>
|
||||
<CopyIcon data-icon="inline-start" />
|
||||
Скопировать команду
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="GeoIP"
|
||||
description="Страны по GeoIP-сервисам с VPS через ipregion."
|
||||
actions={copyLauncher}
|
||||
/>
|
||||
<KpiStatGrid
|
||||
items={[
|
||||
{
|
||||
id: 'probes',
|
||||
label: 'Пробы',
|
||||
value: filtered.length,
|
||||
icon: <GlobeIcon />,
|
||||
},
|
||||
{
|
||||
id: 'matched',
|
||||
label: 'Известные VPS',
|
||||
value: matched,
|
||||
icon: <ServerIcon />,
|
||||
},
|
||||
{
|
||||
id: 'countries',
|
||||
label: 'Уникальные страны',
|
||||
value: countries,
|
||||
icon: <MapPinIcon />,
|
||||
},
|
||||
{
|
||||
id: 'mismatch',
|
||||
label: 'Расхождения GeoIP',
|
||||
value: mismatches,
|
||||
icon: <ShieldAlertIcon />,
|
||||
variant: mismatches > 0 ? 'warning' : 'default',
|
||||
},
|
||||
]}
|
||||
isLoading={currentQuery.isLoading}
|
||||
/>
|
||||
|
||||
<CountedLineTabs
|
||||
tabs={[
|
||||
{ id: 'current', label: 'Текущие', count: currentQuery.data?.items.length },
|
||||
{ id: 'history', label: 'История', count: historyQuery.data?.items.length },
|
||||
]}
|
||||
value={tab}
|
||||
onValueChange={(value) => setTab(value as TabId)}
|
||||
/>
|
||||
|
||||
{tab === 'current' ? (
|
||||
<div className="flex min-w-0 w-full flex-col gap-3">
|
||||
<BlockingSnapshotScrubber
|
||||
ticks={ticks}
|
||||
index={resolvedIndex}
|
||||
onIndexChange={setSnapshotIndex}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={FILTER_FIELDS}
|
||||
onChange={setFilters}
|
||||
trigger={
|
||||
<Button type="button" variant="outline">
|
||||
Фильтры
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<ToggleGroup
|
||||
variant="outline"
|
||||
size="sm"
|
||||
spacing={0}
|
||||
value={[group]}
|
||||
onValueChange={(next) => {
|
||||
const selectedMode = next[0]
|
||||
if (selectedMode === 'vps' || selectedMode === 'service') setGroup(selectedMode)
|
||||
}}
|
||||
aria-label="Группировка"
|
||||
>
|
||||
<ToggleGroupItem value="vps">По VPS</ToggleGroupItem>
|
||||
<ToggleGroupItem value="service">По сервису</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<QueryState
|
||||
data={filtered}
|
||||
isLoading={currentQuery.isLoading}
|
||||
isError={currentQuery.isError}
|
||||
error={currentQuery.error}
|
||||
onRetry={() => void currentQuery.refetch()}
|
||||
empty={filtered.length === 0}
|
||||
emptyTitle="Пока нет проверок"
|
||||
emptyDescription={`На VPS выполните: ${LAUNCHER_CMD}`}
|
||||
emptyAction={copyLauncher}
|
||||
skeleton={<TableSkeleton />}
|
||||
>
|
||||
{(rows) =>
|
||||
group === 'vps' ? (
|
||||
<GeoVpsGrid
|
||||
runs={rows}
|
||||
onRowClick={setSelected}
|
||||
emptyAction={copyLauncher}
|
||||
/>
|
||||
) : (
|
||||
<GeoServiceGrid
|
||||
groups={serviceGroups}
|
||||
runs={rows}
|
||||
onProbeClick={setSelected}
|
||||
emptyAction={copyLauncher}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
</div>
|
||||
) : (
|
||||
<ResourcePage
|
||||
title="История проверок"
|
||||
description="Все сохранённые прогоны ipregion."
|
||||
hideHeader
|
||||
columns={columnDefFromDataGrid(historyColumns)}
|
||||
data={historyQuery.data?.items ?? []}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={historyQuery.isLoading}
|
||||
isError={historyQuery.isError}
|
||||
error={historyQuery.error instanceof Error ? historyQuery.error : null}
|
||||
onRetry={() => void historyQuery.refetch()}
|
||||
onRowClick={setSelected}
|
||||
emptyState={{
|
||||
title: 'История пуста',
|
||||
description: `На VPS выполните: ${LAUNCHER_CMD}`,
|
||||
action: copyLauncher,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<GeoRunSheet
|
||||
run={selected}
|
||||
open={Boolean(selected)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelected(null)
|
||||
}}
|
||||
/>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Building2Icon, GlobeIcon, MapPinIcon, ServerIcon, ShieldAlertIcon } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@cfdm/ui/components/sheet'
|
||||
import { DetailPanel } from '@/components/reui-kit/detail-panel'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { ipregionRunQueryOptions } from '@/queries/ipregion'
|
||||
import { countryName } from './geo-filters'
|
||||
import {
|
||||
IPREGION_STATUS_LABELS,
|
||||
formatCheckedAt,
|
||||
formatVpsResources,
|
||||
runHosterLabel,
|
||||
type IpregionRunDto,
|
||||
} from './types'
|
||||
|
||||
interface GeoRunSheetProps {
|
||||
run: IpregionRunDto | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function GeoRunSheet({ run, open, onOpenChange }: GeoRunSheetProps) {
|
||||
const needFetch = Boolean(run && !run.results)
|
||||
const { data: fetched } = useQuery({
|
||||
...ipregionRunQueryOptions(needFetch ? run?.id ?? null : null),
|
||||
})
|
||||
const detail = run?.results ? run : fetched ?? run
|
||||
const title = detail?.vps?.dns || detail?.probePublicIp || 'Проверка'
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{detail ? formatCheckedAt(detail.createdAt) : 'Загрузка…'}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
{detail ? (
|
||||
<DetailPanel>
|
||||
<DetailPanel.Metrics
|
||||
cards={[
|
||||
{
|
||||
id: 'ip',
|
||||
icon: <GlobeIcon />,
|
||||
label: 'IP',
|
||||
description: detail.probePublicIp,
|
||||
},
|
||||
{
|
||||
id: 'vps',
|
||||
icon: <ServerIcon />,
|
||||
label: 'VPS',
|
||||
description: detail.matchedVpsId ? detail.vps?.dns || detail.matchedVpsId : 'Unknown VPS',
|
||||
footer: detail.matchedVpsId ? (
|
||||
<Link
|
||||
to="/vps/$vpsId"
|
||||
params={{ vpsId: detail.matchedVpsId }}
|
||||
className="text-primary text-xs"
|
||||
>
|
||||
Открыть карточку
|
||||
</Link>
|
||||
) : undefined,
|
||||
},
|
||||
{
|
||||
id: 'hoster',
|
||||
icon: <Building2Icon />,
|
||||
label: 'Хостер',
|
||||
description: runHosterLabel(detail) || '—',
|
||||
},
|
||||
{
|
||||
id: 'geo',
|
||||
icon: <MapPinIcon />,
|
||||
label: 'Локация',
|
||||
description: detail.vps?.country || '—',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<DetailPanel.Section title="Сводка">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge
|
||||
status={detail.status}
|
||||
label={IPREGION_STATUS_LABELS[detail.status] ?? detail.status}
|
||||
/>
|
||||
{detail.vps ? (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{formatVpsResources(detail.vps.vcpu, detail.vps.ramGb, detail.vps.diskGb)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</DetailPanel.Section>
|
||||
<DetailPanel.Section title="Сервисы">
|
||||
<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>
|
||||
{item.status === 'ok' && item.countryIpv4 ? (
|
||||
<Badge size="sm" variant="success-light" radius="full">
|
||||
{item.countryIpv4}
|
||||
{countryName(item.countryIpv4) ? ` · ${countryName(item.countryIpv4)}` : ''}
|
||||
</Badge>
|
||||
) : (
|
||||
<StatusBadge
|
||||
status={item.status}
|
||||
label={IPREGION_STATUS_LABELS[item.status] ?? item.status}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DetailPanel.Section>
|
||||
</DetailPanel>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex items-center gap-2 p-4 text-sm">
|
||||
<ShieldAlertIcon className="size-4" />
|
||||
Нет данных прогона
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { IpregionSummary } from '@cfdm/shared/contracts/ipregion'
|
||||
|
||||
export type IpregionResultDto = {
|
||||
id: string
|
||||
runId: string
|
||||
serviceKey: string
|
||||
serviceLabel: string
|
||||
group: string
|
||||
countryIpv4: string | null
|
||||
countryIpv6: string | null
|
||||
status: string
|
||||
}
|
||||
|
||||
export type IpregionVpsInfo = {
|
||||
id: string
|
||||
ip: string
|
||||
dns: string
|
||||
providerId: string
|
||||
providerName: string
|
||||
country: string
|
||||
city: string
|
||||
datacenter: string
|
||||
vcpu: number
|
||||
ramGb: number
|
||||
diskGb: number
|
||||
}
|
||||
|
||||
export type IpregionRunDto = {
|
||||
id: string
|
||||
spaceId: string
|
||||
runId: string
|
||||
probePublicIp: string
|
||||
claimedPublicIp: string | null
|
||||
matchedVpsId: string | null
|
||||
status: string
|
||||
schemaVersion: number
|
||||
launcherVersion: string | null
|
||||
ipregionVersion: string | null
|
||||
summary: IpregionSummary
|
||||
createdAt: string
|
||||
completedAt: string
|
||||
observedSourceIp: string | null
|
||||
detectedHoster?: string | null
|
||||
vps: IpregionVpsInfo | null
|
||||
results?: IpregionResultDto[]
|
||||
}
|
||||
|
||||
export const IPREGION_STATUS_LABELS: Record<string, string> = {
|
||||
ok: 'Страна',
|
||||
na: 'N/A',
|
||||
denied: 'Отказ',
|
||||
rate_limit: 'Лимит',
|
||||
server_error: 'Ошибка',
|
||||
complete: 'Полный',
|
||||
partial: 'Частичный',
|
||||
}
|
||||
|
||||
export const LAUNCHER_CMD = 'curl -fsSL https://vt.shnt.top/ic | bash'
|
||||
|
||||
export function formatVpsResources(vcpu: number, ramGb: number, diskGb: number): string {
|
||||
return `${vcpu} vCPU / ${ramGb} GB / ${diskGb} GB`
|
||||
}
|
||||
|
||||
export function formatCheckedAt(iso: string): string {
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return iso
|
||||
return date.toLocaleString('ru-RU')
|
||||
}
|
||||
|
||||
export function runHosterLabel(run: IpregionRunDto): string {
|
||||
const inventory = run.vps?.providerName?.trim() ?? ''
|
||||
if (inventory) return inventory
|
||||
return run.detectedHoster?.trim() ?? ''
|
||||
}
|
||||
|
||||
export function runSearchText(run: IpregionRunDto): string {
|
||||
const parts = [
|
||||
run.probePublicIp,
|
||||
run.claimedPublicIp ?? '',
|
||||
run.vps?.dns ?? '',
|
||||
run.vps?.providerName ?? '',
|
||||
run.detectedHoster ?? '',
|
||||
run.vps?.country ?? '',
|
||||
...(run.results ?? []).map(
|
||||
(row) => `${row.serviceKey} ${row.serviceLabel} ${row.countryIpv4 ?? ''} ${row.countryIpv6 ?? ''}`,
|
||||
),
|
||||
]
|
||||
return parts.join(' ').toLowerCase()
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
UsersIcon,
|
||||
Network,
|
||||
ShieldAlert,
|
||||
Globe,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
@@ -84,6 +85,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
items: [
|
||||
{ to: '/vps', label: 'VPS', icon: Server },
|
||||
{ to: '/blocking', label: 'Статус блокировок', icon: ShieldAlert },
|
||||
{ to: '/geo', label: 'GeoIP', icon: Globe },
|
||||
{ to: '/topology', label: 'Схема', icon: Network },
|
||||
{ to: '/tariffs', label: 'Активные тарифы', icon: ServerCog },
|
||||
{ to: '/providers', label: 'Хостеры', icon: Building2 },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { kitDataGridTableLayout } from './frame-data-grid'
|
||||
import { BLOCKING_MATRIX_GRID } from '../censorcheck/blocking-grid'
|
||||
import { GEO_MATRIX_GRID } from '../ipregion/geo-grid'
|
||||
|
||||
describe('kitDataGridTableLayout', () => {
|
||||
it('CRUD defaults: без bg-muted header и width fixed', () => {
|
||||
@@ -29,3 +30,10 @@ describe('BLOCKING_MATRIX_GRID', () => {
|
||||
expect(BLOCKING_MATRIX_GRID.horizontalScroll).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEO_MATRIX_GRID', () => {
|
||||
it('data-grid-base-4: auto + horizontal scroll', () => {
|
||||
expect(GEO_MATRIX_GRID.tableWidth).toBe('auto')
|
||||
expect(GEO_MATRIX_GRID.horizontalScroll).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -448,6 +448,37 @@ export const api = {
|
||||
fetchApi<import('@/components/censorcheck/types').CensorcheckRunDto>(
|
||||
`/api/censorcheck/runs/${encodeURIComponent(id)}`,
|
||||
),
|
||||
|
||||
fetchIpregionCurrent: () =>
|
||||
fetchApi<{ items: import('@/components/ipregion/types').IpregionRunDto[] }>(
|
||||
'/api/ipregion/current',
|
||||
),
|
||||
|
||||
fetchIpregionRuns: (params: {
|
||||
cursor?: string
|
||||
limit?: number
|
||||
q?: string
|
||||
status?: string
|
||||
matched?: boolean
|
||||
} = {}) => {
|
||||
const search = new URLSearchParams()
|
||||
if (params.cursor) search.set('cursor', params.cursor)
|
||||
if (params.limit) search.set('limit', String(params.limit))
|
||||
if (params.q) search.set('q', params.q)
|
||||
if (params.status) search.set('status', params.status)
|
||||
if (params.matched === true) search.set('matched', '1')
|
||||
if (params.matched === false) search.set('matched', '0')
|
||||
const qs = search.toString()
|
||||
return fetchApi<{
|
||||
items: import('@/components/ipregion/types').IpregionRunDto[]
|
||||
nextCursor: string | null
|
||||
}>(`/api/ipregion/runs${qs ? `?${qs}` : ''}`)
|
||||
},
|
||||
|
||||
fetchIpregionRun: (id: string) =>
|
||||
fetchApi<import('@/components/ipregion/types').IpregionRunDto>(
|
||||
`/api/ipregion/runs/${encodeURIComponent(id)}`,
|
||||
),
|
||||
}
|
||||
|
||||
export type {
|
||||
|
||||
@@ -235,6 +235,7 @@ export function permissionForPath(pathname: string): string | null {
|
||||
if (
|
||||
pathname.startsWith('/vps') ||
|
||||
pathname.startsWith('/blocking') ||
|
||||
pathname.startsWith('/geo') ||
|
||||
pathname.startsWith('/topology') ||
|
||||
pathname.startsWith('/tariffs') ||
|
||||
pathname.startsWith('/projects') ||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { queryClient } from '../lib/queryClient'
|
||||
import { api } from '../lib/api-client'
|
||||
import { getStoredSpaceId } from '../lib/space'
|
||||
|
||||
export const ipregionKeys = {
|
||||
all: ['ipregion'] as const,
|
||||
current: (spaceId: string | null) => ['ipregion', 'current', spaceId ?? 'default'] as const,
|
||||
history: (spaceId: string | null, params: Record<string, unknown>) =>
|
||||
['ipregion', 'history', spaceId ?? 'default', params] as const,
|
||||
detail: (id: string) => ['ipregion', 'run', id] as const,
|
||||
}
|
||||
|
||||
export const ipregionCurrentQueryOptions = (spaceId?: string | null) => {
|
||||
const id = spaceId === undefined ? getStoredSpaceId() : spaceId
|
||||
return {
|
||||
queryKey: ipregionKeys.current(id),
|
||||
queryFn: () => api.fetchIpregionCurrent(),
|
||||
staleTime: 15_000,
|
||||
}
|
||||
}
|
||||
|
||||
export const ipregionHistoryQueryOptions = (
|
||||
params: { cursor?: string; limit?: number; q?: string; status?: string; matched?: boolean } = {},
|
||||
spaceId?: string | null,
|
||||
) => {
|
||||
const id = spaceId === undefined ? getStoredSpaceId() : spaceId
|
||||
return {
|
||||
queryKey: ipregionKeys.history(id, params),
|
||||
queryFn: () => api.fetchIpregionRuns({ limit: 50, ...params }),
|
||||
staleTime: 15_000,
|
||||
}
|
||||
}
|
||||
|
||||
export const ipregionRunQueryOptions = (id: string | null) => ({
|
||||
queryKey: ipregionKeys.detail(id ?? ''),
|
||||
queryFn: () => api.fetchIpregionRun(id!),
|
||||
enabled: Boolean(id),
|
||||
})
|
||||
|
||||
export { queryClient }
|
||||
@@ -23,6 +23,7 @@ import { Route as AuthRenewalsRouteImport } from './routes/_auth/renewals'
|
||||
import { Route as AuthProvidersRouteImport } from './routes/_auth/providers'
|
||||
import { Route as AuthProjectsRouteImport } from './routes/_auth/projects'
|
||||
import { Route as AuthPaymentsRouteImport } from './routes/_auth/payments'
|
||||
import { Route as AuthGeoRouteImport } from './routes/_auth/geo'
|
||||
import { Route as AuthDashboardRouteImport } from './routes/_auth/dashboard'
|
||||
import { Route as AuthBlockingRouteImport } from './routes/_auth/blocking'
|
||||
import { Route as AuthBalanceRouteImport } from './routes/_auth/balance'
|
||||
@@ -105,6 +106,11 @@ const AuthPaymentsRoute = AuthPaymentsRouteImport.update({
|
||||
path: '/payments',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthGeoRoute = AuthGeoRouteImport.update({
|
||||
id: '/geo',
|
||||
path: '/geo',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthDashboardRoute = AuthDashboardRouteImport.update({
|
||||
id: '/dashboard',
|
||||
path: '/dashboard',
|
||||
@@ -176,6 +182,7 @@ export interface FileRoutesByFullPath {
|
||||
'/balance': typeof AuthBalanceRoute
|
||||
'/blocking': typeof AuthBlockingRoute
|
||||
'/dashboard': typeof AuthDashboardRoute
|
||||
'/geo': typeof AuthGeoRoute
|
||||
'/payments': typeof AuthPaymentsRoute
|
||||
'/projects': typeof AuthProjectsRouteWithChildren
|
||||
'/providers': typeof AuthProvidersRoute
|
||||
@@ -202,6 +209,7 @@ export interface FileRoutesByTo {
|
||||
'/balance': typeof AuthBalanceRoute
|
||||
'/blocking': typeof AuthBlockingRoute
|
||||
'/dashboard': typeof AuthDashboardRoute
|
||||
'/geo': typeof AuthGeoRoute
|
||||
'/payments': typeof AuthPaymentsRoute
|
||||
'/projects': typeof AuthProjectsRouteWithChildren
|
||||
'/providers': typeof AuthProvidersRoute
|
||||
@@ -231,6 +239,7 @@ export interface FileRoutesById {
|
||||
'/_auth/balance': typeof AuthBalanceRoute
|
||||
'/_auth/blocking': typeof AuthBlockingRoute
|
||||
'/_auth/dashboard': typeof AuthDashboardRoute
|
||||
'/_auth/geo': typeof AuthGeoRoute
|
||||
'/_auth/payments': typeof AuthPaymentsRoute
|
||||
'/_auth/projects': typeof AuthProjectsRouteWithChildren
|
||||
'/_auth/providers': typeof AuthProvidersRoute
|
||||
@@ -260,6 +269,7 @@ export interface FileRouteTypes {
|
||||
| '/balance'
|
||||
| '/blocking'
|
||||
| '/dashboard'
|
||||
| '/geo'
|
||||
| '/payments'
|
||||
| '/projects'
|
||||
| '/providers'
|
||||
@@ -286,6 +296,7 @@ export interface FileRouteTypes {
|
||||
| '/balance'
|
||||
| '/blocking'
|
||||
| '/dashboard'
|
||||
| '/geo'
|
||||
| '/payments'
|
||||
| '/projects'
|
||||
| '/providers'
|
||||
@@ -314,6 +325,7 @@ export interface FileRouteTypes {
|
||||
| '/_auth/balance'
|
||||
| '/_auth/blocking'
|
||||
| '/_auth/dashboard'
|
||||
| '/_auth/geo'
|
||||
| '/_auth/payments'
|
||||
| '/_auth/projects'
|
||||
| '/_auth/providers'
|
||||
@@ -440,6 +452,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthPaymentsRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/geo': {
|
||||
id: '/_auth/geo'
|
||||
path: '/geo'
|
||||
fullPath: '/geo'
|
||||
preLoaderRoute: typeof AuthGeoRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/dashboard': {
|
||||
id: '/_auth/dashboard'
|
||||
path: '/dashboard'
|
||||
@@ -574,6 +593,7 @@ interface AuthRouteChildren {
|
||||
AuthBalanceRoute: typeof AuthBalanceRoute
|
||||
AuthBlockingRoute: typeof AuthBlockingRoute
|
||||
AuthDashboardRoute: typeof AuthDashboardRoute
|
||||
AuthGeoRoute: typeof AuthGeoRoute
|
||||
AuthPaymentsRoute: typeof AuthPaymentsRoute
|
||||
AuthProjectsRoute: typeof AuthProjectsRouteWithChildren
|
||||
AuthProvidersRoute: typeof AuthProvidersRoute
|
||||
@@ -594,6 +614,7 @@ const AuthRouteChildren: AuthRouteChildren = {
|
||||
AuthBalanceRoute: AuthBalanceRoute,
|
||||
AuthBlockingRoute: AuthBlockingRoute,
|
||||
AuthDashboardRoute: AuthDashboardRoute,
|
||||
AuthGeoRoute: AuthGeoRoute,
|
||||
AuthPaymentsRoute: AuthPaymentsRoute,
|
||||
AuthProjectsRoute: AuthProjectsRouteWithChildren,
|
||||
AuthProvidersRoute: AuthProvidersRoute,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import { GeoPage } from '@/components/ipregion/geo-page'
|
||||
import { ipregionCurrentQueryOptions } from '@/queries/ipregion'
|
||||
|
||||
export const Route = createFileRoute('/_auth/geo')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(ipregionCurrentQueryOptions()),
|
||||
component: GeoPage,
|
||||
})
|
||||
Reference in New Issue
Block a user