feat(geo): добавить раздел IP-регионов
Docker / build (push) Failing after 26s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-25 15:16:17 +07:00
co-authored by Cursor
parent 60a22b9450
commit 235ae03489
37 changed files with 4938 additions and 34 deletions
File diff suppressed because it is too large Load Diff
+356
View File
@@ -0,0 +1,356 @@
#!/usr/bin/env bash
# VPS Tracker launcher for vernette/ipregion (vendor pin 7d1c25c).
# Fetched via: curl -fsSL https://vt.shnt.top/ic | bash
set -euo pipefail
VT_API_URL="${VT_API_URL:-__VT_API_URL__}"
VT_INGEST_TOKEN="${VT_INGEST_TOKEN:-__VT_INGEST_TOKEN__}"
LAUNCHER_VERSION="1"
VENDOR_SHA="7d1c25c"
OS_ID="unknown"
OS_LIKE=""
OS_NAME="unknown"
trap 'exit 130' INT
log() { printf '%s\n' "$*"; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
require_cmd() {
command -v "$1" >/dev/null 2>&1 || die "Нужна команда '$1' (установите пакет и повторите)."
}
detect_os() {
OS_ID="unknown"
OS_LIKE=""
OS_NAME="unknown"
if [ -r /etc/os-release ]; then
# shellcheck disable=SC1091
. /etc/os-release
OS_ID="${ID:-unknown}"
OS_LIKE="${ID_LIKE:-}"
OS_NAME="${PRETTY_NAME:-$OS_ID}"
fi
}
detect_pkg_manager() {
case "$OS_ID" in
debian|ubuntu|linuxmint|pop|raspbian|kali|astra|devuan) printf 'apt'; return 0 ;;
alpine) printf 'apk'; return 0 ;;
arch|manjaro|endeavouros) printf 'pacman'; return 0 ;;
fedora) printf 'dnf'; return 0 ;;
rhel|centos|rocky|almalinux|ol)
if command -v dnf >/dev/null 2>&1; then printf 'dnf'; else printf 'yum'; fi
return 0
;;
esac
case " $OS_LIKE " in
*" debian "*|*" ubuntu "*) printf 'apt'; return 0 ;;
*" rhel "*|*" fedora "*|*" centos "*)
if command -v dnf >/dev/null 2>&1; then printf 'dnf'; else printf 'yum'; fi
return 0
;;
*" arch "*) printf 'pacman'; return 0 ;;
*" alpine "*) printf 'apk'; return 0 ;;
esac
if command -v apt-get >/dev/null 2>&1; then
printf 'apt'
elif command -v dnf >/dev/null 2>&1; then
printf 'dnf'
elif command -v yum >/dev/null 2>&1; then
printf 'yum'
elif command -v pacman >/dev/null 2>&1; then
printf 'pacman'
elif command -v apk >/dev/null 2>&1; then
printf 'apk'
else
return 1
fi
}
pkg_alts() {
local cmd="$1" pm="$2"
case "$pm:$cmd" in
*:jq) printf '%s\n' jq ;;
apt:dig|apt:nslookup) printf '%s\n' dnsutils bind9-dnsutils ;;
dnf:dig|yum:dig|dnf:nslookup|yum:nslookup) printf '%s\n' bind-utils ;;
pacman:dig|pacman:nslookup) printf '%s\n' bind ;;
apk:dig|apk:nslookup) printf '%s\n' bind-tools ;;
apt:column) printf '%s\n' bsdextrautils bsdmainutils ;;
dnf:column|yum:column|pacman:column) printf '%s\n' util-linux ;;
apk:column) printf '%s\n' util-linux-misc util-linux ;;
*) return 1 ;;
esac
}
root_prefix() {
if [ "${EUID:-$(id -u)}" -eq 0 ]; then
return 0
fi
if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
printf 'sudo -n'
return 0
fi
return 1
}
install_one_package() {
local pm="$1" pkg="$2"
local prefix=""
prefix="$(root_prefix)" || die "Нужны права root, чтобы установить: ${pkg}"
# shellcheck disable=SC2086
case "$pm" in
apt)
$prefix env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y -qq "$pkg"
;;
dnf) $prefix dnf install -y "$pkg" ;;
yum) $prefix yum install -y "$pkg" ;;
pacman) $prefix pacman -S --noconfirm --needed "$pkg" ;;
apk) $prefix apk add --no-cache "$pkg" ;;
*) return 1 ;;
esac
}
install_cmd() {
local pm="$1" cmd="$2" alt
while IFS= read -r alt; do
[ -n "$alt" ] || continue
log " пакет ${alt} → команда ${cmd}"
if install_one_package "$pm" "$alt"; then
command -v "$cmd" >/dev/null 2>&1 && return 0
fi
done < <(pkg_alts "$cmd" "$pm")
return 1
}
ensure_cmds() {
local missing=() cmd pm prefix=""
detect_os
pm="$(detect_pkg_manager)" || pm=""
log "ОС: ${OS_NAME} (id=${OS_ID}${OS_LIKE:+ like=${OS_LIKE}}, pkg=${pm:-unknown})"
for cmd in "$@"; do
command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd")
done
[ "${#missing[@]}" -eq 0 ] && return 0
[ -n "$pm" ] || die "Не удалось определить пакетный менеджер (${OS_NAME}). Установите вручную: ${missing[*]}"
log "Отсутствуют команды: ${missing[*]}. Устанавливаю..."
if [ "$pm" = apt ]; then
prefix="$(root_prefix)" || die "Нужны права root, чтобы установить: ${missing[*]}"
# shellcheck disable=SC2086
$prefix env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -qq
fi
for cmd in "${missing[@]}"; do
install_cmd "$pm" "$cmd" || die "Не удалось установить зависимость для '$cmd' (${OS_NAME})"
command -v "$cmd" >/dev/null 2>&1 || die "Команда '$cmd' так и не появилась после установки"
done
}
require_cmd curl
require_cmd bash
ensure_cmds jq dig column nslookup
detect_public_ip() {
local ip=""
ip="$(curl -fsS --max-time 8 https://api.ipify.org 2>/dev/null || true)"
if [ -z "$ip" ]; then
ip="$(curl -fsS --max-time 8 https://icanhazip.com 2>/dev/null | tr -d '[:space:]' || true)"
fi
if [ -z "$ip" ] && command -v dig >/dev/null 2>&1; then
ip="$(dig +short myip.opendns.com @resolver1.opendns.com 2>/dev/null | tr -d '[:space:]' || true)"
fi
printf '%s' "$ip"
}
# Короткий таймаут: на обычном VPS link-local просто не ответит.
curl_meta() {
curl -fsS --connect-timeout 1 --max-time 1 "$@" 2>/dev/null || true
}
detect_cloud_hoster() {
local body=""
body="$(curl_meta http://169.254.169.254/hetzner/v1/metadata)"
if [ -n "$body" ]; then
printf 'Hetzner'
return 0
fi
body="$(curl_meta http://169.254.169.254/metadata/v1/id)"
if [ -n "$body" ]; then
printf 'DigitalOcean'
return 0
fi
body="$(curl_meta http://169.254.169.254/v1/instanceid)"
if [ -n "$body" ]; then
printf 'Vultr'
return 0
fi
body="$(curl_meta http://169.254.169.254/linode/v1/instance-id)"
if [ -n "$body" ]; then
printf 'Linode'
return 0
fi
body="$(curl_meta -H 'Metadata-Flavor: Google' http://metadata.google.internal/computeMetadata/v1/instance/id)"
if [ -n "$body" ]; then
printf 'Google Cloud'
return 0
fi
body="$(curl_meta -H 'Metadata: true' 'http://169.254.169.254/metadata/instance?api-version=2021-02-01')"
if [ -n "$body" ]; then
printf 'Azure'
return 0
fi
body="$(curl_meta http://169.254.169.254/latest/meta-data/instance-id)"
if [ -n "$body" ]; then
printf 'AWS'
return 0
fi
return 1
}
detect_asn_org() {
local ip="$1" json="" org=""
json="$(curl -fsS --connect-timeout 4 --max-time 8 "https://ipwho.is/${ip}" 2>/dev/null || true)"
if [ -n "$json" ]; then
org="$(printf '%s' "$json" | jq -r '.connection.org // .org // empty' 2>/dev/null || true)"
if [ -n "$org" ] && [ "$org" != "null" ]; then
printf '%s' "$org"
return 0
fi
fi
json="$(curl -fsS --connect-timeout 4 --max-time 8 "https://ipinfo.io/${ip}/json" 2>/dev/null || true)"
if [ -n "$json" ]; then
org="$(printf '%s' "$json" | jq -r '.org // empty' 2>/dev/null || true)"
if [ -n "$org" ] && [ "$org" != "null" ]; then
printf '%s' "$org"
return 0
fi
fi
return 1
}
detect_ptr_hint() {
local ip="$1" ptr=""
command -v dig >/dev/null 2>&1 || return 1
ptr="$(dig +short -x "$ip" 2>/dev/null | awk 'NF{print; exit}' | tr -d '\r' | sed 's/\.$//')"
[ -n "$ptr" ] || return 1
printf '%s' "$ptr"
}
detect_hoster() {
local ip="$1" value=""
value="$(detect_cloud_hoster)" && { printf '%s' "$value"; return 0; }
value="$(detect_asn_org "$ip")" && { printf '%s' "$value"; return 0; }
value="$(detect_ptr_hint "$ip")" && { printf '%s' "$value"; return 0; }
return 1
}
write_vendor() {
local dest="$1"
if [ -n "${IPREGION_VENDOR_B64:-}" ]; then
printf '%s' "$IPREGION_VENDOR_B64" | base64 -d >"$dest" 2>/dev/null \
|| printf '%s' "$IPREGION_VENDOR_B64" | base64 -D >"$dest"
return 0
fi
curl -fsSL --max-time 30 "${VT_API_URL}/ic/vendor" -o "$dest"
}
uuid4() {
if [ -r /proc/sys/kernel/random/uuid ]; then
tr -d '[:space:]' </proc/sys/kernel/random/uuid
return
fi
if command -v python3 >/dev/null 2>&1; then
python3 -c 'import uuid; print(uuid.uuid4())'
return
fi
openssl rand -hex 16
}
VT_API_URL="${VT_API_URL%/}"
[ -n "$VT_API_URL" ] || die "VT_API_URL пуст"
[ -n "$VT_INGEST_TOKEN" ] || die "VT_INGEST_TOKEN пуст"
TMPDIR="$(mktemp -d /tmp/vt-ipregion.XXXXXX)"
cleanup() { rm -rf "$TMPDIR"; }
trap 'cleanup; exit 130' INT
trap 'cleanup' EXIT
VENDOR="$TMPDIR/ipregion.sh"
write_vendor "$VENDOR"
chmod +x "$VENDOR"
PUBLIC_IP="$(detect_public_ip)"
[ -n "$PUBLIC_IP" ] || die "Не удалось определить публичный IP"
HOSTER="$(detect_hoster "$PUBLIC_IP" || true)"
RUN_ID="$(uuid4)"
[ -n "$RUN_ID" ] || die "Не удалось сгенерировать runId"
log "ipregion launcher ${LAUNCHER_VERSION} (vendor ${VENDOR_SHA})"
log "probe IP: ${PUBLIC_IP}"
log "хостер: ${HOSTER:-не определён}"
log "runId: ${RUN_ID}"
log "Определяю страны GeoIP (IPv4)..."
set +e
# stdout = JSON; stderr = прогресс (не перехватывать)
RAW_JSON="$(bash "$VENDOR" --json --ipv4)"
CC_EXIT=$?
set -e
if [ "$CC_EXIT" -ne 0 ]; then
log "ipregion завершился с кодом ${CC_EXIT}" >&2
exit 1
fi
PAYLOAD="$TMPDIR/payload.json"
printf '%s' "$RAW_JSON" | jq --arg runId "$RUN_ID" --arg ip "$PUBLIC_IP" --arg lv "$LAUNCHER_VERSION" --arg hoster "$HOSTER" '
def items($group):
((.results[$group] // []) | map({
service: .service,
group: $group,
ipv4: (.ipv4 // null),
ipv6: (.ipv6 // null)
}));
{
schemaVersion: 1,
runId: $runId,
probe: ({ publicIp: $ip } + if ($hoster | length) > 0 then { hoster: $hoster } else {} end),
launcherVersion: $lv,
ipregion: {
version: ((.version | tostring) // "1")
},
results: (items("primary") + items("custom") + items("cdn"))
}
' >"$PAYLOAD"
FALLBACK="/tmp/vt-ipregion-${RUN_ID}.json"
set +e
RESP="$(curl -fsS --max-time 60 -X POST "${VT_API_URL}/api/integrations/ipregion/runs" \
-H "Authorization: Bearer ${VT_INGEST_TOKEN}" \
-H "Content-Type: application/json" \
--data-binary @"$PAYLOAD")"
POST_EXIT=$?
set -e
if [ "$POST_EXIT" -ne 0 ]; then
cp "$PAYLOAD" "$FALLBACK"
log "API недоступен (curl exit ${POST_EXIT}). JSON сохранён: ${FALLBACK}" >&2
exit 2
fi
CHECK_ID="$(printf '%s' "$RESP" | jq -r '.id // empty')"
MATCHED="$(printf '%s' "$RESP" | jq -r '.matchedVpsId // "unmatched"')"
if [ -z "$CHECK_ID" ]; then
cp "$PAYLOAD" "$FALLBACK"
log "Некорректный ответ API. JSON сохранён: ${FALLBACK}" >&2
log "$RESP" >&2
exit 2
fi
log "Check ID: ${CHECK_ID}"
log "VPS: ${MATCHED}"
exit 0
+2
View File
@@ -27,6 +27,7 @@ import { auditRoutes } from './routes/audit.js'
import { notificationsRoutes } from './routes/notifications.js'
import { integrationsCfdmRoutes } from './routes/integrations-cfdm.js'
import { censorcheckRoutes } from './routes/censorcheck.js'
import { ipregionRoutes } from './routes/ipregion.js'
import { launcherRoutes } from './routes/launcher.js'
import { appSwitcherRoutes } from './routes/app-switcher.js'
import { startScheduler } from './services/scheduler.js'
@@ -89,6 +90,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await app.register(notificationsRoutes)
await app.register(integrationsCfdmRoutes)
await app.register(censorcheckRoutes)
await app.register(ipregionRoutes)
await app.register(appSwitcherRoutes)
const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist')
+1
View File
@@ -21,6 +21,7 @@ describe('permissionForRequest', () => {
it('maps vps CRUD', () => {
expect(permissionForRequest('GET', '/api/vps')).toBe('vps:vps:read')
expect(permissionForRequest('GET', '/api/censorcheck/current')).toBe('vps:vps:read')
expect(permissionForRequest('GET', '/api/ipregion/current')).toBe('vps:vps:read')
expect(permissionForRequest('POST', '/api/vps')).toBe('vps:vps:write')
expect(permissionForRequest('DELETE', '/api/vps/abc')).toBe('vps:vps:write')
})
+2 -1
View File
@@ -52,7 +52,8 @@ const RULES: Rule[] = [
p.startsWith('/api/projects') ||
p.startsWith('/api/topology') ||
p.startsWith('/api/data') ||
p.startsWith('/api/censorcheck'),
p.startsWith('/api/censorcheck') ||
p.startsWith('/api/ipregion'),
permission: 'vps:vps:read',
},
{
+2
View File
@@ -75,8 +75,10 @@ function isPublicPath(url: string): boolean {
if (path === '/health' || path === '/ready') return true
if (path === '/api/auth/config') return true
if (path === '/cc' || path.startsWith('/cc/')) return true
if (path === '/ic' || path.startsWith('/ic/')) return true
if (path.startsWith('/api/integrations/cfdm')) return true
if (path.startsWith('/api/integrations/censorcheck')) return true
if (path.startsWith('/api/integrations/ipregion')) return true
return false
}
+143
View File
@@ -0,0 +1,143 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { closeDb, MAIN_SPACE_ID, runWithSpace } from '@cfdm/db'
import { vpsRepository } from '@cfdm/db/repositories/vps'
import { resetTestDb, seedTestProvider, seedTestProviderAccount } from '@cfdm/db/test-setup'
import { buildApp } from '../index.js'
import { mintIngestToken } from '../services/censorcheck/ingest-token.js'
const SECRET = 'test-ipregion-ingest-secret'
function ingestPayload(overrides: Record<string, unknown> = {}) {
return {
schemaVersion: 1,
runId: '11111111-1111-4111-8111-111111111111',
probe: { publicIp: '203.0.113.10' },
launcherVersion: '1',
ipregion: { version: '1' },
results: [
{
service: 'maxmind.com',
group: 'primary',
ipv4: 'NL',
ipv6: 'N/A',
},
{
service: 'Google',
group: 'custom',
ipv4: 'US',
ipv6: null,
},
],
...overrides,
}
}
describe('ipregion ingest + reads', () => {
let app: Awaited<ReturnType<typeof buildApp>>
beforeEach(async () => {
process.env.CENSORCHECK_INGEST_SECRET = SECRET
process.env.CENSORCHECK_RATE_LIMIT = '0'
process.env.TRUST_PROXY = '1'
resetTestDb()
seedTestProvider('p1')
seedTestProviderAccount('a1', 'p1')
app = await buildApp()
})
afterEach(async () => {
await app.close()
closeDb()
delete process.env.TRUST_PROXY
})
async function post(body: unknown, token?: string, headers: Record<string, string> = {}) {
return app.inject({
method: 'POST',
url: '/api/integrations/ipregion/runs',
headers: {
authorization: `Bearer ${token ?? mintIngestToken(SECRET)}`,
'content-type': 'application/json',
...headers,
},
payload: body as object,
})
}
it('отклоняет запрос без токена', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/integrations/ipregion/runs',
payload: ingestPayload(),
})
expect(res.statusCode).toBe(401)
})
it('принимает прогон и оставляет unmatched', async () => {
const res = await post(ingestPayload())
expect(res.statusCode).toBe(200)
const json = res.json() as { matchedVpsId: string | null; probePublicIp: string }
expect(json.matchedVpsId).toBeNull()
expect(json.probePublicIp).toBe('203.0.113.10')
})
it('матчит VPS по IP', async () => {
const vps = runWithSpace(MAIN_SPACE_ID, () =>
vpsRepository.create({
ip: '203.0.113.10',
dns: 'edge.example.com',
providerId: 'p1',
providerAccountId: 'a1',
status: 'active',
tariffType: 'monthly',
currency: 'RUB',
vcpu: 2,
ramGb: 4,
diskGb: 40,
}),
)
const res = await post(ingestPayload({ runId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }))
expect(res.json().matchedVpsId).toBe(vps.id)
})
it('повторяет duplicate runId без второй записи', async () => {
const first = await post(ingestPayload())
const second = await post(ingestPayload())
expect(second.json().id).toBe(first.json().id)
expect(second.json().replayed).toBe(true)
const current = await app.inject({ method: 'GET', url: '/api/ipregion/current' })
expect(current.json().items).toHaveLength(1)
})
it('отдаёт историю и детали с ISO', async () => {
await post(ingestPayload())
const list = await app.inject({ method: 'GET', url: '/api/ipregion/runs?limit=10' })
expect(list.statusCode).toBe(200)
const items = list.json().items as Array<{
id: string
results?: Array<{ countryIpv4: string; status: string; group: string }>
}>
expect(items).toHaveLength(1)
expect(items[0]!.results).toHaveLength(2)
expect(items[0]!.results?.[0]).toMatchObject({
countryIpv4: 'NL',
status: 'ok',
group: 'primary',
})
const detail = await app.inject({ method: 'GET', url: `/api/ipregion/runs/${items[0]!.id}` })
expect(detail.statusCode).toBe(200)
expect(detail.json().results).toHaveLength(2)
})
it('сохраняет хостер из probe', async () => {
await post(
ingestPayload({
runId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
probe: { publicIp: '203.0.113.10', hoster: 'AS14061 DigitalOcean, LLC' },
}),
)
const current = await app.inject({ method: 'GET', url: '/api/ipregion/current' })
expect(current.json().items[0].detectedHoster).toBe('DigitalOcean')
})
})
+129
View File
@@ -0,0 +1,129 @@
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import { canonicalizeHoster } from '@cfdm/shared/contracts/censorcheck'
import { ipregionIngestBodySchema } from '@cfdm/shared/contracts/ipregion'
import { ipregionRepository } from '@cfdm/db/repositories/ipregion'
import { actorFromRequest } from '../lib/audit-actor.js'
import {
bearerToken,
ingestSecret,
verifyIngestToken,
} from '../services/censorcheck/ingest-token.js'
import { matchVpsByPublicIp, resolveProbeIp } from '../services/censorcheck/match-ip.js'
import { normalizeIngestResult, summarizeResults } from '../services/ipregion/normalize.js'
const BODY_LIMIT = 512 * 1024
function sendError(reply: FastifyReply, status: number, code: string, message: string) {
return reply.code(status).send({ error: { code, message } })
}
function requireIngestToken(request: FastifyRequest, reply: FastifyReply): boolean {
const secret = ingestSecret()
if (!secret) {
void sendError(reply, 503, 'UNAVAILABLE', 'Ipregion ingest не настроен')
return false
}
const token = bearerToken(request.headers.authorization)
if (!token || !verifyIngestToken(token, secret)) {
void sendError(reply, 401, 'UNAUTHORIZED', 'Недействительный ingest-токен')
return false
}
return true
}
export const ipregionRoutes: FastifyPluginAsync = async (app) => {
const ingestOpts = {
bodyLimit: BODY_LIMIT,
...(process.env.VITEST || process.env.CENSORCHECK_RATE_LIMIT === '0'
? {}
: { config: { rateLimit: { max: 6, timeWindow: '1 minute' } } }),
}
app.post(
'/api/integrations/ipregion/runs',
ingestOpts,
async (request, reply) => {
if (!requireIngestToken(request, reply)) return
const parsed = ipregionIngestBodySchema.safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 400, 'VALIDATION', parsed.error.message)
}
const existing = ipregionRepository.getByClientRunId(parsed.data.runId)
if (existing) {
return {
id: existing.id,
runId: existing.runId,
matchedVpsId: existing.matchedVpsId,
probePublicIp: existing.probePublicIp,
summary: existing.summary,
replayed: true,
}
}
const claimed = parsed.data.probe.publicIp
const observed = actorFromRequest(request).ip ?? request.ip
const probePublicIp = resolveProbeIp(observed, claimed)
const claimedPublicIp =
claimed && claimed !== probePublicIp ? claimed : null
const match = matchVpsByPublicIp(probePublicIp)
const results = parsed.data.results.map(normalizeIngestResult)
const { summary, runStatus } = summarizeResults(results)
const created = ipregionRepository.create({
spaceId: match.spaceId,
runId: parsed.data.runId,
probePublicIp,
claimedPublicIp,
matchedVpsId: match.vpsId,
status: runStatus,
schemaVersion: parsed.data.schemaVersion,
launcherVersion: parsed.data.launcherVersion ?? null,
ipregionVersion: parsed.data.ipregion?.version ?? null,
summary,
observedSourceIp: observed ?? null,
detectedHoster: canonicalizeHoster(parsed.data.probe.hoster),
results,
})
return {
id: created.id,
runId: created.runId,
matchedVpsId: created.matchedVpsId,
probePublicIp: created.probePublicIp,
summary: created.summary,
}
},
)
app.get('/api/ipregion/current', async () => ({
items: ipregionRepository.listCurrent(),
}))
app.get('/api/ipregion/runs', async (request) => {
const q = request.query as Record<string, string | undefined>
const matched =
q.matched === '1' || q.matched === 'true'
? true
: q.matched === '0' || q.matched === 'false'
? false
: undefined
return ipregionRepository.listHistory({
cursor: q.cursor,
limit: q.limit ? Number(q.limit) : undefined,
q: q.q,
status: q.status,
matched,
})
})
app.get('/api/ipregion/runs/:id', async (request, reply) => {
const { id } = request.params as { id: string }
const run = ipregionRepository.getById(id)
if (!run) {
return sendError(reply, 404, 'NOT_FOUND', 'Прогон не найден')
}
return run
})
}
+44
View File
@@ -50,3 +50,47 @@ describe('GET /cc launcher', () => {
expect(res.body).not.toContain('\r')
})
})
describe('GET /ic launcher', () => {
let app: Awaited<ReturnType<typeof buildApp>>
beforeEach(async () => {
process.env.CENSORCHECK_INGEST_SECRET = 'launcher-secret-key'
process.env.CENSORCHECK_PUBLIC_URL = 'https://vt.shnt.top'
process.env.CENSORCHECK_RATE_LIMIT = '0'
resetTestDb()
app = await buildApp()
})
afterEach(async () => {
await app.close()
closeDb()
})
it('отдаёт bash-скрипт с токеном и no-store', async () => {
const res = await app.inject({ method: 'GET', url: '/ic' })
expect(res.statusCode).toBe(200)
expect(res.headers['content-type']).toMatch(/text\/plain/)
expect(res.headers['cache-control']).toMatch(/no-store/)
expect(res.body).toContain('https://vt.shnt.top')
expect(res.body).toContain('VT_INGEST_TOKEN')
expect(res.body).toContain('ensure_cmds jq dig column nslookup')
expect(res.body).toContain('detect_hoster')
expect(res.body).toContain('--json --ipv4')
expect(res.body).toContain('/api/integrations/ipregion/runs')
expect(res.body).toContain('/ic/vendor')
expect(res.body).toContain('7d1c25c')
expect(res.body).not.toContain('\r')
expect(res.body).not.toContain('__VT_API_URL__')
expect(res.body).not.toContain('__VT_INGEST_TOKEN__')
})
it('отдаёт vendor-скрипт ipregion', async () => {
const res = await app.inject({ method: 'GET', url: '/ic/vendor' })
expect(res.statusCode).toBe(200)
expect(res.body).toContain('#!/usr/bin/env bash')
expect(res.body).toContain('SCRIPT_NAME="ipregion.sh"')
expect(res.body).toContain('finalize_json')
expect(res.body).not.toContain('\r')
})
})
+46 -21
View File
@@ -29,6 +29,42 @@ function sendPlain(reply: FastifyReply, body: string, cache: 'no-store' | 'publi
.send(unixText(body))
}
const IPREGION_SCRIPT_DIR = join(__dirname, '..', '..', 'scripts', 'ipregion')
function mintLauncherScript(
reply: FastifyReply,
secret: string | undefined,
scriptDir: string,
missingSecretMessage: string,
): void {
if (!secret) {
void reply.code(503).send(missingSecretMessage)
return
}
const apiUrl = censorcheckPublicUrl()
const token = mintIngestToken(secret)
let template: string
try {
template = readFileSync(join(scriptDir, 'launcher.sh'), 'utf8')
} catch {
void reply.code(500).send('launcher template missing\n')
return
}
const script = template
.replaceAll('__VT_API_URL__', apiUrl)
.replaceAll('__VT_INGEST_TOKEN__', token)
sendPlain(reply, script, 'no-store')
}
function sendVendor(reply: FastifyReply, filePath: string): void {
try {
const body = readFileSync(filePath, 'utf8')
sendPlain(reply, body, 'public')
} catch {
void reply.code(500).send('vendor script missing\n')
}
}
export const launcherRoutes: FastifyPluginAsync = async (app) => {
const secret = ingestSecret()
@@ -38,29 +74,18 @@ export const launcherRoutes: FastifyPluginAsync = async (app) => {
: { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }
app.get('/cc', ccOpts, async (_request: FastifyRequest, reply: FastifyReply) => {
if (!secret) {
return reply.code(503).send('censorcheck ingest is not configured\n')
}
const apiUrl = censorcheckPublicUrl()
const token = mintIngestToken(secret)
let template: string
try {
template = readFileSync(join(SCRIPT_DIR, 'launcher.sh'), 'utf8')
} catch {
return reply.code(500).send('launcher template missing\n')
}
const script = template
.replaceAll('__VT_API_URL__', apiUrl)
.replaceAll('__VT_INGEST_TOKEN__', token)
sendPlain(reply, script, 'no-store')
mintLauncherScript(reply, secret, SCRIPT_DIR, 'censorcheck ingest is not configured\n')
})
app.get('/cc/vendor', async (_request, reply) => {
try {
const body = readFileSync(join(SCRIPT_DIR, 'censorcheck.sh'), 'utf8')
sendPlain(reply, body, 'public')
} catch {
return reply.code(500).send('vendor script missing\n')
}
sendVendor(reply, join(SCRIPT_DIR, 'censorcheck.sh'))
})
app.get('/ic', ccOpts, async (_request: FastifyRequest, reply: FastifyReply) => {
mintLauncherScript(reply, secret, IPREGION_SCRIPT_DIR, 'ipregion ingest is not configured\n')
})
app.get('/ic/vendor', async (_request, reply) => {
sendVendor(reply, join(IPREGION_SCRIPT_DIR, 'ipregion.sh'))
})
}
+2
View File
@@ -6,6 +6,8 @@ import {
const TABLE_ORDER_DELETE = [
'vps_grants',
'ipregion_results',
'ipregion_runs',
'censorcheck_results',
'censorcheck_runs',
'notification_log',
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import {
canonicalizeCountryValue,
inferIpregionGroup,
} from '@cfdm/shared/contracts/ipregion'
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' })
})
it('мапит статусы ipregion', () => {
expect(canonicalizeCountryValue('N/A').status).toBe('na')
expect(canonicalizeCountryValue('Denied').status).toBe('denied')
expect(canonicalizeCountryValue('Rate-limit').status).toBe('rate_limit')
expect(canonicalizeCountryValue('Rate limit').status).toBe('rate_limit')
expect(canonicalizeCountryValue('Server error').status).toBe('server_error')
})
})
describe('ipregion normalize', () => {
it('нормализует сервис и группу', () => {
const row = normalizeIngestResult({
service: 'Maxmind.com',
ipv4: 'NL',
ipv6: 'N/A',
})
expect(row.serviceKey).toBe('maxmind.com')
expect(row.group).toBe('primary')
expect(row.status).toBe('ok')
expect(row.countryIpv4).toBe('NL')
expect(row.countryIpv6).toBeNull()
})
it('берёт IPv6 если IPv4 N/A', () => {
const row = normalizeIngestResult({
service: 'YouTube CDN',
group: 'cdn',
ipv4: 'N/A',
ipv6: 'US',
})
expect(row.status).toBe('ok')
expect(row.countryIpv6).toBe('US')
expect(row.group).toBe('cdn')
})
it('считает summary и partial', () => {
const { summary, runStatus } = summarizeResults([
{ status: 'ok' },
{ status: 'na' },
{ status: 'denied' },
])
expect(summary.total).toBe(3)
expect(summary.ok).toBe(1)
expect(summary.denied).toBe(1)
expect(runStatus).toBe('partial')
})
it('угадывает группу по имени', () => {
expect(inferIpregionGroup('cloudflare.com')).toBe('primary')
expect(inferIpregionGroup('Netflix')).toBe('custom')
expect(inferIpregionGroup('YouTube CDN')).toBe('cdn')
})
})
@@ -0,0 +1,52 @@
import {
canonicalizeCountryValue,
emptyIpregionSummary,
inferIpregionGroup,
type IpregionGroup,
type IpregionIngestResult,
type IpregionRunStatus,
type IpregionStatus,
type IpregionSummary,
} from '@cfdm/shared/contracts/ipregion'
export type NormalizedIpregionResult = {
serviceKey: string
serviceLabel: string
group: IpregionGroup
countryIpv4: string | null
countryIpv6: string | null
status: IpregionStatus
}
export function normalizeIngestResult(item: IpregionIngestResult): NormalizedIpregionResult {
const serviceLabel = item.service.trim()
const serviceKey = serviceLabel.toLowerCase()
const ipv4 = canonicalizeCountryValue(item.ipv4)
const ipv6 = canonicalizeCountryValue(item.ipv6)
const status = ipv4.status !== 'na' ? ipv4.status : ipv6.status
return {
serviceKey,
serviceLabel,
group: item.group ?? inferIpregionGroup(serviceKey),
countryIpv4: ipv4.country,
countryIpv6: ipv6.country,
status,
}
}
export function summarizeResults(results: { status: IpregionStatus }[]): {
summary: IpregionSummary
runStatus: IpregionRunStatus
} {
const summary = emptyIpregionSummary()
summary.total = results.length
for (const row of results) {
summary[row.status] += 1
}
const runStatus: IpregionRunStatus =
summary.denied > 0 || summary.rate_limit > 0 || summary.server_error > 0
? 'partial'
: 'complete'
return { summary, runStatus }
}
@@ -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>
)
}
+89
View File
@@ -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)
})
})
+31
View File
@@ -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 {
+1
View File
@@ -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') ||
+40
View File
@@ -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 }
+21
View File
@@ -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,
+10
View File
@@ -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,
})