Compare commits

...
19 Commits
Author SHA1 Message Date
DenozordecandCursor a64d8b9755 fix(topology): спрямить HA-линии и не показывать RR без пула
Docker / build (push) Failing after 19s
Membership идёт bezier по ближайшей грани; клик по сервису выделяет VPS-членов. Badge HA только при двух уникальных origin IP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 15:09:19 +07:00
DenozordecandCursor 5a61ce33b4 feat(topology): вынести сервис CFDM в отдельный узел со связями членства
Docker / build (push) Failing after 23s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 14:20:09 +07:00
DenozordecandCursor 4dc5609f17 feat(topology): добавить группы сервисов CFDM на схему
Docker / build (push) Failing after 20s
Сервис ставится существующей группой с Badge типа резервирования; sync сохраняет lbMode.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 11:30:40 +07:00
DenozordecandCursor 29325179f4 fix(ui): привести таблицы к стилю ReUI PRO
Docker / build (push) Failing after 23s
Статусы через ReUI Badge, колонка действий — outline-меню без pin-slab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 00:14:36 +07:00
DenozordecandCursor 573ca097aa fix(domains): привязывать общие FQDN ко всем совпавшим VPS
Docker / build (push) Failing after 29s
Общий binding с несколькими origin-IP больше не остаётся unmatched: строка размножается на каждый однозначно совпавший VPS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 22:02:06 +07:00
DenozordecandCursor 6e401533c7 feat(launcher): полный список ipregion на MikroTik (ros-8)
Docker / build (push) Failing after 27s
40 сервисов как у Linux: primary, custom и CDN. Имена совпадают с vernette/ipregion.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 19:27:28 +07:00
DenozordecandCursor adea0ab070 fix(launcher): собирать ingest JSON MikroTik вручную (ros-7)
Docker / build (push) Failing after 23s
RouterOS serialize ломал тело POST. ros-7 пишет JSON строкой и печатает префикс при ошибке ingest.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 16:30:47 +07:00
DenozordecandCursor f53ca1ed7d fix(launcher): принимать JSON-массив results от MikroTik
Docker / build (push) Failing after 28s
RouterOS serialize превращает results в объект с ключами 0,1 — API отвечал 400. ros-6 собирает массив, ingest принимает оба формата.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 15:59:30 +07:00
DenozordecandCursor d7bcd0d4ec fix(launcher): показывать ошибку ingest MikroTik и повторять без src-address
Docker / build (push) Failing after 23s
RouterOS скрывает HTTP 4xx как fetch error. ros-5 печатает код и тело ответа, затем повторяет POST без src-address.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 14:09:19 +07:00
DenozordecandCursor ecd053b832 fix(launcher): упростить пробы MikroTik до переменной vt-srcIp
Docker / build (push) Failing after 26s
Убраны автоопределение интерфейса и policy routing: исходный IP задаётся вручную на каждом роутере.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 10:55:17 +07:00
DenozordecandCursor 72aad059bd fix(launcher): выпускать пробы MikroTik через выбранный WAN
Docker / build (push) Failing after 24s
src-address недостаточно при нескольких default route: добавляем policy routing и снимаем правила после прогона.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 00:43:26 +07:00
DenozordecandCursor a9e3cce113 feat(launcher): требовать интерфейс для проверок MikroTik
Docker / build (push) Failing after 22s
Все пробы и ingest идут через src-address выбранного WAN, scheduler сохраняет vtIface.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 00:22:25 +07:00
DenozordecandCursor c844874b0a feat(launcher): добавить скрипты GEO и blocking для MikroTik 7.22+
Docker / build (push) Failing after 27s
Пробы выполняются на роутере через /tool fetch и уходят в те же ingest API. Ежедневный запуск ставит scheduler, который каждый день заново качает .rsc со свежим токеном.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 17:47:04 +07:00
DenozordecandCursor b03318800f feat(launcher): добавить ежедневный запуск проверок cc и ic
Docker / build (push) Failing after 24s
Флаг --daily ставит cron, который раз в сутки заново качает launcher со свежим токеном. Снять: --remove-daily.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 16:18:14 +07:00
DenozordecandCursor 7480b9dc11 fix(geo): распознать CDN-локации и добавить иконки сервисов
Docker / build (push) Failing after 22s
CDN-значения вида SE (ARN) больше не помечаются как ошибка. У каждого GeoIP-сервиса — бренд-иконка, как в разделе блокировок.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 15:54:04 +07:00
DenozordecandCursor 235ae03489 feat(geo): добавить раздел IP-регионов
Docker / build (push) Failing after 26s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 15:16:17 +07:00
DenozordecandCursor 60a22b9450 feat(blocking): определять и показывать хостер в матрице блокировок
Docker / build (push) Failing after 26s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 13:19:02 +07:00
DenozordecandCursor efebcaf16d feat(blocking): добавить шкалу снимков над матрицей
Docker / build (push) Failing after 24s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 12:32:46 +07:00
DenozordecandCursor 9254b8dc22 fix(blocking): удержать горизонтальный скролл матрицы внутри страницы
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 12:32:38 +07:00
100 changed files with 9595 additions and 463 deletions

No files matched your search

+3
View File
@@ -1 +1,4 @@
apps/api/scripts/censorcheck/*.sh text eol=lf
apps/api/scripts/censorcheck/*.rsc text eol=lf
apps/api/scripts/ipregion/*.sh text eol=lf
apps/api/scripts/ipregion/*.rsc text eol=lf
+16 -1
View File
@@ -93,13 +93,28 @@ vps-tracker/
## Статус блокировок (censorcheck)
Ручная проверка с VPS: `curl -fsSL https://vt.shnt.top/cc | bash` (тот же контейнер, Traefik dual Host).
Раз в сутки: `curl -fsSL https://vt.shnt.top/cc | bash -s -- --daily` (cron, свежий HMAC-токен на каждый запуск).
MikroTik 7.22+: `:global "vt-srcIp" "1.2.3.4"; /tool fetch url="https://vt.shnt.top/cc.rsc" dst-path=vt-cc.rsc; /import file-name=vt-cc.rsc` (ежедневно: `?daily=1`; все пробы через `src-address` этого IP).
- **Vendor:** `apps/api/scripts/censorcheck/censorcheck.sh` (pin SHA `12c5839`, MIT)
- **Launcher:** `GET /cc` минтит HMAC ingest-токен (TTL 20 мин); по `/etc/os-release` ставит `jq`/`dig`/`column` без prompt; прогресс-бар в stderr; `GET /cc/vendor` — скрипт (LF)
- **Launcher:** `GET /cc` минтит HMAC ingest-токен (TTL 20 мин); по `/etc/os-release` ставит `jq`/`dig`/`column` без prompt; прогресс-бар в stderr; `GET /cc/vendor` — скрипт (LF); `--daily` / `--remove-daily`
- **MikroTik:** `GET /cc.rsc` — RouterOS-скрипт (HTTPS GET, без DPI); обязателен `:global vt-srcIp`; `?daily=1` / `?remove=daily`; scheduler `vt-cc`
- **Ingest:** `POST /api/integrations/censorcheck/runs` (без portal JWT)
- **UI:** `/blocking` — текущие прогоны и история, группировка VPS / сервис
- Env: `CENSORCHECK_INGEST_SECRET`, `CENSORCHECK_PUBLIC_URL`, `VPS_LAUNCHER_DOMAIN`
## GeoIP (ipregion)
Ручная проверка с VPS: `curl -fsSL https://vt.shnt.top/ic | bash`.
Раз в сутки: `curl -fsSL https://vt.shnt.top/ic | bash -s -- --daily`.
MikroTik 7.22+: `:global "vt-srcIp" "1.2.3.4"; /tool fetch url="https://vt.shnt.top/ic.rsc" dst-path=vt-ic.rsc; /import file-name=vt-ic.rsc` (`?daily=1`; пробы через тот же `vt-srcIp`).
- **Vendor:** `apps/api/scripts/ipregion/ipregion.sh` (pin SHA `7d1c25c`, MIT, [vernette/ipregion](https://github.com/vernette/ipregion))
- **Launcher:** `GET /ic` минтит HMAC ingest-токен (тот же `CENSORCHECK_INGEST_SECRET`); `GET /ic/vendor` — pinned скрипт (LF); `--daily` / `--remove-daily`
- **MikroTik:** `GET /ic.rsc` — полный список vernette/ipregion (primary + custom + cdn, как Linux); обязателен `:global vt-srcIp`; scheduler `vt-ic`
- **Ingest:** `POST /api/integrations/ipregion/runs` (без portal JWT)
- **UI:** `/geo` — матрица ISO-стран VPS × сервисы (primary / custom / cdn)
## Команды
```bash
+212
View File
@@ -0,0 +1,212 @@
# VPS Tracker — blocking launcher for RouterOS 7.22+
# First run: :global "vt-srcIp" "1.2.3.4"; /tool fetch url="__VT_API_URL__/cc.rsc" dst-path=vt-cc.rsc; /import file-name=vt-cc.rsc
# HTTPS GET only (no DPI/SNI). Ingest: POST /api/integrations/censorcheck/runs
:local vtApi "__VT_API_URL__"
:local vtToken "__VT_INGEST_TOKEN__"
:local vtDaily "__VT_DAILY__"
:local vtRemove "__VT_REMOVE_DAILY__"
:local launcherVer "ros-7"
:local schedName "vt-cc"
:local dstFile "vt-cc.rsc"
:local ver [/system resource get version]
:local dot [:find $ver "."]
:if ([:typeof $dot] = "nil") do={
:error ("Need RouterOS 7.22+ (got " . $ver . ")")
}
:local major [:tonum [:pick $ver 0 $dot]]
:local rest [:pick $ver ($dot + 1) [:len $ver]]
:local cut [:len $rest]
:local d2 [:find $rest "."]
:local sp [:find $rest " "]
:if ([:typeof $d2] != "nil") do={ :set cut $d2 }
:if (([:typeof $sp] != "nil") and (($cut = [:len $rest]) or ($sp < $cut))) do={ :set cut $sp }
:local minor [:tonum [:pick $rest 0 $cut]]
:if (($major < 7) or (($major = 7) and ($minor < 22))) do={
:error ("Need RouterOS 7.22+ (got " . $ver . ")")
}
:if ($vtRemove = "yes") do={
:do { /system scheduler remove [find name=$schedName] } on-error={}
:put ("Ежедневная проверка снята (" . $schedName . ")")
} else={
:put ("censorcheck launcher " . $launcherVer . " (RouterOS)")
:global "vt-srcIp"
:if (([:typeof $"vt-srcIp"] != "str") or ([:len $"vt-srcIp"] = 0)) do={
:put "Задайте исходный IP и повторите импорт:"
:put ":global \"vt-srcIp\" \"1.2.3.4\""
:error "vt-srcIp не задан"
}
:local srcIp $"vt-srcIp"
:put ("vt-srcIp: " . $srcIp)
:local ingestOk false
:local r
:local publicIp ""
:do {
:set r [/tool fetch url="https://api.ipify.org" src-address=$srcIp output=user as-value]
:if (($r->"status") = "finished") do={
:set publicIp ($r->"data")
}
} on-error={}
:local cr [:find $publicIp "\r"]
:if ([:typeof $cr] != "nil") do={ :set publicIp [:pick $publicIp 0 $cr] }
:local lf [:find $publicIp "\n"]
:if ([:typeof $lf] != "nil") do={ :set publicIp [:pick $publicIp 0 $lf] }
:if ([:len $publicIp] = 0) do={
:do {
:set r [/tool fetch url="https://ifconfig.me/ip" src-address=$srcIp output=user as-value]
:if (($r->"status") = "finished") do={
:set publicIp ($r->"data")
}
} on-error={}
:set cr [:find $publicIp "\r"]
:if ([:typeof $cr] != "nil") do={ :set publicIp [:pick $publicIp 0 $cr] }
:set lf [:find $publicIp "\n"]
:if ([:typeof $lf] != "nil") do={ :set publicIp [:pick $publicIp 0 $lf] }
}
:if ([:len $publicIp] = 0) do={
:error "Не удалось определить публичный IP"
}
:local hoster ""
:do {
:set r [/tool fetch url=("https://ipwho.is/" . $publicIp) src-address=$srcIp output=user as-value]
:if (($r->"status") = "finished") do={
:local j [:deserialize from=json value=($r->"data")]
:local isp ($j->"connection"->"isp")
:if ([:typeof $isp] = "str") do={ :set hoster $isp }
:if ([:len $hoster] = 0) do={
:local org ($j->"org")
:if ([:typeof $org] = "str") do={ :set hoster $org }
}
}
} on-error={}
:local runId ("mt-" . [:rndstr length=16])
:put ("probe IP: " . $publicIp)
:if ([:len $hoster] > 0) do={ :put ("хостер: " . $hoster) }
:put ("runId: " . $runId)
:put "Проверяю сайты (HTTPS GET, без DPI)..."
:local hosts {\
"youtube.com";"redirector.googlevideo.com";"discord.com";"instagram.com";"facebook.com";\
"x.com";"linkedin.com";"rutracker.org";"digitalocean.com";"amnezia.org";"getoutline.org";\
"mailfence.com";"flibusta.is";"rezka.ag";"api.telegram.org";"play.google.com";\
"spotify.com";"netflix.com";"patreon.com";"swagger.io";"snyk.io";"mongodb.com";\
"autodesk.com";"graylog.org";"redis.io";"copilot.microsoft.com"\
}
:local resultJson ""
:local itemJson ""
:local code 0
:local data
:local line
:local nl
:local cr2
:local sp1
:local rest2
:local sp2
:local codeStr
:local n
:foreach host in=$hosts do={
:set code 0
:do {
:set r [/tool fetch url=("https://" . $host . "/") src-address=$srcIp output=user-with-headers as-value]
:if (($r->"status") != "finished") do={
:set code -1
} else={
:set data ($r->"data")
:set line $data
:set nl [:find $data "\n"]
:if ([:typeof $nl] != "nil") do={ :set line [:pick $data 0 $nl] }
:set cr2 [:find $line "\r"]
:if ([:typeof $cr2] != "nil") do={ :set line [:pick $line 0 $cr2] }
:if ([:pick $line 0 4] = "HTTP") do={
:set sp1 [:find $line " "]
:if ([:typeof $sp1] != "nil") do={
:set rest2 [:pick $line ($sp1 + 1) [:len $line]]
:set sp2 [:find $rest2 " "]
:set codeStr $rest2
:if ([:typeof $sp2] != "nil") do={ :set codeStr [:pick $rest2 0 $sp2] }
:set n [:tonum $codeStr]
:if ([:typeof $n] = "num") do={ :set code $n } else={ :set code 200 }
} else={ :set code 200 }
} else={ :set code 200 }
}
} on-error={
:set code 0
}
:put ($host . " " . $code)
:set itemJson ("{\"service\":\"" . $host . "\",\"raw\":{\"https\":{\"ipv4\":{\"status\":" . $code . "}}}}")
:if ([:len $resultJson] > 0) do={ :set resultJson ($resultJson . ",") }
:set resultJson ($resultJson . $itemJson)
}
:local hosterJson ""
:if ([:len $hoster] > 0) do={
:local esc ""
:local hi 0
:while ($hi < [:len $hoster]) do={
:local ch [:pick $hoster $hi ($hi + 1)]
:if ($ch = "\\") do={ :set esc ($esc . "\\\\") } else={
:if ($ch = "\"") do={ :set esc ($esc . "\\\"") } else={ :set esc ($esc . $ch) }
}
:set hi ($hi + 1)
}
:set hosterJson (",\"hoster\":\"" . $esc . "\"")
}
:if ([:len $resultJson] = 0) do={ :error "ingest: results пустой" }
:local json ("{\"schemaVersion\":1,\"runId\":\"" . $runId . "\",\"probe\":{\"publicIp\":\"" . $publicIp . "\"" . $hosterJson . "},\"launcherVersion\":\"" . $launcherVer . "\",\"censorcheck\":{\"version\":\"ros\",\"mode\":\"https\"},\"results\":[" . $resultJson . "]}")
:local hdrs ("Content-Type: application/json,Authorization: Bearer " . $vtToken)
:put ("Отправляю ingest (" . [:len $json] . " B)...")
:set ingestOk false
:onerror err,attr in={
:set r [/tool fetch url=($vtApi . "/api/integrations/censorcheck/runs") src-address=$srcIp http-method=post http-header-field=$hdrs http-data=$json output=user as-value]
:put ($r->"data")
:set ingestOk true
} do={
:put ("ingest: " . $err)
:if (([:typeof $attr] = "array") and ([:typeof ($attr->"code")] != "nil")) do={
:put ("code: " . ($attr->"code"))
}
:if (([:typeof $attr] = "array") and ([:typeof ($attr->"data")] = "str") and ([:len ($attr->"data")] > 0)) do={
:put ($attr->"data")
}
:if ([:len $json] > 120) do={ :put ([:pick $json 0 120] . "...") } else={ :put $json }
}
:if ($ingestOk = false) do={
:put "повтор ingest без src-address..."
:onerror err2,attr2 in={
:set r [/tool fetch url=($vtApi . "/api/integrations/censorcheck/runs") http-method=post http-header-field=$hdrs http-data=$json output=user as-value]
:put ($r->"data")
} do={
:put ("ingest retry: " . $err2)
:if (([:typeof $attr2] = "array") and ([:typeof ($attr2->"data")] = "str") and ([:len ($attr2->"data")] > 0)) do={
:put ($attr2->"data")
}
:if ([:len $json] > 120) do={ :put ([:pick $json 0 120] . "...") } else={ :put $json }
}
}
:if ($vtDaily = "yes") do={
:local ident [/system identity get name]
:local h ([:len $ident] + [:len [/system resource get architecture-name]])
:local hour (3 + ($h % 4))
:local minute (($h * 17) % 60)
:local hh $hour
:local mm $minute
:if ($hour < 10) do={ :set hh ("0" . $hour) }
:if ($minute < 10) do={ :set mm ("0" . $minute) }
:local startTime ($hh . ":" . $mm . ":00")
:local ev (":global \"vt-srcIp\" \"" . $srcIp . "\"; /tool fetch url=" . $vtApi . "/cc.rsc dst-path=" . $dstFile . "; /import file-name=" . $dstFile)
:do { /system scheduler remove [find name=$schedName] } on-error={}
/system scheduler add name=$schedName interval=1d start-time=$startTime on-event=$ev policy=read,write,test,policy comment="vps-tracker vt-cc"
:put ("Ежедневная проверка: каждый день в " . $startTime . " (" . $schedName . ")")
:put ("Снять: /tool fetch url=" . $vtApi . "/cc.rsc?remove=daily dst-path=" . $dstFile . "; /import file-name=" . $dstFile)
}
}
+211 -3
View File
@@ -5,8 +5,10 @@ set -euo pipefail
VT_API_URL="${VT_API_URL:-__VT_API_URL__}"
VT_INGEST_TOKEN="${VT_INGEST_TOKEN:-__VT_INGEST_TOKEN__}"
LAUNCHER_VERSION="4"
LAUNCHER_VERSION="6"
VENDOR_SHA="12c5839"
DAILY_SLUG="vt-censorcheck"
DAILY_PATH="/cc"
OS_ID="unknown"
OS_LIKE=""
@@ -165,6 +167,184 @@ detect_public_ip() {
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
}
show_launcher_help() {
cat <<EOF
Использование:
curl -fsSL ${VT_API_URL}${DAILY_PATH} | bash
curl -fsSL ${VT_API_URL}${DAILY_PATH} | bash -s -- --daily
curl -fsSL ${VT_API_URL}${DAILY_PATH} | bash -s -- --remove-daily
--daily выполнить проверку и поставить cron раз в сутки
--remove-daily убрать ежедневный cron
EOF
}
daily_run_cmd() {
local url="${VT_API_URL}${DAILY_PATH}"
local curl_bin bash_bin flock_bin
curl_bin="$(command -v curl)"
bash_bin="$(command -v bash)"
flock_bin="$(command -v flock || true)"
if [ -n "$flock_bin" ]; then
printf "%s -n /tmp/%s.lock -c '%s -fsSL --max-time 180 %s | %s'" \
"$flock_bin" "$DAILY_SLUG" "$curl_bin" "$url" "$bash_bin"
else
printf '%s -fsSL --max-time 180 %s | %s' "$curl_bin" "$url" "$bash_bin"
fi
}
daily_slot() {
local seed n
seed="$(hostname -f 2>/dev/null || hostname 2>/dev/null || cat /etc/hostname 2>/dev/null || echo vt)"
n="$(printf '%s' "$seed" | cksum | awk '{print $1}')"
DAILY_MINUTE=$((n % 60))
DAILY_HOUR=$((3 + (n / 60) % 4))
}
remove_daily() {
local prefix="" removed=0
if [ -e "/etc/cron.d/${DAILY_SLUG}" ]; then
if prefix="$(root_prefix)"; then
# shellcheck disable=SC2086
$prefix rm -f "/etc/cron.d/${DAILY_SLUG}"
removed=1
fi
fi
if command -v crontab >/dev/null 2>&1; then
local current
current="$(crontab -l 2>/dev/null || true)"
if printf '%s\n' "$current" | grep -qF "vps-tracker:${DAILY_SLUG}"; then
printf '%s\n' "$current" | grep -vF "vps-tracker:${DAILY_SLUG}" | crontab -
removed=1
fi
fi
if [ "$removed" -eq 1 ]; then
log "Ежедневная проверка снята (${DAILY_SLUG})"
else
log "Ежедневная проверка не была установлена"
fi
}
install_daily() {
daily_slot
local run line prefix=""
local marker="# vps-tracker:${DAILY_SLUG}"
run="$(daily_run_cmd)"
if [ -d /etc/cron.d ] && prefix="$(root_prefix)"; then
local tmp
tmp="$(mktemp)"
cat >"$tmp" <<EOF
# Managed by VPS Tracker launcher. ${marker}
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=""
${DAILY_MINUTE} ${DAILY_HOUR} * * * root ${run} >>/var/log/${DAILY_SLUG}.log 2>&1
EOF
# shellcheck disable=SC2086
$prefix cp "$tmp" "/etc/cron.d/${DAILY_SLUG}"
# shellcheck disable=SC2086
$prefix chmod 644 "/etc/cron.d/${DAILY_SLUG}"
rm -f "$tmp"
log "Ежедневная проверка: каждый день в ${DAILY_HOUR}:$(printf '%02d' "$DAILY_MINUTE") (/etc/cron.d/${DAILY_SLUG})"
log "Снять: curl -fsSL ${VT_API_URL}${DAILY_PATH} | bash -s -- --remove-daily"
return 0
fi
if ! command -v crontab >/dev/null 2>&1; then
die "Нет cron (ни /etc/cron.d, ни crontab). Установите пакет cron и повторите --daily"
fi
line="${DAILY_MINUTE} ${DAILY_HOUR} * * * ${run} >>/tmp/${DAILY_SLUG}.log 2>&1 ${marker}"
{
(crontab -l 2>/dev/null || true) | grep -vF "vps-tracker:${DAILY_SLUG}" || true
printf '%s\n' "$line"
} | crontab -
log "Ежедневная проверка: каждый день в ${DAILY_HOUR}:$(printf '%02d' "$DAILY_MINUTE") (crontab)"
log "Снять: curl -fsSL ${VT_API_URL}${DAILY_PATH} | bash -s -- --remove-daily"
}
write_vendor() {
local dest="$1"
if [ -n "${CENSORCHECK_VENDOR_B64:-}" ]; then
@@ -191,6 +371,22 @@ VT_API_URL="${VT_API_URL%/}"
[ -n "$VT_API_URL" ] || die "VT_API_URL пуст"
[ -n "$VT_INGEST_TOKEN" ] || die "VT_INGEST_TOKEN пуст"
DAILY_INSTALL=0
DAILY_REMOVE=0
for arg in "$@"; do
case "$arg" in
--daily|--install-daily) DAILY_INSTALL=1 ;;
--remove-daily|--uninstall-daily) DAILY_REMOVE=1 ;;
-h|--help) show_launcher_help; exit 0 ;;
*) die "Неизвестный аргумент: $arg. См. --help" ;;
esac
done
if [ "$DAILY_REMOVE" -eq 1 ]; then
remove_daily
exit 0
fi
TMPDIR="$(mktemp -d /tmp/vt-censorcheck.XXXXXX)"
cleanup() { rm -rf "$TMPDIR"; }
trap 'cleanup; exit 130' INT
@@ -203,11 +399,14 @@ 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 "censorcheck launcher ${LAUNCHER_VERSION} (vendor ${VENDOR_SHA})"
log "probe IP: ${PUBLIC_IP}"
log "хостер: ${HOSTER:-не определён}"
log "runId: ${RUN_ID}"
log "Проверяю сайты (последовательно, несколько минут)..."
@@ -222,11 +421,11 @@ if [ "$CC_EXIT" -ne 0 ]; then
fi
PAYLOAD="$TMPDIR/payload.json"
printf '%s' "$RAW_JSON" | jq --arg runId "$RUN_ID" --arg ip "$PUBLIC_IP" --arg lv "$LAUNCHER_VERSION" '
printf '%s' "$RAW_JSON" | jq --arg runId "$RUN_ID" --arg ip "$PUBLIC_IP" --arg lv "$LAUNCHER_VERSION" --arg hoster "$HOSTER" '
{
schemaVersion: 1,
runId: $runId,
probe: { publicIp: $ip },
probe: ({ publicIp: $ip } + if ($hoster | length) > 0 then { hoster: $hoster } else {} end),
launcherVersion: $lv,
censorcheck: {
version: ((.version | tostring) // "1"),
@@ -251,6 +450,9 @@ set -e
if [ "$POST_EXIT" -ne 0 ]; then
cp "$PAYLOAD" "$FALLBACK"
log "API недоступен (curl exit ${POST_EXIT}). JSON сохранён: ${FALLBACK}" >&2
if [ "$DAILY_INSTALL" -eq 1 ]; then
install_daily
fi
exit 2
fi
@@ -260,9 +462,15 @@ if [ -z "$CHECK_ID" ]; then
cp "$PAYLOAD" "$FALLBACK"
log "Некорректный ответ API. JSON сохранён: ${FALLBACK}" >&2
log "$RESP" >&2
if [ "$DAILY_INSTALL" -eq 1 ]; then
install_daily
fi
exit 2
fi
log "Check ID: ${CHECK_ID}"
log "VPS: ${MATCHED}"
if [ "$DAILY_INSTALL" -eq 1 ]; then
install_daily
fi
exit 0
File diff suppressed because it is too large. Load diff
+646
View File
@@ -0,0 +1,646 @@
# VPS Tracker — GeoIP launcher for RouterOS 7.22+
# First run: :global "vt-srcIp" "1.2.3.4"; /tool fetch url="__VT_API_URL__/ic.rsc" dst-path=vt-ic.rsc; /import file-name=vt-ic.rsc
# Полный список vernette/ipregion (primary + custom + cdn, без Google Search Captcha). Ingest: POST /api/integrations/ipregion/runs
:local vtApi "__VT_API_URL__"
:local vtToken "__VT_INGEST_TOKEN__"
:local vtDaily "__VT_DAILY__"
:local vtRemove "__VT_REMOVE_DAILY__"
:local launcherVer "ros-8"
:local schedName "vt-ic"
:local dstFile "vt-ic.rsc"
:local ver [/system resource get version]
:local dot [:find $ver "."]
:if ([:typeof $dot] = "nil") do={
:error ("Need RouterOS 7.22+ (got " . $ver . ")")
}
:local major [:tonum [:pick $ver 0 $dot]]
:local rest [:pick $ver ($dot + 1) [:len $ver]]
:local cut [:len $rest]
:local d2 [:find $rest "."]
:local sp [:find $rest " "]
:if ([:typeof $d2] != "nil") do={ :set cut $d2 }
:if (([:typeof $sp] != "nil") and (($cut = [:len $rest]) or ($sp < $cut))) do={ :set cut $sp }
:local minor [:tonum [:pick $rest 0 $cut]]
:if (($major < 7) or (($major = 7) and ($minor < 22))) do={
:error ("Need RouterOS 7.22+ (got " . $ver . ")")
}
:if ($vtRemove = "yes") do={
:do { /system scheduler remove [find name=$schedName] } on-error={}
:put ("Ежедневная проверка снята (" . $schedName . ")")
} else={
:put ("ipregion launcher " . $launcherVer . " (RouterOS)")
:global "vt-srcIp"
:if (([:typeof $"vt-srcIp"] != "str") or ([:len $"vt-srcIp"] = 0)) do={
:put "Задайте исходный IP и повторите импорт:"
:put ":global \"vt-srcIp\" \"1.2.3.4\""
:error "vt-srcIp не задан"
}
:local srcIp $"vt-srcIp"
:put ("vt-srcIp: " . $srcIp)
:local ingestOk false
:local ua "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0"
:local twitchClientId "kimne78kx3ncx6brgo4mv6wki5h1ko"
:local chatgptKey "client-zUdXdSTygXJdzoE0sWTkP8GKTVsUMF2IRM7ShVO2JAG"
:local spotifyKey "142b583129b2df829de3656f9eb484e6"
:local spotifyClientId "9a8d2f0ce77a4e248bb71fefcb557637"
:local netflixKey "YXNkZmFzZGxmbnNkYWZoYXNkZmhrYWxm"
:local redditBasic "b2hYcG9xclpZdWIxa2c6"
:local disneyKey "ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA"
:local youtubeSocs "CAISNQgDEitib3FfaWRlbnRpdHlmcm9udGVuZHVpc2VydmVyXzIwMjUwNzMwLjA1X3AwGgJlbiACGgYIgPC_xAY"
:local disneyBody "{\"query\":\" mutation registerDevice($registerDevice: RegisterDeviceInput!) { registerDevice(registerDevice: $registerDevice) { __typename } } \",\"variables\":{\"registerDevice\":{\"applicationRuntime\":\"android\",\"attributes\":{\"operatingSystem\":\"Android\",\"operatingSystemVersion\":\"13\"},\"deviceFamily\":\"android\",\"deviceLanguage\":\"en\",\"deviceProfile\":\"phone\",\"devicePlatformId\":\"android\"}},\"operationName\":\"registerDevice\"}"
:local twitchBody "[{\"operationName\":\"VerifyEmail_CurrentUser\",\"variables\":{},\"extensions\":{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"f9e7dcdf7e99c314c82d8f7f725fab5f99d1df3d7359b53c9ae122deec590198\"}}}]"
:local redditGqlBody "{\"operationName\":\"UserLocation\",\"variables\":{},\"extensions\":{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"f07de258c54537e24d7856080f662c1b1268210251e5789c8c08f20d76cc8ab2\"}}}"
:local redditUa "Reddit/Version 2025.29.0/Build 2529021/Android 13"
:local r
:local j
:local isp
:local org
:local publicIp ""
:do {
:set r [/tool fetch url="https://api.ipify.org" src-address=$srcIp output=user as-value]
:if (($r->"status") = "finished") do={
:set publicIp ($r->"data")
}
} on-error={}
:local cr [:find $publicIp "\r"]
:if ([:typeof $cr] != "nil") do={ :set publicIp [:pick $publicIp 0 $cr] }
:local lf [:find $publicIp "\n"]
:if ([:typeof $lf] != "nil") do={ :set publicIp [:pick $publicIp 0 $lf] }
:if ([:len $publicIp] = 0) do={
:do {
:set r [/tool fetch url="https://ifconfig.me/ip" src-address=$srcIp output=user as-value]
:if (($r->"status") = "finished") do={
:set publicIp ($r->"data")
}
} on-error={}
:set cr [:find $publicIp "\r"]
:if ([:typeof $cr] != "nil") do={ :set publicIp [:pick $publicIp 0 $cr] }
:set lf [:find $publicIp "\n"]
:if ([:typeof $lf] != "nil") do={ :set publicIp [:pick $publicIp 0 $lf] }
}
:if ([:len $publicIp] = 0) do={
:error "Не удалось определить публичный IP"
}
:local hoster ""
:do {
:set r [/tool fetch url=("https://ipwho.is/" . $publicIp) src-address=$srcIp output=user as-value]
:if (($r->"status") = "finished") do={
:set j [:deserialize from=json value=($r->"data")]
:set isp ($j->"connection"->"isp")
:if ([:typeof $isp] = "str") do={ :set hoster $isp }
:if ([:len $hoster] = 0) do={
:set org ($j->"org")
:if ([:typeof $org] = "str") do={ :set hoster $org }
}
}
} on-error={}
:local runId ("mt-" . [:rndstr length=16])
:put ("probe IP: " . $publicIp)
:if ([:len $hoster] > 0) do={ :put ("хостер: " . $hoster) }
:put ("runId: " . $runId)
:put "Проверяю GeoIP (JSON, IPv4)..."
:local names {\
"maxmind.com";"rdap.db.ripe.net";"ipinfo.io";"cloudflare.com";"ipregistry.co";\
"ipapi.co";"ifconfig.co";"ip2location.io";"iplocation.com";"country.is";\
"geoapify.com";"geojs.io";"ipapi.is";"ipbase.com";"ipquery.io";"ipwho.is";\
"ip-api.com";\
"Google";"YouTube";"Twitch";"ChatGPT";"Netflix";"Spotify";"Reddit";"Disney+";\
"Gemini Supported";"Reddit (Guest Access)";"YouTube Premium";"Spotify Signup";\
"Disney+ Access";"Apple";"Steam";"Tiktok";"Ookla Speedtest";"JetBrains";\
"PlayStation";"Microsoft";\
"Cloudflare CDN";"YouTube CDN";"Netflix CDN"\
}
:local resultJson ""
:local itemJson ""
:local url ""
:local method "get"
:local postData ""
:local fetchHdrs ""
:local iso "N/A"
:local httpCode 0
:local body ""
:local data
:local line
:local nl
:local cr2
:local sp1
:local rest2
:local sp2
:local codeStr
:local n
:local sep
:local p
:local plain
:local pcr
:local plf
:local ctry
:local colo
:local iata
:local ci
:local marker
:local tmp
:local tmp2
:local tmp3
:local googleCode ""
:local token ""
:local statusStr ""
:local launched ""
:local errCnt ""
:local inLoc ""
:local countryName ""
:local regionsMd ""
:local lineStart ""
:local iataLoc ""
:local word3 ""
:local dashPos ""
:local hdrData ""
:local wi 0
:local wc 0
:foreach name in=$names do={
:set url ""
:set method "get"
:set postData ""
:set fetchHdrs ""
:if ($name = "maxmind.com") do={
:set url "https://geoip.maxmind.com/geoip/v2.1/city/me"
:set fetchHdrs "Referer: https://www.maxmind.com"
}
:if ($name = "rdap.db.ripe.net") do={ :set url ("https://rdap.db.ripe.net/ip/" . $publicIp) }
:if ($name = "ipinfo.io") do={ :set url ("https://ipinfo.io/widget/demo/" . $publicIp) }
:if ($name = "cloudflare.com") do={ :set url "https://speed.cloudflare.com/meta" }
:if ($name = "ipregistry.co") do={
:set url ("https://api.ipregistry.co/" . $publicIp . "?hostname=true&key=sb69ksjcajfs4c")
:set fetchHdrs "Origin: https://ipregistry.co"
}
:if ($name = "ipapi.co") do={ :set url ("https://ipapi.co/" . $publicIp . "/json") }
:if ($name = "ifconfig.co") do={ :set url ("https://ifconfig.co/country-iso?ip=" . $publicIp) }
:if ($name = "ip2location.io") do={ :set url ("https://api.ip2location.io/?ip=" . $publicIp) }
:if ($name = "iplocation.com") do={
:set url "https://iplocation.com"
:set method "post"
:set postData ("ip=" . $publicIp)
:set fetchHdrs ("User-Agent: " . $ua)
}
:if ($name = "country.is") do={ :set url ("https://api.country.is/" . $publicIp) }
:if ($name = "geoapify.com") do={ :set url ("https://api.geoapify.com/v1/ipinfo?&ip=" . $publicIp . "&apiKey=b8568cb9afc64fad861a69edbddb2658") }
:if ($name = "geojs.io") do={ :set url ("https://get.geojs.io/v1/ip/country.json?ip=" . $publicIp) }
:if ($name = "ipapi.is") do={ :set url ("https://api.ipapi.is/?q=" . $publicIp) }
:if ($name = "ipbase.com") do={ :set url ("https://api.ipbase.com/v2/info?ip=" . $publicIp) }
:if ($name = "ipquery.io") do={ :set url ("https://api.ipquery.io/" . $publicIp) }
:if ($name = "ipwho.is") do={ :set url ("https://ipwho.is/" . $publicIp) }
:if ($name = "ip-api.com") do={
:set url ("https://demo.ip-api.com/json/" . $publicIp . "?fields=countryCode")
:set fetchHdrs "Origin: https://ip-api.com"
}
:if ($name = "Google") do={
:set url "https://accounts.google.com/v3/signin/identifier?flowName=GlifSetupAndroid"
:set fetchHdrs ("User-Agent: " . $ua)
}
:if ($name = "YouTube") do={ :set url "https://www.youtube.com/sw.js_data" }
:if ($name = "Twitch") do={
:set url "https://gql.twitch.tv/gql"
:set method "post"
:set postData $twitchBody
:set fetchHdrs ("Client-Id: " . $twitchClientId . ",Content-Type: application/json")
}
:if ($name = "ChatGPT") do={
:set url "https://ab.chatgpt.com/v1/initialize"
:set method "post"
:set postData "{}"
:set fetchHdrs ("Statsig-Api-Key: " . $chatgptKey . ",Content-Type: application/json")
}
:if ($name = "Netflix") do={ :set url ("https://api.fast.com/netflix/speedtest/v2?https=true&token=" . $netflixKey . "&urlCount=1") }
:if ($name = "Spotify") do={
:set url ("https://spclient.wg.spotify.com/signup/public/v1/account/?validate=1&key=" . $spotifyKey)
:set fetchHdrs ("X-Client-Id: " . $spotifyClientId)
}
:if ($name = "Reddit") do={ :set url "reddit-oauth" }
:if ($name = "Disney+") do={
:set url "https://disney.api.edge.bamgrid.com/graph/v1/device/graphql"
:set method "post"
:set postData $disneyBody
:set fetchHdrs ("Authorization: Bearer " . $disneyKey . ",Content-Type: application/json")
}
:if ($name = "Gemini Supported") do={ :set url "gemini-check" }
:if ($name = "Reddit (Guest Access)") do={
:set url "https://www.reddit.com"
:set fetchHdrs ("User-Agent: " . $ua)
}
:if ($name = "YouTube Premium") do={
:set url "https://www.youtube.com/premium"
:set fetchHdrs ("User-Agent: " . $ua . ",Cookie: SOCS=" . $youtubeSocs . ",Accept-Language: en-US,en;q=0.9")
}
:if ($name = "Spotify Signup") do={
:set url ("https://spclient.wg.spotify.com/signup/public/v1/account/?validate=1&key=" . $spotifyKey)
:set fetchHdrs ("X-Client-Id: " . $spotifyClientId)
}
:if ($name = "Disney+ Access") do={
:set url "https://disney.api.edge.bamgrid.com/graph/v1/device/graphql"
:set method "post"
:set postData $disneyBody
:set fetchHdrs ("Authorization: Bearer " . $disneyKey . ",Content-Type: application/json")
}
:if ($name = "Apple") do={ :set url "https://gspe1-ssl.ls.apple.com/pep/gcc" }
:if ($name = "Steam") do={
:set url "https://store.steampowered.com"
:set method "head"
}
:if ($name = "Tiktok") do={ :set url "https://www.tiktok.com/api/v1/web-cookie-privacy/config?appId=1988" }
:if ($name = "Ookla Speedtest") do={ :set url "https://www.speedtest.net/api/js/config-sdk" }
:if ($name = "JetBrains") do={ :set url "https://data.services.jetbrains.com/geo" }
:if ($name = "PlayStation") do={
:set url "https://www.playstation.com"
:set method "head"
}
:if ($name = "Microsoft") do={ :set url "https://login.live.com" }
:if ($name = "Cloudflare CDN") do={
:set url "https://speed.cloudflare.com/meta"
:set fetchHdrs "Referer: https://speed.cloudflare.com"
}
:if ($name = "YouTube CDN") do={ :set url "https://redirector.googlevideo.com/report_mapping?di=no" }
:if ($name = "Netflix CDN") do={ :set url ("https://api.fast.com/netflix/speedtest/v2?https=true&token=" . $netflixKey . "&urlCount=1") }
:set iso "N/A"
:set httpCode 0
:set body ""
:set hdrData ""
:if ($url = "reddit-oauth") do={
:set token ""
:do {
:set r [/tool fetch url="https://www.reddit.com/auth/v2/oauth/access-token/loid" src-address=$srcIp http-method=post http-header-field=("Authorization: Basic " . $redditBasic . ",Content-Type: application/json,User-Agent: " . $redditUa) http-data="{\"scopes\":[\"email\"]}" output=user as-value]
:if (($r->"status") = "finished") do={
:set j [:deserialize from=json value=($r->"data")]
:set token [:tostr ($j->"access_token")]
}
} on-error={}
:if ([:len $token] > 0) do={
:do {
:set r [/tool fetch url="https://gql-fed.reddit.com" src-address=$srcIp http-method=post http-header-field=("Authorization: Bearer " . $token . ",Content-Type: application/json,User-Agent: " . $redditUa) http-data=$redditGqlBody output=user as-value]
:if (($r->"status") = "finished") do={
:set body ($r->"data")
:set httpCode 200
}
} on-error={}
}
} else={
:if ($url = "gemini-check") do={
:set googleCode ""
:do {
:set r [/tool fetch url="https://accounts.google.com/v3/signin/identifier?flowName=GlifSetupAndroid" src-address=$srcIp http-header-field=("User-Agent: " . $ua) output=user-with-headers as-value]
:if (($r->"status") = "finished") do={
:set data ($r->"data")
:set sep "\r\n\r\n"
:set p [:find $data $sep]
:if ([:typeof $p] = "nil") do={ :set sep "\n\n"; :set p [:find $data $sep] }
:if ([:typeof $p] != "nil") do={ :set plain [:pick $data ($p + [:len $sep]) [:len $data]] } else={ :set plain $data }
:set marker [:find $plain "name=\"region\" value=\""]
:if ([:typeof $marker] != "nil") do={ :set googleCode [:pick $plain ($marker + 21) ($marker + 23)] }
}
} on-error={}
:if ([:len $googleCode] = 2) do={
:set countryName ""
:do {
:set r [/tool fetch url=("https://www.apicountries.com/alpha/" . $googleCode) src-address=$srcIp output=user as-value]
:if (($r->"status") = "finished") do={
:set j [:deserialize from=json value=($r->"data")]
:set countryName [:tostr ($j->"name")]
}
} on-error={}
:if (([:len $countryName] > 0) and ($countryName != "nil")) do={
:set regionsMd ""
:do {
:set r [/tool fetch url="https://ai.google.dev/gemini-api/docs/available-regions.md.txt" src-address=$srcIp output=user as-value]
:if (($r->"status") = "finished") do={ :set regionsMd ($r->"data") }
} on-error={}
:set lineStart ("- " . $countryName)
:if ([:find $regionsMd $lineStart] != nil) do={ :set iso "Yes" } else={ :set iso "No" }
}
}
:set httpCode 200
} else={
:do {
:if ($method = "head") do={
:if ([:len $fetchHdrs] > 0) do={
:set r [/tool fetch url=$url src-address=$srcIp http-method=head http-header-field=$fetchHdrs output=user-with-headers as-value]
} else={
:set r [/tool fetch url=$url src-address=$srcIp http-method=head output=user-with-headers as-value]
}
} else={
:if ($method = "post") do={
:if ([:len $fetchHdrs] = 0) do={ :set fetchHdrs "Content-Type: application/x-www-form-urlencoded" }
:if ([:find $fetchHdrs "Content-Type:"] = nil) do={
:if ([:find $postData "{"] = 0) do={
:set fetchHdrs ($fetchHdrs . ",Content-Type: application/json")
} else={
:set fetchHdrs ($fetchHdrs . ",Content-Type: application/x-www-form-urlencoded")
}
}
:set r [/tool fetch url=$url http-method=post http-data=$postData http-header-field=$fetchHdrs src-address=$srcIp output=user-with-headers as-value]
} else={
:if ([:len $fetchHdrs] > 0) do={
:set r [/tool fetch url=$url src-address=$srcIp http-header-field=$fetchHdrs output=user-with-headers as-value]
} else={
:set r [/tool fetch url=$url src-address=$srcIp output=user-with-headers as-value]
}
}
}
:if (($r->"status") != "finished") do={
:set httpCode -1
} else={
:set data ($r->"data")
:set hdrData $data
:set line $data
:set nl [:find $data "\n"]
:if ([:typeof $nl] != "nil") do={ :set line [:pick $data 0 $nl] }
:set cr2 [:find $line "\r"]
:if ([:typeof $cr2] != "nil") do={ :set line [:pick $line 0 $cr2] }
:set httpCode 200
:if ([:pick $line 0 4] = "HTTP") do={
:set sp1 [:find $line " "]
:if ([:typeof $sp1] != "nil") do={
:set rest2 [:pick $line ($sp1 + 1) [:len $line]]
:set sp2 [:find $rest2 " "]
:set codeStr $rest2
:if ([:typeof $sp2] != "nil") do={ :set codeStr [:pick $rest2 0 $sp2] }
:set n [:tonum $codeStr]
:if ([:typeof $n] = "num") do={ :set httpCode $n }
}
}
:set sep "\r\n\r\n"
:set p [:find $data $sep]
:if ([:typeof $p] = "nil") do={
:set sep "\n\n"
:set p [:find $data $sep]
}
:if ([:typeof $p] != "nil") do={
:set body [:pick $data ($p + [:len $sep]) [:len $data]]
} else={
:set body $data
}
}
} on-error={
:set httpCode 0
}
}
}
:if (($httpCode = 0) or ($httpCode = -1)) do={ :set iso "N/A" }
:if (($httpCode = 401) or ($httpCode = 403)) do={ :set iso "Denied" }
:if ($httpCode = 429) do={ :set iso "Rate-limit" }
:if ($httpCode >= 500) do={ :set iso "Server error" }
:if (($httpCode >= 200) and ($httpCode < 400)) do={
:if ($name = "ifconfig.co") do={
:set plain $body
:set pcr [:find $plain "\r"]
:if ([:typeof $pcr] != "nil") do={ :set plain [:pick $plain 0 $pcr] }
:set plf [:find $plain "\n"]
:if ([:typeof $plf] != "nil") do={ :set plain [:pick $plain 0 $plf] }
:if ([:len $plain] > 0) do={ :set iso $plain } else={ :set iso "N/A" }
} else={
:if ($name = "Google") do={
:set marker [:find $body "name=\"region\" value=\""]
:if ([:typeof $marker] != "nil") do={ :set iso [:pick $body ($marker + 21) ($marker + 23)] } else={ :set iso "N/A" }
} else={
:if ($name = "YouTube") do={
:set plain $body
:set nl [:find $plain "\n"]
:if ([:typeof $nl] != "nil") do={ :set plain [:pick $plain ($nl + 1) [:len $plain]] }
:set nl [:find $plain "\n"]
:if ([:typeof $nl] != "nil") do={ :set plain [:pick $plain ($nl + 1) [:len $plain]] }
:do {
:set j [:deserialize from=json value=$plain]
:set iso [:tostr (($j->0)->2->0->0->1)]
:if (([:len $iso] = 0) or ($iso = "nil")) do={ :set iso "N/A" }
} on-error={ :set iso "N/A" }
} else={
:if ($name = "Reddit (Guest Access)") do={
:if ([:find $body "Denied"] != nil) do={ :set iso "No" } else={ :set iso "Yes" }
} else={
:if ($name = "YouTube Premium") do={
:if ([:find $body "youtube premium is not available in your country"] != nil) do={ :set iso "No" } else={ :set iso "Yes" }
} else={
:if ($name = "Apple") do={
:set plain $body
:set pcr [:find $plain "\r"]
:if ([:typeof $pcr] != "nil") do={ :set plain [:pick $plain 0 $pcr] }
:set plf [:find $plain "\n"]
:if ([:typeof $plf] != "nil") do={ :set plain [:pick $plain 0 $plf] }
:if ([:len $plain] > 0) do={ :set iso $plain } else={ :set iso "N/A" }
} else={
:if ($name = "Steam") do={
:set marker [:find $hdrData "steamCountry="]
:if ([:typeof $marker] != "nil") do={
:set tmp [:pick $hdrData ($marker + 13) [:len $hdrData]]
:set tmp2 [:find $tmp ";"]
:if ([:typeof $tmp2] != "nil") do={ :set iso [:pick $tmp 0 $tmp2] } else={ :set iso $tmp }
} else={ :set iso "N/A" }
} else={
:if ($name = "PlayStation") do={
:set marker [:find $hdrData "country="]
:if ([:typeof $marker] != "nil") do={
:set tmp [:pick $hdrData ($marker + 8) [:len $hdrData]]
:set tmp2 [:find $tmp ";"]
:if ([:typeof $tmp2] != "nil") do={ :set iso [:pick $tmp 0 $tmp2] } else={ :set iso $tmp }
} else={ :set iso "N/A" }
} else={
:if ($name = "Microsoft") do={
:set marker [:find $body "\"sRequestCountry\":\""]
:if ([:typeof $marker] != "nil") do={ :set iso [:pick $body ($marker + 19) ($marker + 21)] } else={ :set iso "N/A" }
} else={
:if ($name = "YouTube CDN") do={
:set iata ""
:set word3 ""
:set tmp $body
:set wi 0
:set wc 0
:while ($wi < [:len $tmp]) do={
:if ([:pick $tmp $wi ($wi + 1)] = " ") do={ :set wc ($wc + 1) }
:if ($wc = 3) do={
:set word3 [:pick $tmp $wi [:len $tmp]]
:set wi [:len $tmp]
}
:set wi ($wi + 1)
}
:if ([:len $word3] > 0) do={
:set dashPos [:find $word3 "-"]
:if ([:typeof $dashPos] != "nil") do={
:set tmp2 [:pick $word3 ($dashPos + 1) [:len $word3]]
:if ([:len $tmp2] >= 3) do={ :set iata [:pick $tmp2 0 3] }
}
}
:if ([:len $iata] = 3) do={
:set iataLoc ""
:do {
:set r [/tool fetch url="https://www.air-port-codes.com/api/v1/single" src-address=$srcIp http-method=post http-header-field="APC-Auth: 96dc04b3fb,Referer: https://www.air-port-codes.com/,Content-Type: application/x-www-form-urlencoded" http-data=("iata=" . $iata) output=user as-value]
:if (($r->"status") = "finished") do={
:set j [:deserialize from=json value=($r->"data")]
:set iataLoc [:tostr ($j->"airport"->"country"->"iso")]
}
} on-error={}
:if (([:len $iataLoc] > 0) and ($iataLoc != "nil")) do={ :set iso ($iataLoc . " (" . $iata . ")") } else={ :set iso ("N/A (" . $iata . ")") }
} else={ :set iso "N/A" }
} else={
:do {
:set j [:deserialize from=json value=$body]
:if ($name = "maxmind.com") do={ :set iso [:tostr ($j->"country"->"iso_code")] }
:if ($name = "rdap.db.ripe.net") do={ :set iso [:tostr ($j->"country")] }
:if ($name = "ipinfo.io") do={
:set iso [:tostr ($j->"data"->"country")]
:if (([:len $iso] = 0) or ($iso = "nil")) do={ :set iso [:tostr ($j->"country")] }
}
:if ($name = "cloudflare.com") do={ :set iso [:tostr ($j->"country")] }
:if ($name = "ipregistry.co") do={ :set iso [:tostr ($j->"location"->"country"->"code")] }
:if ($name = "ipapi.co") do={ :set iso [:tostr ($j->"country")] }
:if ($name = "ip2location.io") do={ :set iso [:tostr ($j->"country_code")] }
:if ($name = "iplocation.com") do={ :set iso [:tostr ($j->"country_code")] }
:if ($name = "country.is") do={ :set iso [:tostr ($j->"country")] }
:if ($name = "geoapify.com") do={ :set iso [:tostr ($j->"country"->"iso_code")] }
:if ($name = "geojs.io") do={ :set iso [:tostr ($j->0->"country")] }
:if ($name = "ipapi.is") do={ :set iso [:tostr ($j->"location"->"country_code")] }
:if ($name = "ipbase.com") do={ :set iso [:tostr ($j->"data"->"location"->"country"->"alpha2")] }
:if ($name = "ipquery.io") do={ :set iso [:tostr ($j->"location"->"country_code")] }
:if ($name = "ipwho.is") do={ :set iso [:tostr ($j->"country_code")] }
:if ($name = "ip-api.com") do={ :set iso [:tostr ($j->"countryCode")] }
:if ($name = "Twitch") do={ :set iso [:tostr ($j->0->"data"->"requestInfo"->"countryCode")] }
:if ($name = "ChatGPT") do={ :set iso [:tostr ($j->"derived_fields"->"country")] }
:if ($name = "Netflix") do={ :set iso [:tostr ($j->"client"->"location"->"country")] }
:if ($name = "Spotify") do={ :set iso [:tostr ($j->"country")] }
:if ($name = "Reddit") do={ :set iso [:tostr ($j->"data"->"userLocation"->"countryCode")] }
:if ($name = "Disney+") do={ :set iso [:tostr ($j->"extensions"->"sdk"->"session"->"location"->"countryCode")] }
:if ($name = "Spotify Signup") do={
:set statusStr [:tostr ($j->"status")]
:set launched [:tostr ($j->"is_country_launched")]
:if (($statusStr = "120") or ($statusStr = "320") or ($launched = "false")) do={ :set iso "No" } else={ :set iso "Yes" }
}
:if ($name = "Disney+ Access") do={
:set inLoc [:tostr ($j->"extensions"->"sdk"->"session"->"inSupportedLocation")]
:if ($inLoc = "true") do={ :set iso "Yes" } else={ :set iso "No" }
}
:if ($name = "Tiktok") do={ :set iso [:tostr ($j->"body"->"appProps"->"region")] }
:if ($name = "Ookla Speedtest") do={ :set iso [:tostr ($j->"location"->"countryCode")] }
:if ($name = "JetBrains") do={ :set iso [:tostr ($j->"code")] }
:if ($name = "Netflix CDN") do={ :set iso [:tostr ($j->"targets"->0->"location"->"country")] }
:if ($name = "Cloudflare CDN") do={
:set ctry [:tostr ($j->"country")]
:set colo ($j->"colo")
:set iata ""
:if ([:typeof $colo] = "str") do={ :set iata $colo }
:if ([:typeof $colo] = "array") do={
:set ci ($colo->"iata")
:if ([:typeof $ci] = "str") do={ :set iata $ci }
}
:if (([:len $iata] = 3)) do={
:set iataLoc ""
:do {
:set r [/tool fetch url="https://www.air-port-codes.com/api/v1/single" src-address=$srcIp http-method=post http-header-field="APC-Auth: 96dc04b3fb,Referer: https://www.air-port-codes.com/,Content-Type: application/x-www-form-urlencoded" http-data=("iata=" . $iata) output=user as-value]
:if (($r->"status") = "finished") do={
:set j [:deserialize from=json value=($r->"data")]
:set iataLoc [:tostr ($j->"airport"->"country"->"iso")]
}
} on-error={}
:if (([:len $iataLoc] > 0) and ($iataLoc != "nil")) do={ :set iso ($iataLoc . " (" . $iata . ")") } else={
:if (([:len $ctry] > 0) and ($ctry != "nil")) do={ :set iso ($ctry . " (" . $iata . ")") } else={ :set iso ("N/A (" . $iata . ")") }
}
} else={
:if (([:len $ctry] > 0) and ($ctry != "nil")) do={ :set iso $ctry } else={ :set iso "N/A" }
}
}
:if (([:len $iso] = 0) or ($iso = "nil")) do={ :set iso "N/A" }
} on-error={
:set iso "Server error"
}
}
}
}
}
}
}
}
}
}
}
}
:put ($name . " " . $iso)
:set itemJson ("{\"service\":\"" . $name . "\",\"ipv4\":\"" . $iso . "\"}")
:if ([:len $resultJson] > 0) do={ :set resultJson ($resultJson . ",") }
:set resultJson ($resultJson . $itemJson)
}
:local hosterJson ""
:if ([:len $hoster] > 0) do={
:local esc ""
:local hi 0
:while ($hi < [:len $hoster]) do={
:local ch [:pick $hoster $hi ($hi + 1)]
:if ($ch = "\\") do={ :set esc ($esc . "\\\\") } else={
:if ($ch = "\"") do={ :set esc ($esc . "\\\"") } else={ :set esc ($esc . $ch) }
}
:set hi ($hi + 1)
}
:set hosterJson (",\"hoster\":\"" . $esc . "\"")
}
:if ([:len $resultJson] = 0) do={ :error "ingest: results пустой" }
:local json ("{\"schemaVersion\":1,\"runId\":\"" . $runId . "\",\"probe\":{\"publicIp\":\"" . $publicIp . "\"" . $hosterJson . "},\"launcherVersion\":\"" . $launcherVer . "\",\"ipregion\":{\"version\":\"ros\"},\"results\":[" . $resultJson . "]}")
:local hdrs ("Content-Type: application/json,Authorization: Bearer " . $vtToken)
:put ("Отправляю ingest (" . [:len $json] . " B)...")
:set ingestOk false
:onerror err,attr in={
:set r [/tool fetch url=($vtApi . "/api/integrations/ipregion/runs") src-address=$srcIp http-method=post http-header-field=$hdrs http-data=$json output=user as-value]
:put ($r->"data")
:set ingestOk true
} do={
:put ("ingest: " . $err)
:if (([:typeof $attr] = "array") and ([:typeof ($attr->"code")] != "nil")) do={
:put ("code: " . ($attr->"code"))
}
:if (([:typeof $attr] = "array") and ([:typeof ($attr->"data")] = "str") and ([:len ($attr->"data")] > 0)) do={
:put ($attr->"data")
}
:if ([:len $json] > 120) do={ :put ([:pick $json 0 120] . "...") } else={ :put $json }
}
:if ($ingestOk = false) do={
:put "повтор ingest без src-address..."
:onerror err2,attr2 in={
:set r [/tool fetch url=($vtApi . "/api/integrations/ipregion/runs") http-method=post http-header-field=$hdrs http-data=$json output=user as-value]
:put ($r->"data")
} do={
:put ("ingest retry: " . $err2)
:if (([:typeof $attr2] = "array") and ([:typeof ($attr2->"data")] = "str") and ([:len ($attr2->"data")] > 0)) do={
:put ($attr2->"data")
}
:if ([:len $json] > 120) do={ :put ([:pick $json 0 120] . "...") } else={ :put $json }
}
}
:if ($vtDaily = "yes") do={
:local ident [/system identity get name]
:local h ([:len $ident] + [:len [/system resource get architecture-name]])
:local hour (3 + ($h % 4))
:local minute (($h * 17) % 60)
:local hh $hour
:local mm $minute
:if ($hour < 10) do={ :set hh ("0" . $hour) }
:if ($minute < 10) do={ :set mm ("0" . $minute) }
:local startTime ($hh . ":" . $mm . ":00")
:local ev (":global \"vt-srcIp\" \"" . $srcIp . "\"; /tool fetch url=" . $vtApi . "/ic.rsc dst-path=" . $dstFile . "; /import file-name=" . $dstFile)
:do { /system scheduler remove [find name=$schedName] } on-error={}
/system scheduler add name=$schedName interval=1d start-time=$startTime on-event=$ev policy=read,write,test,policy comment="vps-tracker vt-ic"
:put ("Ежедневная проверка: каждый день в " . $startTime . " (" . $schedName . ")")
:put ("Снять: /tool fetch url=" . $vtApi . "/ic.rsc?remove=daily dst-path=" . $dstFile . "; /import file-name=" . $dstFile)
}
}
+479
View File
@@ -0,0 +1,479 @@
#!/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="2"
VENDOR_SHA="7d1c25c"
DAILY_SLUG="vt-ipregion"
DAILY_PATH="/ic"
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
}
show_launcher_help() {
cat <<EOF
Использование:
curl -fsSL ${VT_API_URL}${DAILY_PATH} | bash
curl -fsSL ${VT_API_URL}${DAILY_PATH} | bash -s -- --daily
curl -fsSL ${VT_API_URL}${DAILY_PATH} | bash -s -- --remove-daily
--daily выполнить проверку и поставить cron раз в сутки
--remove-daily убрать ежедневный cron
EOF
}
daily_run_cmd() {
local url="${VT_API_URL}${DAILY_PATH}"
local curl_bin bash_bin flock_bin
curl_bin="$(command -v curl)"
bash_bin="$(command -v bash)"
flock_bin="$(command -v flock || true)"
if [ -n "$flock_bin" ]; then
printf "%s -n /tmp/%s.lock -c '%s -fsSL --max-time 180 %s | %s'" \
"$flock_bin" "$DAILY_SLUG" "$curl_bin" "$url" "$bash_bin"
else
printf '%s -fsSL --max-time 180 %s | %s' "$curl_bin" "$url" "$bash_bin"
fi
}
daily_slot() {
local seed n
seed="$(hostname -f 2>/dev/null || hostname 2>/dev/null || cat /etc/hostname 2>/dev/null || echo vt)"
n="$(printf '%s' "$seed" | cksum | awk '{print $1}')"
DAILY_MINUTE=$((n % 60))
DAILY_HOUR=$((3 + (n / 60) % 4))
}
remove_daily() {
local prefix="" removed=0
if [ -e "/etc/cron.d/${DAILY_SLUG}" ]; then
if prefix="$(root_prefix)"; then
# shellcheck disable=SC2086
$prefix rm -f "/etc/cron.d/${DAILY_SLUG}"
removed=1
fi
fi
if command -v crontab >/dev/null 2>&1; then
local current
current="$(crontab -l 2>/dev/null || true)"
if printf '%s\n' "$current" | grep -qF "vps-tracker:${DAILY_SLUG}"; then
printf '%s\n' "$current" | grep -vF "vps-tracker:${DAILY_SLUG}" | crontab -
removed=1
fi
fi
if [ "$removed" -eq 1 ]; then
log "Ежедневная проверка снята (${DAILY_SLUG})"
else
log "Ежедневная проверка не была установлена"
fi
}
install_daily() {
daily_slot
local run line prefix=""
local marker="# vps-tracker:${DAILY_SLUG}"
run="$(daily_run_cmd)"
if [ -d /etc/cron.d ] && prefix="$(root_prefix)"; then
local tmp
tmp="$(mktemp)"
cat >"$tmp" <<EOF
# Managed by VPS Tracker launcher. ${marker}
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=""
${DAILY_MINUTE} ${DAILY_HOUR} * * * root ${run} >>/var/log/${DAILY_SLUG}.log 2>&1
EOF
# shellcheck disable=SC2086
$prefix cp "$tmp" "/etc/cron.d/${DAILY_SLUG}"
# shellcheck disable=SC2086
$prefix chmod 644 "/etc/cron.d/${DAILY_SLUG}"
rm -f "$tmp"
log "Ежедневная проверка: каждый день в ${DAILY_HOUR}:$(printf '%02d' "$DAILY_MINUTE") (/etc/cron.d/${DAILY_SLUG})"
log "Снять: curl -fsSL ${VT_API_URL}${DAILY_PATH} | bash -s -- --remove-daily"
return 0
fi
if ! command -v crontab >/dev/null 2>&1; then
die "Нет cron (ни /etc/cron.d, ни crontab). Установите пакет cron и повторите --daily"
fi
line="${DAILY_MINUTE} ${DAILY_HOUR} * * * ${run} >>/tmp/${DAILY_SLUG}.log 2>&1 ${marker}"
{
(crontab -l 2>/dev/null || true) | grep -vF "vps-tracker:${DAILY_SLUG}" || true
printf '%s\n' "$line"
} | crontab -
log "Ежедневная проверка: каждый день в ${DAILY_HOUR}:$(printf '%02d' "$DAILY_MINUTE") (crontab)"
log "Снять: curl -fsSL ${VT_API_URL}${DAILY_PATH} | bash -s -- --remove-daily"
}
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 пуст"
DAILY_INSTALL=0
DAILY_REMOVE=0
for arg in "$@"; do
case "$arg" in
--daily|--install-daily) DAILY_INSTALL=1 ;;
--remove-daily|--uninstall-daily) DAILY_REMOVE=1 ;;
-h|--help) show_launcher_help; exit 0 ;;
*) die "Неизвестный аргумент: $arg. См. --help" ;;
esac
done
if [ "$DAILY_REMOVE" -eq 1 ]; then
remove_daily
exit 0
fi
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
if [ "$DAILY_INSTALL" -eq 1 ]; then
install_daily
fi
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
if [ "$DAILY_INSTALL" -eq 1 ]; then
install_daily
fi
exit 2
fi
log "Check ID: ${CHECK_ID}"
log "VPS: ${MATCHED}"
if [ "$DAILY_INSTALL" -eq 1 ]; then
install_daily
fi
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',
},
{
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { coerceRouterosIngestBody } from './ros-ingest.js'
describe('coerceRouterosIngestBody', () => {
it('превращает results с числовыми ключами в массив', () => {
const out = coerceRouterosIngestBody({
schemaVersion: '1',
runId: 'mt-abcdefghijklmnop',
probe: { publicIp: '185.246.117.91' },
results: {
'0': { service: 'maxmind.com', ipv4: 'N/A' },
'1': { service: 'ipinfo.io', ipv4: 'RU' },
},
}) as { schemaVersion: number; results: Array<{ service: string }> }
expect(out.schemaVersion).toBe(1)
expect(out.results).toEqual([
{ service: 'maxmind.com', ipv4: 'N/A' },
{ service: 'ipinfo.io', ipv4: 'RU' },
])
})
it('оставляет обычный массив без изменений', () => {
const results = [{ service: 'ipinfo.io', ipv4: 'RU' }]
const out = coerceRouterosIngestBody({
schemaVersion: 1,
results,
}) as { results: unknown }
expect(out.results).toBe(results)
})
it('парсит JSON-строку', () => {
const out = coerceRouterosIngestBody(
'{"schemaVersion":1,"results":{"0":{"service":"a"}}}',
) as { results: Array<{ service: string }> }
expect(out.results).toEqual([{ service: 'a' }])
})
})
+33
View File
@@ -0,0 +1,33 @@
/**
* RouterOS `:serialize to=json` of `$arr->0 = $item` yields
* `{"0": {...}, "1": {...}}` instead of a JSON array.
* Numbers may also come as strings (`schemaVersion: "1"`).
*/
export function coerceRouterosIngestBody(body: unknown): unknown {
let value: unknown = body
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value)) {
value = value.toString('utf8')
}
if (typeof value === 'string') {
try {
value = JSON.parse(value)
} catch {
return body
}
}
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
const rec = { ...(value as Record<string, unknown>) }
if (rec.schemaVersion === '1') rec.schemaVersion = 1
rec.results = coerceIndexedObjectToArray(rec.results)
return rec
}
function coerceIndexedObjectToArray(value: unknown): unknown {
if (Array.isArray(value) || value == null || typeof value !== 'object') return value
const obj = value as Record<string, unknown>
const keys = Object.keys(obj)
if (keys.length === 0) return value
if (!keys.every((key) => /^\d+$/.test(key))) return value
return keys.sort((a, b) => Number(a) - Number(b)).map((key) => obj[key])
}
+3 -1
View File
@@ -74,9 +74,11 @@ function isPublicPath(url: string): boolean {
const path = url.split('?')[0] ?? url
if (path === '/health' || path === '/ready') return true
if (path === '/api/auth/config') return true
if (path === '/cc' || path.startsWith('/cc/')) return true
if (path === '/cc' || path === '/cc.rsc' || path.startsWith('/cc/')) return true
if (path === '/ic' || path === '/ic.rsc' || 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
}
+59 -1
View File
@@ -154,10 +154,68 @@ describe('censorcheck ingest + reads', () => {
await post(ingestPayload())
const list = await app.inject({ method: 'GET', url: '/api/censorcheck/runs?limit=10' })
expect(list.statusCode).toBe(200)
const items = list.json().items as Array<{ id: string }>
const items = list.json().items as Array<{ id: string; results?: unknown[] }>
expect(items).toHaveLength(1)
expect(items[0]!.results).toHaveLength(1)
const detail = await app.inject({ method: 'GET', url: `/api/censorcheck/runs/${items[0]!.id}` })
expect(detail.statusCode).toBe(200)
expect(detail.json().results).toHaveLength(1)
})
it('сохраняет хостер из probe и канонизирует ASN', async () => {
const res = await post(
ingestPayload({
runId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
probe: { publicIp: '203.0.113.10', hoster: 'AS14061 DigitalOcean, LLC' },
}),
)
expect(res.statusCode).toBe(200)
const current = await app.inject({ method: 'GET', url: '/api/censorcheck/current' })
expect(current.json().items[0].detectedHoster).toBe('DigitalOcean')
expect(current.json().items[0].vps).toBeNull()
})
it('для matched VPS отдаёт имя хостера из инвентаря', async () => {
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,
}),
)
await post(
ingestPayload({
runId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
probe: { publicIp: '203.0.113.10', hoster: 'Hetzner Online GmbH' },
}),
)
const current = await app.inject({ method: 'GET', url: '/api/censorcheck/current' })
const item = current.json().items[0]
expect(item.vps.providerName).toBe('Test Host')
expect(item.detectedHoster).toBe('Hetzner')
})
it('принимает results как объект с числовыми ключами (RouterOS serialize)', async () => {
const res = await post(
ingestPayload({
schemaVersion: '1',
runId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
results: {
'0': {
service: 'youtube.com',
raw: { https: { ipv4: { status: 200 } } },
},
},
}),
)
expect(res.statusCode).toBe(200)
expect(res.json().summary.available).toBe(1)
})
})
+4 -2
View File
@@ -1,5 +1,5 @@
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import { censorcheckIngestBodySchema } from '@cfdm/shared/contracts/censorcheck'
import { canonicalizeHoster, censorcheckIngestBodySchema } from '@cfdm/shared/contracts/censorcheck'
import { censorcheckRepository } from '@cfdm/db/repositories/censorcheck'
import { actorFromRequest } from '../lib/audit-actor.js'
import {
@@ -7,6 +7,7 @@ import {
ingestSecret,
verifyIngestToken,
} from '../services/censorcheck/ingest-token.js'
import { coerceRouterosIngestBody } from '../lib/ros-ingest.js'
import { matchVpsByPublicIp, resolveProbeIp } from '../services/censorcheck/match-ip.js'
import { normalizeIngestResult, summarizeResults } from '../services/censorcheck/normalize.js'
@@ -44,7 +45,7 @@ export const censorcheckRoutes: FastifyPluginAsync = async (app) => {
async (request, reply) => {
if (!requireIngestToken(request, reply)) return
const parsed = censorcheckIngestBodySchema.safeParse(request.body)
const parsed = censorcheckIngestBodySchema.safeParse(coerceRouterosIngestBody(request.body))
if (!parsed.success) {
return sendError(reply, 400, 'VALIDATION', parsed.error.message)
}
@@ -82,6 +83,7 @@ export const censorcheckRoutes: FastifyPluginAsync = async (app) => {
censorcheckVersion: parsed.data.censorcheck?.version ?? null,
summary,
observedSourceIp: observed ?? null,
detectedHoster: canonicalizeHoster(parsed.data.probe.hoster),
results,
})
+158
View File
@@ -0,0 +1,158 @@
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')
})
it('принимает results как объект с числовыми ключами (RouterOS serialize)', async () => {
const res = await post(
ingestPayload({
schemaVersion: '1',
runId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
results: {
'0': { service: 'maxmind.com', ipv4: 'NL' },
'1': { service: 'ipinfo.io', ipv4: 'RU' },
},
}),
)
expect(res.statusCode).toBe(200)
expect(res.json().summary.ok).toBe(2)
})
})
+130
View File
@@ -0,0 +1,130 @@
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 { coerceRouterosIngestBody } from '../lib/ros-ingest.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(coerceRouterosIngestBody(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
})
}
+208
View File
@@ -28,9 +28,16 @@ describe('GET /cc launcher', () => {
expect(res.body).toContain('VT_INGEST_TOKEN')
expect(res.body).toContain('ensure_cmds jq dig column')
expect(res.body).toContain('detect_os')
expect(res.body).toContain('detect_hoster')
expect(res.body).toContain('ipwho.is')
expect(res.body).toContain('/etc/os-release')
expect(res.body).toContain('apt-get install -y -qq')
expect(res.body).toContain('Проверяю сайты')
expect(res.body).toContain('--daily')
expect(res.body).toContain('--remove-daily')
expect(res.body).toContain('DAILY_SLUG="vt-censorcheck"')
expect(res.body).toContain('vps-tracker:${DAILY_SLUG}')
expect(res.body).toContain('/etc/cron.d')
expect(res.body).not.toContain('2>/tmp/vt-censorcheck-err')
expect(res.body).not.toContain('\r')
expect(res.body).not.toContain('__VT_API_URL__')
@@ -48,3 +55,204 @@ 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).toContain('--daily')
expect(res.body).toContain('--remove-daily')
expect(res.body).toContain('DAILY_SLUG="vt-ipregion"')
expect(res.body).toContain('vps-tracker:${DAILY_SLUG}')
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')
})
})
describe('GET /cc.rsc RouterOS 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('отдаёт .rsc с токеном и no-store', async () => {
const res = await app.inject({ method: 'GET', url: '/cc.rsc' })
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('/tool fetch')
expect(res.body).toContain('/api/integrations/censorcheck/runs')
expect(res.body).toContain('ros-7')
expect(res.body).toContain(':local vtDaily "no"')
expect(res.body).toContain(':local vtRemove "no"')
expect(res.body).toContain('/system scheduler')
expect(res.body).toContain('youtube.com')
expect(res.body).toContain('vt-srcIp')
expect(res.body).toContain(':global "vt-srcIp"')
expect(res.body).toContain('src-address=$srcIp')
expect(res.body).toContain(':onerror')
expect(res.body).toContain('повтор ingest без src-address')
expect(res.body).toContain('schemaVersion\\":1')
expect(res.body).not.toContain('mark-routing')
expect(res.body).not.toContain('vtIface')
expect(res.body).not.toContain('\r')
expect(res.body).not.toContain('__VT_API_URL__')
expect(res.body).not.toContain('__VT_INGEST_TOKEN__')
expect(res.body).not.toContain('__VT_DAILY__')
expect(res.body).not.toContain('__VT_REMOVE_DAILY__')
})
it('?daily=1 включает установку scheduler', async () => {
const res = await app.inject({ method: 'GET', url: '/cc.rsc?daily=1' })
expect(res.statusCode).toBe(200)
expect(res.body).toContain(':local vtDaily "yes"')
expect(res.body).toContain(':local vtRemove "no"')
expect(res.body).toContain('/system scheduler add')
})
it('?remove=daily снимает scheduler без проб', async () => {
const res = await app.inject({ method: 'GET', url: '/cc.rsc?remove=daily' })
expect(res.statusCode).toBe(200)
expect(res.body).toContain(':local vtRemove "yes"')
expect(res.body).toContain(':local vtDaily "no"')
expect(res.body).toContain('Ежедневная проверка снята')
})
})
describe('GET /ic.rsc RouterOS 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('отдаёт .rsc с токеном и no-store', async () => {
const res = await app.inject({ method: 'GET', url: '/ic.rsc' })
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('/tool fetch')
expect(res.body).toContain('/api/integrations/ipregion/runs')
expect(res.body).toContain('ros-8')
expect(res.body).toContain(':local vtDaily "no"')
expect(res.body).toContain('ipinfo.io')
expect(res.body).toContain('Cloudflare CDN')
expect(res.body).toContain('YouTube CDN')
expect(res.body).toContain('YouTube')
expect(res.body).toContain('Netflix CDN')
expect(res.body).toContain('vt-srcIp')
expect(res.body).toContain(':global "vt-srcIp"')
expect(res.body).toContain('src-address=$srcIp')
expect(res.body).toContain(':onerror')
expect(res.body).toContain('повтор ingest без src-address')
expect(res.body).toContain('schemaVersion\\":1')
expect(res.body).not.toContain('mark-routing')
expect(res.body).not.toContain('vtIface')
expect(res.body).not.toContain('\r')
expect(res.body).not.toContain('__VT_API_URL__')
expect(res.body).not.toContain('__VT_INGEST_TOKEN__')
})
it('?daily=1 включает установку scheduler', async () => {
const res = await app.inject({ method: 'GET', url: '/ic.rsc?daily=1' })
expect(res.statusCode).toBe(200)
expect(res.body).toContain(':local vtDaily "yes"')
expect(res.body).toContain('/system scheduler add')
})
})
describe('GET /cc.rsc without ingest secret', () => {
let app: Awaited<ReturnType<typeof buildApp>>
const prevNodeEnv = process.env.NODE_ENV
const prevAuthRequired = process.env.AUTH_REQUIRED
const prevCc = process.env.CENSORCHECK_INGEST_SECRET
const prevAuth = process.env.AUTH_JWT_SECRET
const prevJwt = process.env.JWT_SECRET
beforeEach(async () => {
process.env.NODE_ENV = 'production'
process.env.AUTH_REQUIRED = 'false'
delete process.env.CENSORCHECK_INGEST_SECRET
delete process.env.AUTH_JWT_SECRET
delete process.env.JWT_SECRET
process.env.CENSORCHECK_RATE_LIMIT = '0'
resetTestDb()
app = await buildApp()
})
afterEach(async () => {
await app.close()
closeDb()
process.env.NODE_ENV = prevNodeEnv
if (prevAuthRequired === undefined) delete process.env.AUTH_REQUIRED
else process.env.AUTH_REQUIRED = prevAuthRequired
if (prevCc === undefined) delete process.env.CENSORCHECK_INGEST_SECRET
else process.env.CENSORCHECK_INGEST_SECRET = prevCc
if (prevAuth === undefined) delete process.env.AUTH_JWT_SECRET
else process.env.AUTH_JWT_SECRET = prevAuth
if (prevJwt === undefined) delete process.env.JWT_SECRET
else process.env.JWT_SECRET = prevJwt
})
it('отвечает 503', async () => {
const res = await app.inject({ method: 'GET', url: '/cc.rsc' })
expect(res.statusCode).toBe(503)
expect(res.body).toMatch(/not configured/)
})
})
+110 -21
View File
@@ -29,6 +29,78 @@ function sendPlain(reply: FastifyReply, body: string, cache: 'no-store' | 'publi
.send(unixText(body))
}
const IPREGION_SCRIPT_DIR = join(__dirname, '..', '..', 'scripts', 'ipregion')
function truthyQuery(value: string | undefined): boolean {
return value === '1' || value === 'true' || value === 'yes'
}
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')
}
type RosQuery = { daily?: string; remove?: string }
function mintRosLauncher(
reply: FastifyReply,
secret: string | undefined,
scriptDir: string,
missingSecretMessage: string,
query: RosQuery,
): void {
if (!secret) {
void reply.code(503).send(missingSecretMessage)
return
}
const remove = query.remove === 'daily' || truthyQuery(query.remove)
const daily = !remove && truthyQuery(query.daily)
const apiUrl = censorcheckPublicUrl()
const token = mintIngestToken(secret)
let template: string
try {
template = readFileSync(join(scriptDir, 'launcher.rsc'), '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)
.replaceAll('__VT_DAILY__', daily ? 'yes' : 'no')
.replaceAll('__VT_REMOVE_DAILY__', remove ? 'yes' : 'no')
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 +110,46 @@ 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<{ Querystring: RosQuery }>(
'/cc.rsc',
ccOpts,
async (request: FastifyRequest<{ Querystring: RosQuery }>, reply: FastifyReply) => {
mintRosLauncher(
reply,
secret,
SCRIPT_DIR,
'censorcheck ingest is not configured\n',
request.query,
)
},
)
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<{ Querystring: RosQuery }>(
'/ic.rsc',
ccOpts,
async (request: FastifyRequest<{ Querystring: RosQuery }>, reply: FastifyReply) => {
mintRosLauncher(
reply,
secret,
IPREGION_SCRIPT_DIR,
'ipregion ingest is not configured\n',
request.query,
)
},
)
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,21 @@
import { describe, expect, it } from 'vitest'
import { canonicalizeHoster } from '@cfdm/shared/contracts/censorcheck'
describe('canonicalizeHoster', () => {
it('мапит ASN/org на короткое имя', () => {
expect(canonicalizeHoster('AS14061 DigitalOcean, LLC')).toBe('DigitalOcean')
expect(canonicalizeHoster('Hetzner Online GmbH')).toBe('Hetzner')
expect(canonicalizeHoster('hosted-by.vdsina.ru')).toBe('VDSina')
expect(canonicalizeHoster(' aeza.net. ')).toBe('Aeza')
})
it('пустую строку отбрасывает', () => {
expect(canonicalizeHoster('')).toBeNull()
expect(canonicalizeHoster(' ')).toBeNull()
expect(canonicalizeHoster(undefined)).toBeNull()
})
it('неизвестную org чистит без alias', () => {
expect(canonicalizeHoster('AS12345 Example Hosting LLC')).toBe('Example Hosting')
})
})
@@ -0,0 +1,109 @@
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', iata: null })
expect(canonicalizeCountryValue(' de ')).toEqual({ status: 'ok', country: 'DE', iata: null })
})
it('мапит CDN ISO + IATA', () => {
expect(canonicalizeCountryValue('SE (ARN)')).toEqual({
status: 'ok',
country: 'SE',
iata: 'ARN',
})
expect(canonicalizeCountryValue('RS (BEG)')).toEqual({
status: 'ok',
country: 'RS',
iata: 'BEG',
})
expect(canonicalizeCountryValue('(ARN)')).toEqual({
status: 'ok',
country: null,
iata: 'ARN',
})
})
it('мапит статусы ipregion', () => {
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('сохраняет CDN ISO + IATA и N/A', () => {
const cf = normalizeIngestResult({
service: 'Cloudflare CDN',
group: 'cdn',
ipv4: 'SE (ARN)',
})
expect(cf.status).toBe('ok')
expect(cf.countryIpv4).toBe('SE (ARN)')
expect(cf.group).toBe('cdn')
const yt = normalizeIngestResult({
service: 'YouTube CDN',
ipv4: 'RS (BEG)',
})
expect(yt.status).toBe('ok')
expect(yt.countryIpv4).toBe('RS (BEG)')
const nflx = normalizeIngestResult({
service: 'Netflix CDN',
ipv4: 'N/A',
})
expect(nflx.status).toBe('na')
expect(nflx.countryIpv4).toBeNull()
})
it('считает summary и partial', () => {
const { summary, runStatus } = summarizeResults([
{ status: 'ok' },
{ 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,53 @@
import {
canonicalizeCountryValue,
emptyIpregionSummary,
formatCanonicalCountry,
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: formatCanonicalCountry(ipv4),
countryIpv6: formatCanonicalCountry(ipv6),
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,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { Filter } from '@/components/reui/filters'
import { filterCensorcheckRuns, groupRunsByService, collectServiceColumns, collectProbeColumns, shortHostLabel } from './blocking-filters'
import type { CensorcheckRunDto } from './types'
import { runHosterLabel, type CensorcheckRunDto } from './types'
const run = (overrides: Partial<CensorcheckRunDto> = {}): CensorcheckRunDto => ({
id: 'ccrun-1',
@@ -88,6 +88,37 @@ describe('filterCensorcheckRuns', () => {
]),
).toHaveLength(0)
})
it('фильтрует по хостеру из прогона, если инвентарь пуст', () => {
const unmatched = run({
matchedVpsId: null,
vps: null,
detectedHoster: 'DigitalOcean',
})
expect(
filterCensorcheckRuns([unmatched], [
{ id: '1', field: 'hoster', operator: 'contains', values: ['digital'] },
]),
).toHaveLength(1)
expect(
filterCensorcheckRuns([unmatched], [
{ id: '1', field: 'hoster', operator: 'contains', values: ['hetzner'] },
]),
).toHaveLength(0)
})
})
describe('runHosterLabel', () => {
it('предпочитает имя из инвентаря', () => {
expect(runHosterLabel(run())).toBe('Hoster')
expect(runHosterLabel(run({ detectedHoster: 'Hetzner' }))).toBe('Hoster')
})
it('берёт detectedHoster если VPS не сматчен', () => {
expect(runHosterLabel(run({ matchedVpsId: null, vps: null, detectedHoster: 'Hetzner' }))).toBe(
'Hetzner',
)
})
})
describe('groupRunsByService', () => {
@@ -39,7 +39,7 @@ export function filterCensorcheckRuns(
continue
}
if (filter.field === 'hoster') {
const name = (run.vps?.providerName ?? '').toLowerCase()
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
@@ -13,7 +13,7 @@ import {
} from './blocking-filters'
import { resolveServiceIcon, ServiceGlyph } from './service-icons'
import { StatusMatrixCell } from './status-matrix-cell'
import type { CensorcheckRunDto } from './types'
import { runHosterLabel, type CensorcheckRunDto } 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 BLOCKING_MATRIX_GRID = {
@@ -31,12 +31,14 @@ function vpsIdentityColumn(): DataGridColumn<CensorcheckRunDto> {
icon: ServerIcon,
enableHiding: false,
enablePinning: true,
size: 220,
minSize: 180,
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"
@@ -49,7 +51,7 @@ function vpsIdentityColumn(): DataGridColumn<CensorcheckRunDto> {
) : (
<span className="font-medium">Unknown VPS</span>
)
return dataGridCellStack(link, ip)
return dataGridCellStack(link, secondary)
},
}
}
@@ -2,8 +2,10 @@ import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
BanIcon,
CalendarClockIcon,
CopyIcon,
GlobeIcon,
RouterIcon,
ServerIcon,
ShieldAlertIcon,
} from 'lucide-react'
@@ -26,11 +28,21 @@ import {
import { StatusBadge } from '@/components/status-badge'
import type { DataGridColumn } from '@/components/data-grid-types'
import { BlockingServiceGrid, BlockingVpsGrid } from './blocking-grid'
import { BlockingSnapshotScrubber } from './blocking-snapshot-scrubber'
import {
collectSnapshotTicks,
latestRunsAsOf,
mergeCensorcheckRuns,
resolveSnapshotIndex,
} from './blocking-snapshots'
import { CheckRunSheet } from './check-run-sheet'
import { filterCensorcheckRuns, serviceMatrixRows } from './blocking-filters'
import {
CENSORCHECK_STATUS_LABELS,
LAUNCHER_CMD,
LAUNCHER_CMD_DAILY,
LAUNCHER_CMD_ROS,
LAUNCHER_CMD_ROS_DAILY,
formatCheckedAt,
type CensorcheckRunDto,
} from './types'
@@ -110,36 +122,73 @@ export function BlockingPage() {
const [group, setGroup] = useState<GroupMode>('vps')
const [filters, setFilters] = useState<Filter[]>([])
const [selected, setSelected] = useState<CensorcheckRunDto | null>(null)
const [snapshotIndex, setSnapshotIndex] = useState<number | null>(null)
const currentQuery = useQuery(censorcheckCurrentQueryOptions(spaceId))
const historyQuery = useQuery({
...censorcheckHistoryQueryOptions({ limit: 50 }, spaceId),
enabled: tab === 'history',
})
const historyQuery = useQuery(censorcheckHistoryQueryOptions({ limit: 200 }, spaceId))
const runs = currentQuery.data?.items ?? []
const filtered = useMemo(() => filterCensorcheckRuns(runs, filters), [runs, filters])
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(
() => filterCensorcheckRuns(snapshotRuns, filters),
[snapshotRuns, filters],
)
const serviceGroups = useMemo(() => serviceMatrixRows(filtered), [filtered])
const matched = filtered.filter((row) => row.matchedVpsId).length
const blocked = filtered.reduce((sum, row) => sum + row.summary.blocked, 0)
const copyLauncher = (
<Button
type="button"
variant="outline"
onClick={() => void copyText(LAUNCHER_CMD, 'Команда скопирована')}
>
<CopyIcon data-icon="inline-start" />
Скопировать команду
</Button>
<>
<Button
type="button"
variant="outline"
onClick={() => void copyText(LAUNCHER_CMD, 'Команда скопирована')}
>
<CopyIcon data-icon="inline-start" />
Скопировать команду
</Button>
<Button
type="button"
variant="outline"
onClick={() => void copyText(LAUNCHER_CMD_DAILY, 'Команда для ежедневного запуска скопирована')}
>
<CalendarClockIcon data-icon="inline-start" />
Раз в день
</Button>
<Button
type="button"
variant="outline"
onClick={() => void copyText(LAUNCHER_CMD_ROS, 'Команда MikroTik скопирована')}
>
<RouterIcon data-icon="inline-start" />
MikroTik
</Button>
<Button
type="button"
variant="outline"
onClick={() => void copyText(LAUNCHER_CMD_ROS_DAILY, 'Команда MikroTik для ежедневного запуска скопирована')}
>
<RouterIcon data-icon="inline-start" />
MikroTik · день
</Button>
</>
)
return (
<PageShell>
<PageHeader
title="Статус блокировок"
description="Проверки DPI и геоблокировок с VPS через censorcheck."
description="Проверки DPI и геоблокировок с VPS или MikroTik 7.22+."
actions={copyLauncher}
/>
<KpiStatGrid
@@ -175,7 +224,7 @@ export function BlockingPage() {
<CountedLineTabs
tabs={[
{ id: 'current', label: 'Текущие', count: filtered.length },
{ id: 'current', label: 'Текущие', count: currentQuery.data?.items.length },
{ id: 'history', label: 'История', count: historyQuery.data?.items.length },
]}
value={tab}
@@ -183,7 +232,12 @@ export function BlockingPage() {
/>
{tab === 'current' ? (
<div className="flex flex-col gap-3">
<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}
@@ -218,7 +272,7 @@ export function BlockingPage() {
onRetry={() => void currentQuery.refetch()}
empty={filtered.length === 0}
emptyTitle="Пока нет проверок"
emptyDescription={`На VPS выполните: ${LAUNCHER_CMD}`}
emptyDescription={`На VPS: ${LAUNCHER_CMD}. На MikroTik 7.22+: ${LAUNCHER_CMD_ROS}`}
emptyAction={copyLauncher}
skeleton={<TableSkeleton />}
>
@@ -255,7 +309,7 @@ export function BlockingPage() {
onRowClick={setSelected}
emptyState={{
title: 'История пуста',
description: `На VPS выполните: ${LAUNCHER_CMD}`,
description: `На VPS: ${LAUNCHER_CMD}. На MikroTik 7.22+: ${LAUNCHER_CMD_ROS}`,
action: copyLauncher,
}}
/>
@@ -0,0 +1,109 @@
import { Badge } from '@/components/reui/badge'
import { Frame, FramePanel } from '@/components/reui/frame'
import {
Timeline,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
} from '@/components/reui/timeline'
import { Slider } from '@cfdm/ui/components/slider'
import { cn } from '@cfdm/ui/lib/utils'
import {
formatSnapshotTickLabel,
type BlockingSnapshotTick,
} from './blocking-snapshots'
import { formatCheckedAt } from './types'
/** DNA: c-timeline-12 + shadcn Slider. Preview: https://reui.io/preview/base/components/c-timeline-12 */
export function BlockingSnapshotScrubber({
ticks,
index,
onIndexChange,
}: {
ticks: BlockingSnapshotTick[]
index: number
onIndexChange: (index: number) => void
}) {
if (ticks.length === 0) return null
const selected = ticks[index] ?? ticks[ticks.length - 1]!
const lastIndex = ticks.length - 1
const isLatest = index >= lastIndex
const max = Math.max(0, lastIndex)
const handleSlider = (next: number | readonly number[]) => {
const value = Array.isArray(next) ? next[0] : next
if (typeof value === 'number') onIndexChange(value)
}
return (
<Frame dense variant="default" spacing="sm" className="w-full min-w-0">
<FramePanel className="flex flex-col gap-3 px-(--frame-panel-header-px) py-3">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
<span className="text-sm font-medium">
{isLatest ? 'Сейчас' : formatSnapshotTickLabel(selected.key)}
</span>
<span className="text-muted-foreground text-xs tabular-nums">
{formatCheckedAt(selected.asOf)}
</span>
</div>
<Badge size="sm" variant={isLatest ? 'success' : 'outline'}>
{isLatest ? 'Актуально' : 'Снимок'}
</Badge>
</div>
<Slider
value={[index]}
min={0}
max={max}
step={1}
disabled={ticks.length <= 1}
onValueChange={handleSlider}
aria-label="Снимок проверок на дату"
/>
<div className="min-w-0 overflow-x-auto">
<Timeline
orientation="horizontal"
value={index + 1}
onValueChange={(step) => onIndexChange(Math.max(0, step - 1))}
className="min-w-max"
>
{ticks.map((tick, tickIndex) => {
const showLabel =
ticks.length <= 8 ||
tickIndex === 0 ||
tickIndex === lastIndex ||
tickIndex === index
return (
<TimelineItem
key={tick.key}
step={tickIndex + 1}
className="cursor-pointer"
onClick={() => onIndexChange(tickIndex)}
>
<TimelineHeader>
<TimelineSeparator />
<TimelineDate
className={cn(
'text-[11px] tabular-nums',
showLabel ? undefined : 'invisible',
)}
>
{formatSnapshotTickLabel(tick.key)}
</TimelineDate>
<TimelineIndicator className="size-2.5" />
</TimelineHeader>
</TimelineItem>
)
})}
</Timeline>
</div>
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest'
import {
collectSnapshotTicks,
latestRunsAsOf,
mergeCensorcheckRuns,
resolveSnapshotIndex,
snapshotDayKey,
} from './blocking-snapshots'
import type { CensorcheckRunDto } from './types'
const run = (overrides: Partial<CensorcheckRunDto> = {}): CensorcheckRunDto => ({
id: 'ccrun-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',
censorcheckVersion: '1',
summary: {
total: 1,
available: 1,
redirected: 0,
denied: 0,
blocked: 0,
timeout: 0,
error: 0,
},
createdAt: '2026-08-22T10:00:00.000Z',
completedAt: '2026-08-22T10:00:00.000Z',
observedSourceIp: '203.0.113.10',
vps: null,
results: [],
...overrides,
})
describe('mergeCensorcheckRuns', () => {
it('дедуплицирует по id, current побеждает', () => {
const older = run({ id: 'a', summary: { ...run().summary, blocked: 0 } })
const newer = run({ id: 'a', summary: { ...run().summary, blocked: 3 } })
const merged = mergeCensorcheckRuns([newer], [older, run({ id: 'b' })])
expect(merged).toHaveLength(2)
expect(merged.find((item) => item.id === 'a')?.summary.blocked).toBe(3)
})
})
describe('collectSnapshotTicks', () => {
it('группирует по дню и берёт max createdAt', () => {
const ticks = collectSnapshotTicks([
run({ id: '1', createdAt: '2026-08-20T08:00:00.000Z' }),
run({ id: '2', createdAt: '2026-08-20T18:00:00.000Z' }),
run({ id: '3', createdAt: '2026-08-21T12:00:00.000Z' }),
])
expect(ticks.map((tick) => tick.key)).toEqual([
snapshotDayKey('2026-08-20T08:00:00.000Z'),
snapshotDayKey('2026-08-21T12:00:00.000Z'),
])
expect(ticks[0]?.count).toBe(2)
expect(ticks[0]?.asOf).toBe('2026-08-20T18:00:00.000Z')
})
})
describe('latestRunsAsOf', () => {
it('берёт последний прогон каждого IP на момент T', () => {
const rows = [
run({
id: 'old',
probePublicIp: '1.1.1.1',
createdAt: '2026-08-20T10:00:00.000Z',
summary: { ...run().summary, blocked: 1 },
}),
run({
id: 'mid',
probePublicIp: '1.1.1.1',
createdAt: '2026-08-21T10:00:00.000Z',
summary: { ...run().summary, blocked: 2 },
}),
run({
id: 'new',
probePublicIp: '1.1.1.1',
createdAt: '2026-08-22T10:00:00.000Z',
summary: { ...run().summary, blocked: 3 },
}),
run({
id: 'other',
probePublicIp: '2.2.2.2',
createdAt: '2026-08-20T12:00:00.000Z',
}),
]
const asOf = latestRunsAsOf(rows, '2026-08-21T10:00:00.000Z')
expect(asOf.map((item) => item.id).sort()).toEqual(['mid', 'other'])
expect(asOf.find((item) => item.id === 'mid')?.summary.blocked).toBe(2)
})
})
describe('resolveSnapshotIndex', () => {
it('без выбора — последний тик', () => {
expect(resolveSnapshotIndex(5, null)).toBe(4)
expect(resolveSnapshotIndex(0, null)).toBe(0)
expect(resolveSnapshotIndex(3, 9)).toBe(2)
})
})
@@ -0,0 +1,90 @@
export type SnapshotRun = {
id: string
createdAt: string
probePublicIp: string
}
export type BlockingSnapshotTick = {
key: string
asOf: string
count: number
}
export function snapshotDayKey(iso: string): string {
if (/^\d{4}-\d{2}-\d{2}/.test(iso)) return iso.slice(0, 10)
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso.slice(0, 10)
return date.toISOString().slice(0, 10)
}
export function formatSnapshotTickLabel(dayKey: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(dayKey)) return dayKey
const date = new Date(`${dayKey}T12:00:00.000Z`)
if (Number.isNaN(date.getTime())) return dayKey
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'short',
timeZone: 'UTC',
})
}
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: SnapshotRun[]): BlockingSnapshotTick[] {
const byDay = new Map<string, SnapshotRun[]>()
for (const run of runs) {
const key = snapshotDayKey(run.createdAt)
const list = byDay.get(key)
if (list) list.push(run)
else byDay.set(key, [run])
}
return [...byDay.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, list]) => ({
key,
count: list.length,
asOf: list.reduce(
(latest, run) => (run.createdAt > latest ? run.createdAt : latest),
list[0]!.createdAt,
),
}))
}
/** Latest run per probe IP at or before `asOf`. */
export function latestRunsAsOf<T extends SnapshotRun>(
runs: T[],
asOf: string,
): T[] {
if (!asOf) return []
const byIp = new Map<string, T>()
for (const run of runs) {
if (run.createdAt > asOf) continue
const previous = byIp.get(run.probePublicIp)
if (
!previous ||
run.createdAt > previous.createdAt ||
(run.createdAt === previous.createdAt && run.id > previous.id)
) {
byIp.set(run.probePublicIp, run)
}
}
return [...byIp.values()].sort((left, right) => right.createdAt.localeCompare(left.createdAt))
}
export function resolveSnapshotIndex(
tickCount: number,
selected: number | null,
): number {
if (tickCount <= 0) return 0
if (selected == null) return tickCount - 1
return Math.min(Math.max(selected, 0), tickCount - 1)
}
@@ -1,5 +1,5 @@
import { Link } from '@tanstack/react-router'
import { GlobeIcon, MapPinIcon, ServerIcon, ShieldAlertIcon } from 'lucide-react'
import { Building2Icon, GlobeIcon, MapPinIcon, ServerIcon, ShieldAlertIcon } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import {
@@ -16,6 +16,7 @@ import {
CENSORCHECK_STATUS_LABELS,
formatCheckedAt,
formatVpsResources,
runHosterLabel,
type CensorcheckRunDto,
} from './types'
@@ -67,6 +68,12 @@ export function CheckRunSheet({ run, open, onOpenChange }: CheckRunSheetProps) {
</Link>
) : undefined,
},
{
id: 'hoster',
icon: <Building2Icon />,
label: 'Хостер',
description: runHosterLabel(detail) || '—',
},
{
id: 'geo',
icon: <MapPinIcon />,
@@ -40,6 +40,7 @@ export type CensorcheckRunDto = {
createdAt: string
completedAt: string
observedSourceIp: string | null
detectedHoster?: string | null
vps: CensorcheckVpsInfo | null
results?: CensorcheckResultDto[]
}
@@ -56,6 +57,11 @@ export const CENSORCHECK_STATUS_LABELS: Record<string, string> = {
}
export const LAUNCHER_CMD = 'curl -fsSL https://vt.shnt.top/cc | bash'
export const LAUNCHER_CMD_DAILY = 'curl -fsSL https://vt.shnt.top/cc | bash -s -- --daily'
export const LAUNCHER_CMD_ROS =
':global "vt-srcIp" "1.2.3.4"; /tool fetch url="https://vt.shnt.top/cc.rsc" dst-path=vt-cc.rsc; /import file-name=vt-cc.rsc'
export const LAUNCHER_CMD_ROS_DAILY =
':global "vt-srcIp" "1.2.3.4"; /tool fetch url="https://vt.shnt.top/cc.rsc?daily=1" dst-path=vt-cc.rsc; /import file-name=vt-cc.rsc'
export function formatVpsResources(vcpu: number, ramGb: number, diskGb: number): string {
return `${vcpu} vCPU / ${ramGb} GB / ${diskGb} GB`
@@ -67,12 +73,19 @@ export function formatCheckedAt(iso: string): string {
return date.toLocaleString('ru-RU')
}
export function runHosterLabel(run: CensorcheckRunDto): string {
const inventory = run.vps?.providerName?.trim() ?? ''
if (inventory) return inventory
return run.detectedHoster?.trim() ?? ''
}
export function runSearchText(run: CensorcheckRunDto): 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}`),
]
+40 -3
View File
@@ -1,7 +1,9 @@
import type { LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import { cn } from '@cfdm/ui/lib/utils'
import { IconTile } from '@/components/reui/icon-tile'
import { TruncatedText } from '@/components/truncated-text'
import { cn } from '@cfdm/ui/lib/utils'
export function dataGridCellStack(
primary: ReactNode,
@@ -26,14 +28,49 @@ export function dataGridCellStack(
)
}
/**
* Name cell DNA — IconTile elevated size-10.5 + truncate.
* Preview: https://reui.io/preview/base/stats-12
* Docs: https://reui.io/docs/components/base/icon-tile
*/
export function DataGridNameCell({
icon: Icon,
title,
subtitle,
iconClassName = 'text-muted-foreground',
className,
}: {
icon: LucideIcon
title: ReactNode
subtitle?: ReactNode
iconClassName?: string
className?: string
}) {
return (
<div className={cn('flex min-w-0 items-center gap-2.5', className)}>
<IconTile variant="elevated" className="size-10.5" aria-hidden>
<Icon className={iconClassName} />
</IconTile>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate font-medium">{title}</span>
{subtitle ? (
<span className="truncate text-xs text-muted-foreground">{subtitle}</span>
) : null}
</div>
</div>
)
}
export function dataGridCellWithIcon(
icon: ReactNode,
children: ReactNode,
className?: string,
) {
return (
<div className={cn('flex items-center gap-2', className)}>
<span className="shrink-0 text-muted-foreground">{icon}</span>
<div className={cn('flex min-w-0 items-center gap-2.5', className)}>
<IconTile variant="elevated" className="size-10.5" aria-hidden>
{icon}
</IconTile>
{children}
</div>
)
@@ -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,97 @@
import { Badge } from '@/components/reui/badge'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
import { canonicalizeCountryValue } from '@cfdm/shared/contracts/ipregion'
import { COUNTRY_BY_CODE } from '@cfdm/shared/geo'
import { countryBadgeText } from './geo-filters'
import { IPREGION_STATUS_LABELS, formatCheckedAt } from './types'
const STATUS_VARIANT: Record<
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 ''
const iso = canonicalizeCountryValue(code).country ?? code
return COUNTRY_BY_CODE[iso.toUpperCase()]?.name ?? iso
}
function formatTipCountry(value: string | null | undefined): string | null {
const parsed = canonicalizeCountryValue(value)
const iso = parsed.country
const name = iso ? countryLabel(iso) : ''
const parts = [iso, parsed.iata, name && name !== iso ? name : null].filter(Boolean)
return parts.length ? parts.join(' · ') : null
}
/** Compact ISO cell — preview: https://reui.io/docs/components/base/badge · data-grid-base-4 */
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 stored = countryIpv4 || countryIpv6 || null
const badgeIso = countryBadgeText(stored)
const statusLabel = status ? (IPREGION_STATUS_LABELS[status] ?? status) : 'Нет результата'
const display = status === 'ok' && badgeIso ? badgeIso : status ? (IPREGION_STATUS_LABELS[status] ?? status) : '—'
const variant = status ? (STATUS_VARIANT[status] ?? 'outline') : 'outline'
const tip = [
serviceLabel,
vpsLabel,
formatTipCountry(stored) ?? 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,180 @@
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('извлекает ISO из CDN SE (ARN)', () => {
expect(
uniqueCountries([
run({
results: [
{
id: 'r-cdn',
runId: 'iprun-1',
serviceKey: 'cloudflare cdn',
serviceLabel: 'Cloudflare CDN',
group: 'cdn',
countryIpv4: 'SE (ARN)',
countryIpv6: null,
status: 'ok',
},
],
}),
]),
).toEqual(['SE'])
})
it('считает расхождение с инвентарём', () => {
expect(
isGeoMismatch(
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,296 @@
import { canonicalizeCountryValue } from '@cfdm/shared/contracts/ipregion'
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 resultCountryIso(value: string | null | undefined): string | null {
return canonicalizeCountryValue(value).country
}
export function inventoryCountryCode(run: IpregionRunDto): string | null {
const raw = run.vps?.country?.trim() ?? ''
if (!raw) return null
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 ''
const iso = resultCountryIso(code) ?? code
return COUNTRY_BY_CODE[iso.toUpperCase()]?.name ?? iso
}
export function countryBadgeText(value: string | null | undefined): string {
const parsed = canonicalizeCountryValue(value)
if (parsed.country && parsed.iata) return parsed.country
if (parsed.country) return parsed.country
if (parsed.iata) return parsed.iata
return value?.trim() || ''
}
export function majorityCountry(run: IpregionRunDto): string | null {
const counts = new Map<string, number>()
for (const row of run.results ?? []) {
const code = resultCountryIso(row.countryIpv4) || resultCountryIso(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') {
const v4 = resultCountryIso(row.countryIpv4)
const v6 = resultCountryIso(row.countryIpv6)
if (v4) set.add(v4)
if (v6) set.add(v6)
}
}
}
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,198 @@
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, dataGridCellWithIcon } 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 { resolveServiceIcon, ServiceGlyph } from './service-icons'
import { runHosterLabel, type IpregionRunDto } from './types'
/** DNA data-grid-base-4: auto width + H-scroll + pin start. Preview: https://reui.io/preview/base/data-grid-base-4 */
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,
icon: resolveServiceIcon(svc.key),
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) =>
dataGridCellWithIcon(
<ServiceGlyph serviceKey={row.serviceKey} />,
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,332 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
CalendarClockIcon,
CopyIcon,
GlobeIcon,
MapPinIcon,
RouterIcon,
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,
LAUNCHER_CMD_DAILY,
LAUNCHER_CMD_ROS,
LAUNCHER_CMD_ROS_DAILY,
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>
<Button
type="button"
variant="outline"
onClick={() => void copyText(LAUNCHER_CMD_DAILY, 'Команда для ежедневного запуска скопирована')}
>
<CalendarClockIcon data-icon="inline-start" />
Раз в день
</Button>
<Button
type="button"
variant="outline"
onClick={() => void copyText(LAUNCHER_CMD_ROS, 'Команда MikroTik скопирована')}
>
<RouterIcon data-icon="inline-start" />
MikroTik
</Button>
<Button
type="button"
variant="outline"
onClick={() => void copyText(LAUNCHER_CMD_ROS_DAILY, 'Команда MikroTik для ежедневного запуска скопирована')}
>
<RouterIcon data-icon="inline-start" />
MikroTik · день
</Button>
</>
)
return (
<PageShell>
<PageHeader
title="GeoIP"
description="Страны по GeoIP-сервисам с VPS или MikroTik 7.22+."
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}. На MikroTik 7.22+: ${LAUNCHER_CMD_ROS}`}
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}. На MikroTik 7.22+: ${LAUNCHER_CMD_ROS}`,
action: copyLauncher,
}}
/>
)}
<GeoRunSheet
run={selected}
open={Boolean(selected)}
onOpenChange={(open) => {
if (!open) setSelected(null)
}}
/>
</PageShell>
)
}
@@ -0,0 +1,139 @@
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 { ServiceGlyph } from './service-icons'
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 items-center gap-2">
<ServiceGlyph serviceKey={item.serviceKey} className="text-muted-foreground" />
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{item.serviceLabel}</span>
<span className="text-muted-foreground text-xs">{item.group}</span>
</div>
</div>
{item.status === 'ok' && (item.countryIpv4 || item.countryIpv6) ? (
<Badge size="sm" variant="success-light" radius="full">
{item.countryIpv4 || item.countryIpv6}
{countryName(item.countryIpv4 || item.countryIpv6)
? ` · ${countryName(item.countryIpv4 || item.countryIpv6)}`
: ''}
</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,26 @@
import { describe, expect, it } from 'vitest'
import { GlobeIcon } from 'lucide-react'
import {
IPREGION_CDN_SERVICES,
IPREGION_CUSTOM_SERVICES,
IPREGION_PRIMARY_SERVICES,
} from '@cfdm/shared/contracts/ipregion'
import { resolveServiceIcon } from './service-icons'
describe('resolveServiceIcon', () => {
it('резолвит все GeoIP-сервисы без fallback Globe', () => {
const keys = [
...IPREGION_PRIMARY_SERVICES,
...IPREGION_CUSTOM_SERVICES,
...IPREGION_CDN_SERVICES,
]
for (const key of keys) {
expect(resolveServiceIcon(key), key).not.toBe(GlobeIcon)
}
})
it('unknown / custom → Globe', () => {
expect(resolveServiceIcon('unknown.example')).toBe(GlobeIcon)
})
})
@@ -0,0 +1,136 @@
import type { ComponentType, SVGProps } from 'react'
import {
AppWindowIcon,
BinaryIcon,
CircleHelpIcon,
ClapperboardIcon,
CodeXmlIcon,
DatabaseIcon,
EarthIcon,
FileJsonIcon,
FlagIcon,
GlobeIcon,
InfoIcon,
LayersIcon,
LibraryIcon,
MapIcon,
MapPinIcon,
MapPinnedIcon,
NetworkIcon,
SearchIcon,
SparklesIcon,
TerminalIcon,
type LucideIcon,
} from 'lucide-react'
import {
siApple,
siCloudflare,
siGoogle,
siGooglegemini,
siJetbrains,
siNetflix,
siPlaystation,
siReddit,
siSpeedtest,
siSpotify,
siSteam,
siTiktok,
siTwitch,
siYoutube,
type SimpleIcon,
} from 'simple-icons'
import { cn } from '@cfdm/ui/lib/utils'
function BrandGlyph({
icon,
className,
}: {
icon: SimpleIcon
className?: string
}) {
return (
<svg
role="img"
viewBox="0 0 24 24"
className={cn('size-3.5 shrink-0', className)}
aria-hidden
>
<title>{icon.title}</title>
<path fill="currentColor" d={icon.path} />
</svg>
)
}
function brandComponent(icon: SimpleIcon): ComponentType<{ className?: string }> {
function BrandIcon({ className }: { className?: string }) {
return <BrandGlyph icon={icon} className={className} />
}
BrandIcon.displayName = `BrandIcon(${icon.slug})`
return BrandIcon
}
const BRAND_ICONS: Record<string, ComponentType<{ className?: string }>> = {
'cloudflare.com': brandComponent(siCloudflare),
'cloudflare cdn': brandComponent(siCloudflare),
google: brandComponent(siGoogle),
'google search captcha': brandComponent(siGoogle),
youtube: brandComponent(siYoutube),
'youtube premium': brandComponent(siYoutube),
'youtube cdn': brandComponent(siYoutube),
twitch: brandComponent(siTwitch),
netflix: brandComponent(siNetflix),
'netflix cdn': brandComponent(siNetflix),
spotify: brandComponent(siSpotify),
'spotify signup': brandComponent(siSpotify),
reddit: brandComponent(siReddit),
'reddit (guest access)': brandComponent(siReddit),
apple: brandComponent(siApple),
steam: brandComponent(siSteam),
tiktok: brandComponent(siTiktok),
jetbrains: brandComponent(siJetbrains),
playstation: brandComponent(siPlaystation),
'gemini supported': brandComponent(siGooglegemini),
'ookla speedtest': brandComponent(siSpeedtest),
}
const GENERIC_ICONS: Record<string, LucideIcon> = {
'maxmind.com': DatabaseIcon,
'rdap.db.ripe.net': NetworkIcon,
'ipinfo.io': InfoIcon,
'ipregistry.co': LibraryIcon,
'ipapi.co': CodeXmlIcon,
'ifconfig.co': TerminalIcon,
'ip2location.io': MapPinnedIcon,
'iplocation.com': MapIcon,
'country.is': FlagIcon,
'geoapify.com': MapPinIcon,
'geojs.io': FileJsonIcon,
'ipapi.is': BinaryIcon,
'ipbase.com': LayersIcon,
'ipquery.io': SearchIcon,
'ipwho.is': CircleHelpIcon,
'ip-api.com': EarthIcon,
chatgpt: SparklesIcon,
'disney+': ClapperboardIcon,
'disney+ access': ClapperboardIcon,
microsoft: AppWindowIcon,
}
export function resolveServiceIcon(
serviceKey: string,
): ComponentType<SVGProps<SVGSVGElement> & { className?: string }> {
const key = serviceKey.trim().toLowerCase()
return BRAND_ICONS[key] ?? GENERIC_ICONS[key] ?? GlobeIcon
}
export function ServiceGlyph({
serviceKey,
className,
}: {
serviceKey: string
className?: string
}) {
const Icon = resolveServiceIcon(serviceKey)
return <Icon className={cn('size-3.5 shrink-0', className)} />
}
+94
View File
@@ -0,0 +1,94 @@
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 const LAUNCHER_CMD_DAILY = 'curl -fsSL https://vt.shnt.top/ic | bash -s -- --daily'
export const LAUNCHER_CMD_ROS =
':global "vt-srcIp" "1.2.3.4"; /tool fetch url="https://vt.shnt.top/ic.rsc" dst-path=vt-ic.rsc; /import file-name=vt-ic.rsc'
export const LAUNCHER_CMD_ROS_DAILY =
':global "vt-srcIp" "1.2.3.4"; /tool fetch url="https://vt.shnt.top/ic.rsc?daily=1" dst-path=vt-ic.rsc; /import file-name=vt-ic.rsc'
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()
}
+4 -2
View File
@@ -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 },
@@ -257,7 +259,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<SystemMonitorPopover />
</div>
</header>
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
<div className="flex min-w-0 flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
<SpaceScopedMain>{children}</SpaceScopedMain>
</div>
</SidebarInset>
@@ -272,7 +274,7 @@ export function AppShell({ children }: { children: ReactNode }) {
function SpaceScopedMain({ children }: { children: ReactNode }) {
const { spaceId } = useSpaceId()
return (
<div key={spaceId ?? 'default'} className="flex flex-1 flex-col gap-4 md:gap-6">
<div key={spaceId ?? 'default'} className="flex min-w-0 flex-1 flex-col gap-4 md:gap-6">
{children}
</div>
)
+1 -1
View File
@@ -16,7 +16,7 @@ export function PageHeader({ title, description, actions }: PageHeaderProps) {
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</div>
)
}
+1 -1
View File
@@ -2,5 +2,5 @@ import type { ReactNode } from 'react'
import { cn } from '@cfdm/ui/lib/utils'
export function PageShell({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
return <div className={cn('flex w-full min-w-0 flex-col gap-4 md:gap-6', className)}>{children}</div>
}
@@ -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)
})
})
@@ -291,14 +291,17 @@ function FrameDataGridBody<TData extends object>({
header: 'text-xs font-medium text-muted-foreground',
}}
>
<DataGridContainer border={false}>
<DataGridContainer border={false} className={horizontalScroll ? 'min-w-0' : undefined}>
{virtualization || horizontalScroll ? (
<DataGridScrollArea
orientation={scrollOrientation}
style={virtualization ? { height } : undefined}
>
{tableNode}
</DataGridScrollArea>
<div className="min-w-0 w-full">
<DataGridScrollArea
className="min-w-0 w-full"
orientation={scrollOrientation}
style={virtualization ? { height } : undefined}
>
{tableNode}
</DataGridScrollArea>
</div>
) : (
tableNode
)}
@@ -481,7 +484,7 @@ export function FrameDataGrid<TData extends object>({
if (data.length === 0) {
return (
<Frame dense variant="default" spacing="sm" className={cn('w-full', className)}>
<Frame dense variant="default" spacing="sm" className={cn('w-full min-w-0', className)}>
{hasHeader ? (
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
) : null}
@@ -511,7 +514,7 @@ export function FrameDataGrid<TData extends object>({
)
return (
<Frame dense variant="default" spacing="sm" className={cn('w-full', className)}>
<Frame dense variant="default" spacing="sm" className={cn('w-full min-w-0', className)}>
{hasHeader ? (
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
) : null}
+67 -16
View File
@@ -1,8 +1,25 @@
import type { ReactNode } from 'react'
import { PencilIcon, Trash2Icon } from 'lucide-react'
import { useState, type ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import { cn } from '@cfdm/ui/lib/utils'
import { ConfirmDialog } from './confirm-dialog'
export interface RowActionExtra {
label: string
icon?: LucideIcon
onSelect: () => void
disabled?: boolean
}
interface RowActionsProps {
onEdit?: () => void
onDelete?: () => void
@@ -10,10 +27,15 @@ interface RowActionsProps {
deleteTitle?: string
deleteDescription?: ReactNode
deleteLabel?: string
extra?: ReactNode
extra?: RowActionExtra[]
className?: string
}
/**
* Data-grid row actions — outline ⋯ menu.
* Preview: https://reui.io/preview/base/components/c-dropdown-menu-12
* Docs: https://reui.io/docs/components/base/dropdown-menu
*/
export function RowActions({
onEdit,
onDelete,
@@ -24,23 +46,52 @@ export function RowActions({
extra,
className,
}: RowActionsProps) {
if (!onEdit && !onDelete && !extra) return null
const [deleteOpen, setDeleteOpen] = useState(false)
const extras = extra ?? []
if (!onEdit && !onDelete && extras.length === 0) return null
return (
<div className={`flex justify-end gap-1 ${className ?? ''}`}>
{extra}
{onEdit ? (
<Button variant="ghost" size="icon-sm" onClick={onEdit} aria-label={editLabel}>
<PencilIcon />
</Button>
) : null}
<div className={cn('flex justify-end', className)}>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button type="button" variant="outline" size="icon-sm" aria-label="Действия" />
}
>
<MoreHorizontalIcon className="size-4" aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
{extras.map((item) => {
const Icon = item.icon
return (
<DropdownMenuItem
key={item.label}
disabled={item.disabled}
onClick={item.onSelect}
>
{Icon ? <Icon aria-hidden /> : null}
{item.label}
</DropdownMenuItem>
)
})}
{onEdit ? (
<DropdownMenuItem onClick={onEdit}>
<PencilIcon aria-hidden />
{editLabel}
</DropdownMenuItem>
) : null}
{onDelete ? (
<DropdownMenuItem variant="destructive" onClick={() => setDeleteOpen(true)}>
<Trash2Icon aria-hidden />
{deleteLabel}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
{onDelete ? (
<ConfirmDialog
trigger={
<Button variant="ghost" size="icon-sm" aria-label="Удалить">
<Trash2Icon />
</Button>
}
open={deleteOpen}
onOpenChange={setDeleteOpen}
title={deleteTitle}
description={deleteDescription}
destructive
+39 -17
View File
@@ -1,40 +1,62 @@
import type { ComponentProps } from 'react'
import { cn } from '@cfdm/ui/lib/utils'
import { Badge } from '@/components/reui/badge'
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
const STATUS_VARIANT: Record<string, BadgeVariant> = {
active: 'success',
ok: 'success',
paid: 'success',
available: 'success',
complete: 'success',
paused: 'secondary',
active: 'success-light',
ok: 'success-light',
up: 'success-light',
paid: 'success-light',
available: 'success-light',
complete: 'success-light',
paused: 'invert-light',
archived: 'outline',
error: 'destructive',
denied: 'destructive',
blocked: 'destructive',
running: 'info',
overdue: 'warning',
stale: 'warning',
timeout: 'warning',
redirected: 'warning',
partial: 'warning',
error: 'destructive-light',
denied: 'destructive-light',
blocked: 'destructive-light',
down: 'destructive-light',
running: 'info-light',
overdue: 'warning-light',
stale: 'warning-light',
timeout: 'warning-light',
redirected: 'warning-light',
partial: 'warning-light',
}
const DOT_COLOR: Record<string, string> = {
'success-light': 'bg-success',
success: 'bg-success',
'info-light': 'bg-info',
info: 'bg-info',
'warning-light': 'bg-warning',
warning: 'bg-warning',
'destructive-light': 'bg-destructive',
destructive: 'bg-destructive',
'invert-light': 'bg-muted-foreground',
secondary: 'bg-muted-foreground',
outline: 'bg-muted-foreground',
}
export function StatusBadge({
status,
label,
size = 'default',
size = 'sm',
className,
}: {
status: string
label?: string
size?: NonNullable<ComponentProps<typeof Badge>['size']>
className?: string
}) {
const variant = STATUS_VARIANT[status] ?? 'outline'
const dotColor = DOT_COLOR[variant] ?? 'bg-muted-foreground'
return (
<Badge variant={variant} size={size}>
<Badge variant={variant} size={size} radius="full" className={cn('gap-1.5', className)}>
<span className={cn('size-1.5 shrink-0 rounded-full', dotColor)} aria-hidden />
{label ?? status}
</Badge>
)
@@ -1,28 +1,48 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import { TabsContent } from '@cfdm/ui/components/tabs'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemTitle,
} from '@cfdm/ui/components/item'
import { Badge } from '@/components/reui/badge'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { EmptyState } from '@/components/empty-state'
import { FormSheet } from '@/components/form-sheet'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { vpsSpecsLine } from './types'
import { aggregateCfdmServices, type CfdmTopologyService } from './cfdm-services'
import { lbModeBadgeVariant, lbModeLabel, vpsSpecsLine } from './types'
import type { Vps } from '@/types/entities'
interface AddVpsSheetProps {
open: boolean
onOpenChange: (open: boolean) => void
existingVpsIds: Set<string>
existingCfdmServiceIds: Set<number>
onAdd: (vpsIds: string[]) => void
onAddServices: (services: CfdmTopologyService[]) => void
}
export function AddVpsSheet({
open,
onOpenChange,
existingVpsIds,
existingCfdmServiceIds,
onAdd,
onAddServices,
}: AddVpsSheetProps) {
const { data: snapshot } = useQuery(snapshotQueryOptions())
const [tab, setTab] = useState('vps')
const [q, setQ] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [selectedServices, setSelectedServices] = useState<Set<number>>(new Set())
const list = useMemo(() => {
const all = (snapshot?.vps ?? []) as Vps[]
@@ -38,6 +58,20 @@ export function AddVpsSheet({
.slice(0, 80)
}, [snapshot?.vps, q])
const services = useMemo(
() => aggregateCfdmServices(snapshot?.vpsDomains ?? []),
[snapshot?.vpsDomains],
)
const filteredServices = useMemo(() => {
const term = q.trim().toLowerCase()
if (!term) return services
return services.filter((s) => {
if (s.name.toLowerCase().includes(term) || s.slug.toLowerCase().includes(term)) return true
return s.fqdns.some((f) => f.toLowerCase().includes(term))
})
}, [services, q])
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev)
@@ -47,77 +81,192 @@ export function AddVpsSheet({
})
}
function toggleService(id: number) {
setSelectedServices((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function reset() {
setSelected(new Set())
setSelectedServices(new Set())
setQ('')
setTab('vps')
}
const submitCount = tab === 'cfdm' ? selectedServices.size : selected.size
const submitDisabled = submitCount === 0
return (
<FormSheet
open={open}
onOpenChange={(v) => {
if (!v) {
setSelected(new Set())
setQ('')
}
if (!v) reset()
onOpenChange(v)
}}
title="Добавить VPS на схему"
description="Сервер появится на канве. Позицию и связи можно настроить вручную."
submitLabel={`Добавить${selected.size ? ` (${selected.size})` : ''}`}
submitDisabled={selected.size === 0}
title={tab === 'cfdm' ? 'Добавить сервис CFDM' : 'Добавить VPS на схему'}
description={
tab === 'cfdm'
? 'Сервис появится как узел. Серверы свяжутся пунктиром членства — один VPS может быть в нескольких сервисах.'
: 'Сервер появится на канве. Позицию и связи можно настроить вручную.'
}
submitLabel={`Добавить${submitCount ? ` (${submitCount})` : ''}`}
submitDisabled={submitDisabled}
onSubmit={() => {
onAdd([...selected])
setSelected(new Set())
setQ('')
if (tab === 'cfdm') {
const picked = services.filter((s) => selectedServices.has(s.serviceId))
onAddServices(picked)
} else {
onAdd([...selected])
}
reset()
onOpenChange(false)
}}
>
<Input
placeholder="Поиск по IP, DNS, проекту…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<div className="flex max-h-[50vh] flex-col gap-1 overflow-y-auto">
{list.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">Нет подходящих VPS</p>
) : (
list.map((v) => {
const already = existingVpsIds.has(v.id)
const checked = selected.has(v.id)
return (
<button
key={v.id}
type="button"
disabled={already}
onClick={() => toggle(v.id)}
className="flex flex-col gap-0.5 rounded-md border border-transparent px-2 py-2 text-left hover:bg-muted disabled:opacity-50"
data-selected={checked || undefined}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium">
{v.dns || v.ip}
</span>
{already ? (
<span className="text-[10px] text-muted-foreground">уже на схеме</span>
) : (
<span
className={
checked
? 'size-2 rounded-full bg-primary'
: 'size-2 rounded-full border border-border'
}
/>
)}
</div>
<span className="font-mono text-[11px] text-muted-foreground">{v.ip}</span>
<span className="text-[11px] text-muted-foreground">{vpsSpecsLine(v)}</span>
</button>
)
})
)}
</div>
{selected.size > 0 ? (
<CountedLineTabs
tabs={[
{ id: 'vps', label: 'VPS', count: list.length },
{ id: 'cfdm', label: 'Сервисы CFDM', count: services.length },
]}
value={tab}
onValueChange={setTab}
className="flex flex-col gap-3"
>
<Input
placeholder={
tab === 'cfdm' ? 'Поиск по сервису, FQDN…' : 'Поиск по IP, DNS, проекту…'
}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<TabsContent value="vps" className="mt-0 flex flex-col gap-1">
<div className="flex max-h-[50vh] flex-col gap-1 overflow-y-auto">
{list.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">Нет подходящих VPS</p>
) : (
list.map((v) => {
const already = existingVpsIds.has(v.id)
const checked = selected.has(v.id)
return (
<button
key={v.id}
type="button"
disabled={already}
onClick={() => toggle(v.id)}
className="flex flex-col gap-0.5 rounded-md border border-transparent px-2 py-2 text-left hover:bg-muted disabled:opacity-50"
data-selected={checked || undefined}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium">{v.dns || v.ip}</span>
{already ? (
<span className="text-[10px] text-muted-foreground">уже на схеме</span>
) : (
<span
className={
checked
? 'size-2 rounded-full bg-primary'
: 'size-2 rounded-full border border-border'
}
/>
)}
</div>
<span className="font-mono text-[11px] text-muted-foreground">{v.ip}</span>
<span className="text-[11px] text-muted-foreground">{vpsSpecsLine(v)}</span>
</button>
)
})
)}
</div>
</TabsContent>
<TabsContent value="cfdm" className="mt-0 flex flex-col gap-1">
{services.length === 0 ? (
<EmptyState
title="Нет сервисов CFDM"
description="Включите интеграцию и синхронизируйте bindings — сервисы появятся здесь."
centered={false}
action={
<Button variant="outline" size="sm" render={<Link to="/settings/integrations" />}>
Настройки интеграции
</Button>
}
/>
) : filteredServices.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">Нет подходящих сервисов</p>
) : (
<ItemGroup className="max-h-[50vh] gap-1 overflow-y-auto">
{filteredServices.map((s) => {
const already = existingCfdmServiceIds.has(s.serviceId)
const noServers = s.matchedVpsIds.length === 0
const disabled = already || noServers
const checked = selectedServices.has(s.serviceId)
return (
<Item
key={s.serviceId}
size="sm"
variant={checked ? 'muted' : 'default'}
render={
<button
type="button"
disabled={disabled}
onClick={() => toggleService(s.serviceId)}
/>
}
className="w-full text-left disabled:opacity-50"
data-selected={checked || undefined}
>
<ItemContent className="gap-0.5">
<ItemTitle className="flex flex-wrap items-center gap-1.5">
<span className="truncate">{s.name}</span>
{s.lbMode ? (
<Badge variant={lbModeBadgeVariant(s.lbMode)} size="xs">
{lbModeLabel(s.lbMode)}
</Badge>
) : null}
</ItemTitle>
<ItemDescription>
{s.fqdns.slice(0, 2).join(', ')}
{s.fqdns.length > 2 ? ` +${s.fqdns.length - 2}` : ''}
{' · '}
{s.matchedVpsIds.length} серв.
</ItemDescription>
</ItemContent>
<ItemActions>
{already ? (
<span className="text-[10px] text-muted-foreground">уже на схеме</span>
) : noServers ? (
<span className="text-[10px] text-muted-foreground">нет серверов</span>
) : (
<span
className={
checked
? 'size-2 rounded-full bg-primary'
: 'size-2 rounded-full border border-border'
}
/>
)}
</ItemActions>
</Item>
)
})}
</ItemGroup>
)}
</TabsContent>
</CountedLineTabs>
{(tab === 'vps' ? selected.size : selectedServices.size) > 0 ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setSelected(new Set())}
onClick={() => {
setSelected(new Set())
setSelectedServices(new Set())
}}
>
Сбросить выбор
</Button>
@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest'
import type { VpsDomain } from '@/types/entities'
import { aggregateCfdmServices, servicesForVps } from './cfdm-services'
function domain(partial: Partial<VpsDomain> & Pick<VpsDomain, 'id' | 'cfdmServiceId'>): VpsDomain {
return {
vpsId: null,
fqdn: 'vpn.example.com',
zoneName: 'example.com',
hostname: 'vpn',
serviceName: 'VPN',
serviceSlug: 'vpn',
cfdmBindingId: 1,
source: 'cfdm',
matchStatus: 'unmatched',
syncedAt: '2026-01-01T00:00:00.000Z',
...partial,
}
}
describe('aggregateCfdmServices', () => {
it('groups bindings by service and collects matched VPS', () => {
const rows: VpsDomain[] = [
domain({
id: 'a',
cfdmServiceId: 10,
cfdmBindingId: 1,
fqdn: 'vpn-a.example.com',
vpsId: 'vps-1',
matchStatus: 'matched',
targetIps: JSON.stringify(['203.0.113.10']),
lbMode: 'failover',
}),
domain({
id: 'b',
cfdmServiceId: 10,
cfdmBindingId: 2,
fqdn: 'vpn-b.example.com',
vpsId: 'vps-2',
matchStatus: 'matched',
targetIps: JSON.stringify(['203.0.113.20']),
lbMode: 'failover',
}),
domain({
id: 'c',
cfdmServiceId: 11,
cfdmBindingId: 3,
serviceName: 'DNS',
serviceSlug: 'dns',
fqdn: 'ns.example.com',
matchStatus: 'unmatched',
targetIps: JSON.stringify(['198.51.100.1']),
lbMode: 'round_robin',
}),
]
const services = aggregateCfdmServices(rows)
expect(services).toHaveLength(2)
const vpn = services.find((s) => s.serviceId === 10)
expect(vpn?.name).toBe('VPN')
expect(vpn?.lbMode).toBe('failover')
expect(vpn?.matchedVpsIds.sort()).toEqual(['vps-1', 'vps-2'])
expect(vpn?.fqdns).toEqual(['vpn-a.example.com', 'vpn-b.example.com'])
expect(vpn?.unmatchedIps).toEqual([])
const dns = services.find((s) => s.serviceId === 11)
expect(dns?.matchedVpsIds).toEqual([])
expect(dns?.unmatchedIps).toEqual(['198.51.100.1'])
expect(dns?.lbMode).toBeUndefined()
})
it('omits lbMode when unique origin IPs are below two', () => {
const rows: VpsDomain[] = [
domain({
id: 'a',
cfdmServiceId: 10,
cfdmBindingId: 1,
vpsId: 'vps-1',
matchStatus: 'matched',
targetIps: JSON.stringify(['203.0.113.10']),
lbMode: 'round_robin',
}),
domain({
id: 'b',
cfdmServiceId: 10,
cfdmBindingId: 2,
fqdn: 'vpn-b.example.com',
vpsId: 'vps-2',
matchStatus: 'matched',
targetIps: JSON.stringify(['203.0.113.10']),
lbMode: 'round_robin',
}),
]
expect(aggregateCfdmServices(rows)[0]?.lbMode).toBeUndefined()
})
it('lists services a VPS belongs to', () => {
const rows: VpsDomain[] = [
domain({
id: 'a',
cfdmServiceId: 10,
cfdmBindingId: 1,
vpsId: 'vps-1',
matchStatus: 'matched',
lbMode: 'failover',
}),
domain({
id: 'b',
cfdmServiceId: 11,
cfdmBindingId: 3,
serviceName: 'DNS',
serviceSlug: 'dns',
fqdn: 'ns.example.com',
vpsId: 'vps-1',
matchStatus: 'matched',
lbMode: 'round_robin',
}),
]
const chips = servicesForVps(aggregateCfdmServices(rows), 'vps-1')
expect(chips.map((c) => c.name).sort()).toEqual(['DNS', 'VPN'])
})
it('deduplicates the same vpsId across bindings of one service', () => {
const rows: VpsDomain[] = [
domain({
id: 'a',
cfdmServiceId: 10,
cfdmBindingId: 1,
vpsId: 'vps-1',
matchStatus: 'matched',
}),
domain({
id: 'b',
cfdmServiceId: 10,
cfdmBindingId: 2,
fqdn: 'vpn-b.example.com',
vpsId: 'vps-1',
matchStatus: 'matched',
}),
]
const [svc] = aggregateCfdmServices(rows)
expect(svc?.matchedVpsIds).toEqual(['vps-1'])
})
})
@@ -0,0 +1,93 @@
import type { TopologyLbMode } from '@cfdm/shared/contracts/integration-cfdm'
import type { VpsDomain } from '@/types/entities'
import { isTopologyLbMode } from './types'
export type CfdmTopologyService = {
serviceId: number
name: string
slug: string
lbMode?: TopologyLbMode
fqdns: string[]
matchedVpsIds: string[]
unmatchedIps: string[]
}
function parseTargetIps(raw: string | null | undefined): string[] {
if (!raw) return []
try {
const parsed: unknown = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []
} catch {
return []
}
}
/** Группирует vps_domains по сервису CFDM для пикера на схеме. */
export function aggregateCfdmServices(domains: VpsDomain[]): CfdmTopologyService[] {
const byService = new Map<
number,
{
name: string
slug: string
lbMode?: TopologyLbMode
fqdns: Set<string>
matchedVpsIds: Set<string>
originIps: Set<string>
unmatchedIps: Set<string>
}
>()
for (const row of domains) {
if (row.source && row.source !== 'cfdm') continue
let bucket = byService.get(row.cfdmServiceId)
if (!bucket) {
bucket = {
name: row.serviceName,
slug: row.serviceSlug,
fqdns: new Set(),
matchedVpsIds: new Set(),
originIps: new Set(),
unmatchedIps: new Set(),
}
byService.set(row.cfdmServiceId, bucket)
}
if (row.fqdn) bucket.fqdns.add(row.fqdn)
if (isTopologyLbMode(row.lbMode)) bucket.lbMode = row.lbMode
for (const ip of parseTargetIps(row.targetIps)) bucket.originIps.add(ip)
if (row.vpsId && row.matchStatus === 'matched') {
bucket.matchedVpsIds.add(row.vpsId)
} else {
for (const ip of parseTargetIps(row.targetIps)) bucket.unmatchedIps.add(ip)
}
}
return [...byService.entries()]
.map(([serviceId, b]) => ({
serviceId,
name: b.name,
slug: b.slug,
lbMode: b.originIps.size >= 2 ? b.lbMode : undefined,
fqdns: [...b.fqdns].sort(),
matchedVpsIds: [...b.matchedVpsIds],
unmatchedIps: [...b.unmatchedIps],
}))
.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
}
export function serviceFqdnMeta(service: Pick<CfdmTopologyService, 'fqdns'>): {
fqdn: string
extraFqdns: string[]
} {
const [primary, ...rest] = service.fqdns
return { fqdn: primary ?? '', extraFqdns: rest }
}
/** Сервисы CFDM, в которых состоит данный VPS (для чипов на карточке). */
export function servicesForVps(
services: CfdmTopologyService[],
vpsId: string,
): Pick<CfdmTopologyService, 'serviceId' | 'name' | 'lbMode'>[] {
return services
.filter((s) => s.matchedVpsIds.includes(vpsId))
.map((s) => ({ serviceId: s.serviceId, name: s.name, lbMode: s.lbMode }))
}
@@ -47,3 +47,37 @@ export function createConnectedEdge(connection: {
data,
)
}
export function membershipEdgeData(): TopologyEdgeData {
return {
relation: 'membership',
label: '',
lineStyle: 'dashed',
direction: 'none',
protocol: '',
localIp: '',
remoteIp: '',
notes: '',
}
}
export function createMembershipEdge(
serviceNodeId: string,
vpsNodeId: string,
): Edge<TopologyEdgeData> {
const data = membershipEdgeData()
return applyEdgeVisuals(
{
id: `e-membership-${serviceNodeId}-${vpsNodeId}`,
source: serviceNodeId,
target: vpsNodeId,
data,
deletable: false,
},
data,
)
}
export function isMembershipEdge(edge: Edge<TopologyEdgeData>): boolean {
return edge.data?.relation === 'membership'
}
@@ -2,6 +2,7 @@ import { memo } from 'react'
import {
BaseEdge,
EdgeLabelRenderer,
getBezierPath,
getSmoothStepPath,
type Edge,
type EdgeProps,
@@ -30,19 +31,22 @@ function TopologyEdgeComponent(props: EdgeProps<Edge<TopologyEdgeData>>) {
data,
} = props
const [edgePath, labelX, labelY] = getSmoothStepPath({
const relation = data?.relation ?? 'network'
const title = data?.label?.trim() ?? ''
const protocol = data?.protocol?.trim() ?? ''
const tunnelIps = formatEdgeTunnelIps(data ?? {})
const membership = relation === 'membership'
const pathParams = {
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
})
const relation = data?.relation ?? 'network'
const title = data?.label?.trim() ?? ''
const protocol = data?.protocol?.trim() ?? ''
const tunnelIps = formatEdgeTunnelIps(data ?? {})
}
const [edgePath, labelX, labelY] = membership
? getBezierPath(pathParams)
: getSmoothStepPath(pathParams)
return (
<>
@@ -56,6 +60,7 @@ function TopologyEdgeComponent(props: EdgeProps<Edge<TopologyEdgeData>>) {
strokeWidth: selected ? 2.5 : 1.5,
}}
/>
{membership ? null : (
<EdgeLabelRenderer>
<div
className={cn(
@@ -88,6 +93,7 @@ function TopologyEdgeComponent(props: EdgeProps<Edge<TopologyEdgeData>>) {
) : null}
</div>
</EdgeLabelRenderer>
)}
</>
)
}
@@ -4,10 +4,14 @@ import { Textarea } from '@cfdm/ui/components/textarea'
import { FormSheet } from '@/components/form-sheet'
import { FormField } from '@/components/form-field'
import { SelectField } from '@/components/select-field'
import { Badge } from '@/components/reui/badge'
import {
SHAPE_KIND_OPTIONS,
lbModeBadgeVariant,
lbModeLabel,
type GroupNodeData,
type NoteNodeData,
type ServiceNodeData,
type ShapeKind,
type ShapeNodeData,
type TopologyNodeType,
@@ -17,13 +21,18 @@ export type EditableElement =
| { kind: 'shape'; id: string; data: ShapeNodeData }
| { kind: 'note'; id: string; data: NoteNodeData }
| { kind: 'group'; id: string; data: GroupNodeData }
| { kind: 'service'; id: string; data: ServiceNodeData }
interface ElementEditSheetProps {
element: EditableElement | null
open: boolean
locked?: boolean
onOpenChange: (open: boolean) => void
onSave: (id: string, type: TopologyNodeType, data: ShapeNodeData | NoteNodeData | GroupNodeData) => void
onSave: (
id: string,
type: TopologyNodeType,
data: ShapeNodeData | NoteNodeData | GroupNodeData | ServiceNodeData,
) => void
}
export function ElementEditSheet({
@@ -49,6 +58,10 @@ export function ElementEditSheet({
setText(element.data.text ?? '')
setLabel('')
setNotes('')
} else if (element.kind === 'service') {
setLabel(element.data.label ?? '')
setNotes('')
setText('')
} else {
setLabel(element.data.label ?? '')
setNotes(element.data.notes ?? '')
@@ -61,7 +74,9 @@ export function ElementEditSheet({
? 'Параметры блока'
: element?.kind === 'note'
? 'Параметры заметки'
: 'Параметры группы'
: element?.kind === 'service'
? 'Параметры сервиса CFDM'
: 'Параметры группы'
return (
<FormSheet
@@ -71,7 +86,9 @@ export function ElementEditSheet({
description={
locked
? 'Схема заблокирована — только просмотр'
: 'Изменения сохранятся на схеме'
: element?.kind === 'service'
? 'Имя можно изменить. FQDN и тип резервирования — из CFDM.'
: 'Изменения сохранятся на схеме'
}
submitLabel="Сохранить"
submitDisabled={locked || !element}
@@ -81,6 +98,11 @@ export function ElementEditSheet({
onSave(element.id, 'shape', { kind, label: label.trim() || 'Блок' })
} else if (element.kind === 'note') {
onSave(element.id, 'note', { text: text.trim() || 'Заметка' })
} else if (element.kind === 'service') {
onSave(element.id, 'service', {
...element.data,
label: label.trim() || element.data.label || 'Сервис',
})
} else {
onSave(element.id, 'group', {
label: label.trim() || 'Группа',
@@ -152,6 +174,39 @@ export function ElementEditSheet({
</FormField>
</>
) : null}
{element?.kind === 'service' ? (
<>
<FormField label="Название" htmlFor="service-label">
<Input
id="service-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
disabled={locked}
maxLength={80}
/>
</FormField>
{element.data.fqdn ? (
<FormField label="Основной FQDN">
<p className="font-mono text-sm text-foreground">{element.data.fqdn}</p>
</FormField>
) : null}
{element.data.extraFqdns && element.data.extraFqdns.length > 0 ? (
<FormField label="Другие FQDN">
<p className="font-mono text-sm text-muted-foreground">
{element.data.extraFqdns.join(', ')}
</p>
</FormField>
) : null}
{element.data.lbMode ? (
<FormField label="Резервирование">
<Badge variant={lbModeBadgeVariant(element.data.lbMode)} size="sm">
{lbModeLabel(element.data.lbMode)}
</Badge>
</FormField>
) : null}
</>
) : null}
</FormSheet>
)
}
@@ -2,8 +2,9 @@ import type { TopologyFlowNode, TopologyNodeType } from './types'
const GROUP_Z = -1
const CONTENT_Z = 1
const SERVICE_Z = 3
const ATTACHABLE = new Set<TopologyNodeType>(['vps', 'shape', 'note'])
const ATTACHABLE = new Set<TopologyNodeType>(['vps', 'shape', 'note', 'service'])
export function isAttachableType(type: TopologyNodeType | undefined): boolean {
return type != null && ATTACHABLE.has(type)
@@ -16,10 +17,10 @@ export function normalizeGroupLayers(nodes: TopologyFlowNode[]): TopologyFlowNod
}
// extent: 'parent' блокирует вытаскивание — не используем; parentId достаточно для «прилипания»
const cleared = n.extent != null ? { ...n, extent: undefined } : n
if (cleared.zIndex != null && cleared.zIndex < CONTENT_Z) {
return { ...cleared, zIndex: CONTENT_Z }
if (cleared.type === 'service') {
return { ...cleared, zIndex: SERVICE_Z }
}
return cleared.zIndex == null ? { ...cleared, zIndex: CONTENT_Z } : cleared
return { ...cleared, zIndex: CONTENT_Z }
})
}
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'
import {
applyMembershipHandles,
clusterIdsForService,
pickMembershipHandles,
} from './membership-handles'
import type { TopologyFlowNode } from './types'
import { membershipEdgeData } from './edge-utils'
describe('pickMembershipHandles', () => {
const origin = { x: 0, y: 0 }
it('picks bottom→top when VPS is below', () => {
expect(pickMembershipHandles(origin, { x: 0, y: 100 })).toEqual({
sourceHandle: 'bottom',
targetHandle: 'top',
})
})
it('picks top→bottom when VPS is above', () => {
expect(pickMembershipHandles(origin, { x: 0, y: -100 })).toEqual({
sourceHandle: 'top',
targetHandle: 'bottom',
})
})
it('picks right→left when VPS is to the right', () => {
expect(pickMembershipHandles(origin, { x: 100, y: 0 })).toEqual({
sourceHandle: 'right',
targetHandle: 'left',
})
})
it('picks left→right when VPS is to the left', () => {
expect(pickMembershipHandles(origin, { x: -100, y: 0 })).toEqual({
sourceHandle: 'left',
targetHandle: 'right',
})
})
it('uses vertical on below-right diagonal', () => {
expect(pickMembershipHandles(origin, { x: 10, y: 20 })).toEqual({
sourceHandle: 'bottom',
targetHandle: 'top',
})
})
it('uses vertical on below-left diagonal', () => {
expect(pickMembershipHandles(origin, { x: -10, y: 20 })).toEqual({
sourceHandle: 'bottom',
targetHandle: 'top',
})
})
it('uses vertical on above-right diagonal', () => {
expect(pickMembershipHandles(origin, { x: 10, y: -20 })).toEqual({
sourceHandle: 'top',
targetHandle: 'bottom',
})
})
it('uses vertical on above-left diagonal', () => {
expect(pickMembershipHandles(origin, { x: -10, y: -20 })).toEqual({
sourceHandle: 'top',
targetHandle: 'bottom',
})
})
it('uses vertical when axes are equal', () => {
expect(pickMembershipHandles(origin, { x: 40, y: 40 })).toEqual({
sourceHandle: 'bottom',
targetHandle: 'top',
})
})
})
describe('clusterIdsForService', () => {
it('includes the service and membership VPS only', () => {
const ids = clusterIdsForService('svc-1', [
{ source: 'svc-1', target: 'vps-a', data: { relation: 'membership' } },
{ source: 'svc-1', target: 'vps-b', data: { relation: 'membership' } },
{ source: 'svc-2', target: 'vps-a', data: { relation: 'membership' } },
{ source: 'vps-a', target: 'vps-c', data: { relation: 'network' } },
])
expect(ids.sort()).toEqual(['svc-1', 'vps-a', 'vps-b'])
})
})
describe('applyMembershipHandles', () => {
it('sets handles from node centers', () => {
const nodes: TopologyFlowNode[] = [
{ id: 'svc', type: 'service', position: { x: 0, y: 0 }, data: { label: 'S', cfdmServiceId: 1, fqdn: '' } },
{ id: 'vps', type: 'vps', position: { x: 0, y: 200 }, data: { vpsId: 'v1' } },
]
const edges = [
{
id: 'e1',
source: 'svc',
target: 'vps',
data: membershipEdgeData(),
},
]
const next = applyMembershipHandles(edges, nodes)
expect(next[0]?.sourceHandle).toBe('bottom')
expect(next[0]?.targetHandle).toBe('top')
})
})
@@ -0,0 +1,61 @@
import type { Edge } from '@xyflow/react'
import { getNodeCenterAbsolute } from './group-utils'
import type { TopologyEdgeData, TopologyFlowNode } from './types'
export type MembershipHandleSide = 'top' | 'right' | 'bottom' | 'left'
export function pickMembershipHandles(
serviceCenter: { x: number; y: number },
vpsCenter: { x: number; y: number },
): { sourceHandle: MembershipHandleSide; targetHandle: MembershipHandleSide } {
const dx = vpsCenter.x - serviceCenter.x
const dy = vpsCenter.y - serviceCenter.y
if (Math.abs(dy) >= Math.abs(dx)) {
if (dy >= 0) return { sourceHandle: 'bottom', targetHandle: 'top' }
return { sourceHandle: 'top', targetHandle: 'bottom' }
}
if (dx >= 0) return { sourceHandle: 'right', targetHandle: 'left' }
return { sourceHandle: 'left', targetHandle: 'right' }
}
export function clusterIdsForService(
serviceNodeId: string,
edges: readonly { source: string; target: string; data?: { relation?: string } }[],
): string[] {
const ids = new Set<string>([serviceNodeId])
for (const edge of edges) {
if (edge.data?.relation !== 'membership') continue
if (edge.source === serviceNodeId) ids.add(edge.target)
}
return [...ids]
}
export function applyMembershipHandles(
edges: Edge<TopologyEdgeData>[],
nodes: TopologyFlowNode[],
): Edge<TopologyEdgeData>[] {
let changed = false
const next = edges.map((edge) => {
if (edge.data?.relation !== 'membership') return edge
const source = nodes.find((n) => n.id === edge.source)
const target = nodes.find((n) => n.id === edge.target)
if (!source || !target) return edge
const handles = pickMembershipHandles(
getNodeCenterAbsolute(source, nodes),
getNodeCenterAbsolute(target, nodes),
)
if (
edge.sourceHandle === handles.sourceHandle &&
edge.targetHandle === handles.targetHandle
) {
return edge
}
changed = true
return {
...edge,
sourceHandle: handles.sourceHandle,
targetHandle: handles.targetHandle,
}
})
return changed ? next : edges
}
@@ -3,10 +3,12 @@ import { VpsNode } from './nodes/vps-node'
import { ShapeNode } from './nodes/shape-node'
import { NoteNode } from './nodes/note-node'
import { GroupNode } from './nodes/group-node'
import { ServiceNode } from './nodes/service-node'
export const topologyNodeTypes = {
vps: VpsNode,
shape: ShapeNode,
note: NoteNode,
group: GroupNode,
service: ServiceNode,
} satisfies NodeTypes
@@ -1,7 +1,8 @@
import { memo } from 'react'
import { type NodeProps, NodeResizer } from '@xyflow/react'
import { cn } from '@cfdm/ui/lib/utils'
import type { GroupNodeData } from '../types'
import { Badge } from '@/components/reui/badge'
import { lbModeBadgeVariant, lbModeLabel, type GroupNodeData } from '../types'
function GroupNodeComponent({ data, selected }: NodeProps & { data: GroupNodeData }) {
return (
@@ -13,8 +14,15 @@ function GroupNodeComponent({ data, selected }: NodeProps & { data: GroupNodeDat
>
<NodeResizer minWidth={200} minHeight={120} isVisible={selected} />
<div className="flex flex-col gap-0.5 px-3 py-2">
<div className="text-xs font-medium text-foreground">
{data.label || 'Группа'}
<div className="flex items-center gap-1.5">
<div className="min-w-0 truncate text-xs font-medium text-foreground">
{data.label || 'Группа'}
</div>
{data.lbMode ? (
<Badge variant={lbModeBadgeVariant(data.lbMode)} size="xs">
{lbModeLabel(data.lbMode)}
</Badge>
) : null}
</div>
{data.notes ? (
<div className="text-[10px] text-muted-foreground whitespace-pre-wrap">
@@ -0,0 +1,15 @@
import { Handle, Position } from '@xyflow/react'
const HANDLE_CLASS = '!size-2.5 !border-background !bg-muted-foreground'
/** Четыре стороны; ConnectionMode.Loose позволяет стартовать и заканчивать на любой. */
export function NodeSideHandles() {
return (
<>
<Handle type="source" position={Position.Top} id="top" className={HANDLE_CLASS} />
<Handle type="source" position={Position.Right} id="right" className={HANDLE_CLASS} />
<Handle type="source" position={Position.Bottom} id="bottom" className={HANDLE_CLASS} />
<Handle type="source" position={Position.Left} id="left" className={HANDLE_CLASS} />
</>
)
}
@@ -0,0 +1,42 @@
import { memo } from 'react'
import { type NodeProps } from '@xyflow/react'
import { NetworkIcon } from 'lucide-react'
import { cn } from '@cfdm/ui/lib/utils'
import { Badge } from '@/components/reui/badge'
import { lbModeBadgeVariant, lbModeLabel, type ServiceNodeData } from '../types'
import { NodeSideHandles } from './node-side-handles'
function ServiceNodeComponent({ data, selected }: NodeProps & { data: ServiceNodeData }) {
return (
<div
className={cn(
'relative min-w-[200px] max-w-[280px] rounded-lg border bg-background px-3 py-2 shadow-sm',
selected ? 'border-primary ring-2 ring-primary/20' : 'border-border',
)}
>
<NodeSideHandles />
<div className="flex items-start gap-2">
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted">
<NetworkIcon className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">{data.label || 'Сервис'}</span>
{data.lbMode ? (
<Badge variant={lbModeBadgeVariant(data.lbMode)} size="xs">
{lbModeLabel(data.lbMode)}
</Badge>
) : null}
</div>
{data.fqdn ? (
<div className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground">
{data.fqdn}
</div>
) : null}
</div>
</div>
</div>
)
}
export const ServiceNode = memo(ServiceNodeComponent)
@@ -1,14 +1,17 @@
import { memo } from 'react'
import { Handle, Position, type NodeProps } from '@xyflow/react'
import { memo, useMemo } from 'react'
import { type NodeProps } from '@xyflow/react'
import { ServerIcon } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { cn } from '@cfdm/ui/lib/utils'
import { Badge } from '@/components/reui/badge'
import { CountryFlag } from '@/components/country-flag'
import { StatusBadge } from '@/components/status-badge'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { resolveCountryCode, vpsStatusLabel } from '@/lib/format'
import type { Vps } from '@/types/entities'
import { aggregateCfdmServices, servicesForVps } from '../cfdm-services'
import { type VpsNodeData, vpsSpecsLine } from '../types'
import { NodeSideHandles } from './node-side-handles'
function formatRate(vps: Vps): string | null {
if (vps.tariffType === 'monthly' && vps.monthlyRate != null) {
@@ -31,6 +34,10 @@ function VpsNodeComponent({ data, selected }: NodeProps & { data: VpsNodeData })
const rate = vps ? formatRate(vps) : null
const countryCode = resolveCountryCode(vps?.country)
const hasFlag = Boolean(countryCode)
const serviceChips = useMemo(
() => servicesForVps(aggregateCfdmServices(snapshot?.vpsDomains ?? []), data.vpsId),
[snapshot?.vpsDomains, data.vpsId],
)
return (
<div
@@ -50,11 +57,7 @@ function VpsNodeComponent({ data, selected }: NodeProps & { data: VpsNodeData })
/>
</div>
) : null}
<Handle
type="target"
position={Position.Left}
className="!size-2.5 !border-background !bg-muted-foreground"
/>
<NodeSideHandles />
{vps?.ip ? (
<div className="mb-1 font-mono text-[10px] text-muted-foreground">{vps.ip}</div>
) : null}
@@ -92,13 +95,17 @@ function VpsNodeComponent({ data, selected }: NodeProps & { data: VpsNodeData })
{[vps.country, vps.city, vps.datacenter].filter(Boolean).join(' · ')}
</div>
) : null}
{serviceChips.length > 0 ? (
<div className="mt-1.5 flex flex-wrap gap-1">
{serviceChips.map((s) => (
<Badge key={s.serviceId} variant="secondary" size="xs">
{s.name}
</Badge>
))}
</div>
) : null}
</div>
</div>
<Handle
type="source"
position={Position.Right}
className="!size-2.5 !border-background !bg-muted-foreground"
/>
</div>
)
}
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest'
import { placeCfdmService, placeCfdmServices, migrateCfdmGroupsToServices } from './place-cfdm-service'
import { isServiceNodeData, isVpsNodeData, type TopologyFlowNode } from './types'
import type { CfdmTopologyService } from './cfdm-services'
const vpn: CfdmTopologyService = {
serviceId: 10,
name: 'VPN',
slug: 'vpn',
lbMode: 'failover',
fqdns: ['vpn.example.com', 'vpn-alt.example.com'],
matchedVpsIds: ['vps-a', 'vps-b'],
unmatchedIps: [],
}
const dns: CfdmTopologyService = {
serviceId: 11,
name: 'DNS',
slug: 'dns',
lbMode: 'round_robin',
fqdns: ['ns.shnt.top'],
matchedVpsIds: ['vps-a', 'vps-b'],
unmatchedIps: [],
}
const bgp: CfdmTopologyService = {
serviceId: 12,
name: 'BGP',
slug: 'bgp',
lbMode: 'failover',
fqdns: ['bgp.example.com'],
matchedVpsIds: ['vps-a', 'vps-c'],
unmatchedIps: [],
}
function vpsIds(nodes: TopologyFlowNode[]): string[] {
return nodes
.filter((n): n is TopologyFlowNode & { data: { vpsId: string } } => isVpsNodeData(n.data))
.map((n) => n.data.vpsId)
.sort()
}
describe('placeCfdmService', () => {
it('creates a service node, VPS cards without parentId, and membership edges', () => {
const { nodes, edges, alreadyOnCanvas } = placeCfdmService([], [], vpn, { x: 0, y: 0 })
expect(alreadyOnCanvas).toBe(false)
const service = nodes.find((n) => n.type === 'service')
expect(service).toBeTruthy()
expect(isServiceNodeData(service!.data) && service!.data.lbMode).toBe('failover')
expect(isServiceNodeData(service!.data) && service!.data.cfdmServiceId).toBe(10)
expect(isServiceNodeData(service!.data) && service!.data.fqdn).toBe('vpn.example.com')
expect(isServiceNodeData(service!.data) && service!.data.extraFqdns).toEqual([
'vpn-alt.example.com',
])
expect(nodes.some((n) => n.parentId)).toBe(false)
expect(vpsIds(nodes)).toEqual(['vps-a', 'vps-b'])
expect(edges.every((e) => e.data?.relation === 'membership')).toBe(true)
expect(edges).toHaveLength(2)
expect(edges.every((e) => e.source === service!.id)).toBe(true)
expect(edges.every((e) => e.sourceHandle && e.targetHandle)).toBe(true)
})
it('does not duplicate a service already on the canvas', () => {
const first = placeCfdmService([], [], vpn, { x: 0, y: 0 })
const second = placeCfdmService(first.nodes, first.edges, vpn, { x: 100, y: 100 })
expect(second.alreadyOnCanvas).toBe(true)
expect(second.nodes.filter((n) => n.type === 'service')).toHaveLength(1)
})
it('lets one VPS belong to DNS and BGP without cloning the card', () => {
const first = placeCfdmService([], [], dns, { x: 0, y: 0 })
const result = placeCfdmService(first.nodes, first.edges, bgp, { x: 0, y: 200 })
expect(result.alreadyOnCanvas).toBe(false)
expect(result.nodes.filter((n) => n.type === 'service')).toHaveLength(2)
expect(vpsIds(result.nodes)).toEqual(['vps-a', 'vps-b', 'vps-c'])
const aNode = result.nodes.find(
(n) => n.type === 'vps' && isVpsNodeData(n.data) && n.data.vpsId === 'vps-a',
)
expect(aNode).toBeTruthy()
const membershipToA = result.edges.filter(
(e) => e.data?.relation === 'membership' && e.target === aNode!.id,
)
expect(membershipToA).toHaveLength(2)
})
})
describe('placeCfdmServices', () => {
it('places several services and reports already-on-canvas ids', () => {
const first = placeCfdmServices([], [], [dns], { x: 0, y: 0 })
const second = placeCfdmServices(first.nodes, first.edges, [dns, bgp], { x: 0, y: 0 })
expect(second.alreadyIds).toEqual([11])
expect(second.nodes.filter((n) => n.type === 'service')).toHaveLength(2)
})
})
describe('migrateCfdmGroupsToServices', () => {
it('converts a CFDM group with parentId children into a service node and membership edges', () => {
const group: TopologyFlowNode = {
id: 'group-1',
type: 'group',
position: { x: 10, y: 20 },
style: { width: 400, height: 200 },
data: { label: 'DNS', cfdmServiceId: 11, lbMode: 'round_robin' },
}
const childA: TopologyFlowNode = {
id: 'vps-node-a',
type: 'vps',
parentId: 'group-1',
position: { x: 20, y: 40 },
data: { vpsId: 'vps-a' },
}
const childB: TopologyFlowNode = {
id: 'vps-node-b',
type: 'vps',
parentId: 'group-1',
position: { x: 240, y: 40 },
data: { vpsId: 'vps-b' },
}
const { nodes, edges, changed } = migrateCfdmGroupsToServices(
[group, childA, childB],
[],
[dns],
)
expect(changed).toBe(true)
const service = nodes.find((n) => n.id === 'group-1')
expect(service?.type).toBe('service')
expect(isServiceNodeData(service!.data) && service!.data.fqdn).toBe('ns.shnt.top')
expect(nodes.every((n) => !n.parentId)).toBe(true)
expect(edges).toHaveLength(2)
expect(edges.every((e) => e.data?.relation === 'membership' && e.source === 'group-1')).toBe(
true,
)
})
it('is a no-op when there are no CFDM groups', () => {
const placed = placeCfdmService([], [], dns, { x: 0, y: 0 })
const again = migrateCfdmGroupsToServices(placed.nodes, placed.edges, [dns])
expect(again.changed).toBe(false)
expect(again.nodes.filter((n) => n.type === 'group')).toHaveLength(0)
})
it('strips leftover group size from an already converted service node', () => {
const placed = placeCfdmService([], [], dns, { x: 0, y: 0 })
const wide = placed.nodes.map((n) =>
n.type === 'service' ? { ...n, width: 400, height: 200, style: { width: 400, height: 200 } } : n,
)
const { nodes, changed } = migrateCfdmGroupsToServices(wide, placed.edges, [dns])
expect(changed).toBe(true)
const service = nodes.find((n) => n.type === 'service')
expect(service?.width).toBeUndefined()
expect(service?.style).toBeUndefined()
})
})
@@ -0,0 +1,251 @@
import type { CfdmTopologyService } from './cfdm-services'
import { serviceFqdnMeta } from './cfdm-services'
import { createMembershipEdge } from './edge-utils'
import { applyMembershipHandles } from './membership-handles'
import { detachNodeFromGroup, normalizeGroupLayers, sortParentsFirst } from './group-utils'
import {
isGroupNodeData,
isServiceNodeData,
isVpsNodeData,
newNodeId,
type TopologyFlowEdge,
type TopologyFlowNode,
type ServiceNodeData,
} from './types'
const CELL_W = 240
const CELL_H = 110
const SERVICE_GAP_Y = 120
export type PlaceCfdmServiceResult = {
nodes: TopologyFlowNode[]
edges: TopologyFlowEdge[]
alreadyOnCanvas: boolean
}
function existingServiceNode(
nodes: TopologyFlowNode[],
serviceId: number,
): TopologyFlowNode | undefined {
return nodes.find(
(n) => n.type === 'service' && isServiceNodeData(n.data) && n.data.cfdmServiceId === serviceId,
)
}
function existingVpsNode(
nodes: TopologyFlowNode[],
vpsId: string,
): TopologyFlowNode | undefined {
return nodes.find((n) => n.type === 'vps' && isVpsNodeData(n.data) && n.data.vpsId === vpsId)
}
function hasMembership(
edges: TopologyFlowEdge[],
serviceNodeId: string,
vpsNodeId: string,
): boolean {
return edges.some(
(e) =>
e.data?.relation === 'membership' &&
e.source === serviceNodeId &&
e.target === vpsNodeId,
)
}
function serviceNodeData(service: CfdmTopologyService): ServiceNodeData {
const { fqdn, extraFqdns } = serviceFqdnMeta(service)
return {
label: service.name,
cfdmServiceId: service.serviceId,
fqdn,
extraFqdns: extraFqdns.length > 0 ? extraFqdns : undefined,
lbMode: service.lbMode,
}
}
/** Ставит сервис CFDM как отдельный узел и пунктирные рёбра членства к VPS. */
export function placeCfdmService(
nodes: TopologyFlowNode[],
edges: TopologyFlowEdge[],
service: CfdmTopologyService,
origin: { x: number; y: number },
): PlaceCfdmServiceResult {
const already = existingServiceNode(nodes, service.serviceId)
if (already) {
return { nodes, edges, alreadyOnCanvas: true }
}
const serviceId = newNodeId('service')
const serviceNode: TopologyFlowNode = {
id: serviceId,
type: 'service',
position: origin,
data: serviceNodeData(service),
}
let nextNodes = [...nodes, serviceNode]
let nextEdges = [...edges]
service.matchedVpsIds.forEach((vpsId, i) => {
let vpsNode = existingVpsNode(nextNodes, vpsId)
if (!vpsNode) {
vpsNode = {
id: newNodeId('vps'),
type: 'vps',
position: {
x: origin.x + i * CELL_W,
y: origin.y + SERVICE_GAP_Y,
},
data: { vpsId },
}
nextNodes = [...nextNodes, vpsNode]
}
if (!hasMembership(nextEdges, serviceId, vpsNode.id)) {
nextEdges = [...nextEdges, createMembershipEdge(serviceId, vpsNode.id)]
}
})
return {
nodes: normalizeGroupLayers(sortParentsFirst(nextNodes)),
edges: applyMembershipHandles(nextEdges, nextNodes),
alreadyOnCanvas: false,
}
}
export function placeCfdmServices(
nodes: TopologyFlowNode[],
edges: TopologyFlowEdge[],
services: CfdmTopologyService[],
origin: { x: number; y: number },
): { nodes: TopologyFlowNode[]; edges: TopologyFlowEdge[]; alreadyIds: number[] } {
let nextNodes = nodes
let nextEdges = edges
const alreadyIds: number[] = []
services.forEach((service, i) => {
const result = placeCfdmService(nextNodes, nextEdges, service, {
x: origin.x,
y: origin.y + i * (SERVICE_GAP_Y + CELL_H),
})
nextNodes = result.nodes
nextEdges = result.edges
if (result.alreadyOnCanvas) alreadyIds.push(service.serviceId)
})
return { nodes: nextNodes, edges: nextEdges, alreadyIds }
}
function compactServiceNode(node: TopologyFlowNode): TopologyFlowNode {
if (node.type !== 'service') return node
if (node.style == null && node.width == null && node.height == null && node.measured == null) {
return node
}
const { style: _style, width: _width, height: _height, measured: _measured, ...rest } = node
return rest
}
function compactServiceFromGroup(
group: TopologyFlowNode,
service: CfdmTopologyService | undefined,
): TopologyFlowNode {
const data = isGroupNodeData(group.data) ? group.data : undefined
const { fqdn, extraFqdns } = service ? serviceFqdnMeta(service) : { fqdn: '', extraFqdns: [] }
const nextData: ServiceNodeData = {
label: data?.label ?? service?.name ?? 'Сервис',
cfdmServiceId: data?.cfdmServiceId ?? service?.serviceId ?? 0,
fqdn,
extraFqdns: extraFqdns.length > 0 ? extraFqdns : undefined,
lbMode: data?.lbMode ?? service?.lbMode,
}
const { style: _style, width: _w, height: _h, measured: _m, ...rest } = group
return {
...rest,
type: 'service',
data: nextData,
}
}
/**
* Одноразовая миграция: CFDM dashed-группа + parentId → узел service + membership.
* Идемпотентна: уже `type: 'service'` не трогает.
*/
export function migrateCfdmGroupsToServices(
nodes: TopologyFlowNode[],
edges: TopologyFlowEdge[],
services: CfdmTopologyService[],
): { nodes: TopologyFlowNode[]; edges: TopologyFlowEdge[]; changed: boolean } {
const byServiceId = new Map(services.map((s) => [s.serviceId, s]))
const groups = nodes.filter(
(n) => n.type === 'group' && isGroupNodeData(n.data) && n.data.cfdmServiceId != null,
)
if (groups.length === 0) {
const compacted = nodes.map(compactServiceNode)
const withMeta = applyServiceFqdns(compacted, services)
const nextEdges = applyMembershipHandles(edges, withMeta)
const changed =
compacted.some((n, i) => n !== nodes[i]) ||
withMeta !== compacted ||
nextEdges !== edges
return { nodes: withMeta, edges: nextEdges, changed }
}
let nextNodes = nodes
let nextEdges = edges
let changed = false
for (const group of groups) {
if (!isGroupNodeData(group.data) || group.data.cfdmServiceId == null) continue
const service = byServiceId.get(group.data.cfdmServiceId)
const children = nextNodes.filter((n) => n.parentId === group.id && n.type === 'vps')
for (const child of children) {
const detached = detachNodeFromGroup(child, nextNodes)
nextNodes = nextNodes.map((n) => (n.id === child.id ? detached : n))
if (!hasMembership(nextEdges, group.id, detached.id)) {
nextEdges = [...nextEdges, createMembershipEdge(group.id, detached.id)]
}
changed = true
}
const converted = compactServiceFromGroup(group, service)
nextNodes = nextNodes.map((n) => (n.id === group.id ? converted : n))
changed = true
}
return {
nodes: normalizeGroupLayers(sortParentsFirst(nextNodes)),
edges: applyMembershipHandles(nextEdges, nextNodes),
changed,
}
}
/** Подставляет FQDN/lbMode из агрегата, не меняя позиции. */
export function applyServiceFqdns(
nodes: TopologyFlowNode[],
services: CfdmTopologyService[],
): TopologyFlowNode[] {
if (services.length === 0) return nodes
const byId = new Map(services.map((s) => [s.serviceId, s]))
let changed = false
const next = nodes.map((n) => {
if (n.type !== 'service' || !isServiceNodeData(n.data)) return n
const service = byId.get(n.data.cfdmServiceId)
if (!service) return n
const { fqdn, extraFqdns } = serviceFqdnMeta(service)
const extra = extraFqdns.length > 0 ? extraFqdns : undefined
if (
n.data.fqdn === fqdn &&
n.data.lbMode === service.lbMode &&
JSON.stringify(n.data.extraFqdns) === JSON.stringify(extra)
) {
return n
}
changed = true
return {
...n,
data: {
...n.data,
fqdn,
extraFqdns: extra,
lbMode: service.lbMode,
},
}
})
return changed ? next : nodes
}
@@ -12,6 +12,7 @@ import {
ReactFlowProvider,
Background,
BackgroundVariant,
ConnectionMode,
MarkerType,
addEdge,
useEdgesState,
@@ -27,9 +28,11 @@ import {
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { toPng } from 'html-to-image'
import { useQuery } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import { toast } from 'sonner'
import { cn } from '@cfdm/ui/lib/utils'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { topologyNodeTypes } from './node-types'
import { topologyEdgeTypes } from './edge-types'
import { TopologyPalette, parsePaletteDrag, shapeLabel } from './palette'
@@ -38,7 +41,14 @@ import { AddVpsSheet } from './add-vps-sheet'
import { VpsDetailSheet } from './vps-detail-sheet'
import { ElementEditSheet, type EditableElement } from './element-edit-sheet'
import { EdgeEditSheet } from './edge-edit-sheet'
import { applyEdgeVisuals, createConnectedEdge } from './edge-utils'
import { applyEdgeVisuals, createConnectedEdge, isMembershipEdge } from './edge-utils'
import { applyMembershipHandles, clusterIdsForService } from './membership-handles'
import { aggregateCfdmServices, type CfdmTopologyService } from './cfdm-services'
import {
applyServiceFqdns,
migrateCfdmGroupsToServices,
placeCfdmServices,
} from './place-cfdm-service'
import {
normalizeGroupLayers,
placeWithOptionalParent,
@@ -49,12 +59,14 @@ import {
defaultEdgeData,
isGroupNodeData,
isNoteNodeData,
isServiceNodeData,
isShapeNodeData,
isVpsNodeData,
newNodeId,
type GroupNodeData,
type NoteNodeData,
type PaletteItem,
type ServiceNodeData,
type ShapeNodeData,
type TopologyEdgeData,
type TopologyFlowNode,
@@ -92,8 +104,15 @@ function TopologyEditorInner({
const { resolvedTheme } = useTheme()
const colorMode = resolvedTheme === 'dark' ? 'dark' : 'light'
const wrapperRef = useRef<HTMLDivElement>(null)
const { screenToFlowPosition, fitView, zoomIn, zoomOut, getViewport, setViewport } =
const { screenToFlowPosition, fitView, zoomIn, zoomOut, getViewport, setViewport, getNodes, getEdges } =
useReactFlow()
const { data: snapshot } = useQuery(snapshotQueryOptions())
const cfdmServices = useMemo(
() => aggregateCfdmServices(snapshot?.vpsDomains ?? []),
[snapshot?.vpsDomains],
)
const cfdmServicesRef = useRef(cfdmServices)
cfdmServicesRef.current = cfdmServices
const [nodes, setNodes, onNodesChangeBase] = useNodesState<FlowNode>(
normalizeGroupLayers(sortParentsFirst(initialNodes)),
@@ -116,8 +135,15 @@ function TopologyEditorInner({
useEffect(() => {
skipSave.current = true
hydrated.current = false
setNodes(normalizeGroupLayers(sortParentsFirst(initialNodes)))
setEdges(normalizeEdges(initialEdges))
const migrated = migrateCfdmGroupsToServices(
initialNodes,
initialEdges as FlowEdge[],
cfdmServicesRef.current,
)
const withFqdn = applyServiceFqdns(migrated.nodes, cfdmServicesRef.current)
const withHandles = applyMembershipHandles(migrated.edges, withFqdn)
setNodes(() => normalizeGroupLayers(sortParentsFirst(withFqdn)))
setEdges(() => normalizeEdges(withHandles))
setEditElement(null)
setElementOpen(false)
setEditEdgeId(null)
@@ -134,6 +160,13 @@ function TopologyEditorInner({
return () => window.clearTimeout(t)
}, [diagramId]) // eslint-disable-line react-hooks/exhaustive-deps -- remount on diagram switch
useEffect(() => {
setNodes((ns) => {
const next = applyServiceFqdns(ns, cfdmServices)
return next === ns ? ns : next
})
}, [cfdmServices, setNodes])
const emitSave = useCallback(() => {
if (skipSave.current || !hydrated.current || locked) return
onDocumentChange({
@@ -149,6 +182,10 @@ function TopologyEditorInner({
return () => window.clearTimeout(t)
}, [nodes, edges, emitSave, locked])
const syncMembershipHandles = useCallback(() => {
setEdges((eds) => applyMembershipHandles(eds, getNodes() as FlowNode[]))
}, [getNodes, setEdges])
const onNodesChange: OnNodesChange<FlowNode> = useCallback(
(changes) => {
if (locked) return
@@ -162,8 +199,13 @@ function TopologyEditorInner({
setNodes((ns) => normalizeGroupLayers(ns))
})
}
if (changes.some((c) => c.type === 'dimensions')) {
queueMicrotask(() => {
setEdges((eds) => applyMembershipHandles(eds, getNodes() as FlowNode[]))
})
}
},
[locked, onNodesChangeBase, setNodes],
[locked, onNodesChangeBase, setNodes, setEdges, getNodes],
)
const onEdgesChange: OnEdgesChange<FlowEdge> = useCallback(
@@ -193,6 +235,11 @@ function TopologyEditorInner({
[locked, setEdges],
)
const onNodeDrag: OnNodeDrag<FlowNode> = useCallback(() => {
if (locked) return
syncMembershipHandles()
}, [locked, syncMembershipHandles])
const onNodeDragStop: OnNodeDrag<FlowNode> = useCallback(
(_e, node) => {
if (locked) return
@@ -207,8 +254,9 @@ function TopologyEditorInner({
const updated = ns.map((n) => (n.id === next.id ? next : n))
return normalizeGroupLayers(sortParentsFirst(updated))
})
syncMembershipHandles()
},
[locked, setNodes],
[locked, setNodes, syncMembershipHandles],
)
const existingVpsIds = useMemo(() => {
@@ -219,6 +267,19 @@ function TopologyEditorInner({
return ids
}, [nodes])
const existingCfdmServiceIds = useMemo(() => {
const ids = new Set<number>()
for (const n of nodes) {
if (n.type === 'service' && isServiceNodeData(n.data)) {
ids.add(n.data.cfdmServiceId)
}
if (n.type === 'group' && isGroupNodeData(n.data) && n.data.cfdmServiceId != null) {
ids.add(n.data.cfdmServiceId)
}
}
return ids
}, [nodes])
function placeNode(item: PaletteItem, position: { x: number; y: number }) {
if (item.kind === 'vps-picker') {
setAddVpsOpen(true)
@@ -294,6 +355,19 @@ function TopologyEditorInner({
})
}
function handleAddCfdmServices(services: CfdmTopologyService[]) {
const origin = screenToFlowPosition({
x: (wrapperRef.current?.clientWidth ?? 400) / 2 + 80,
y: (wrapperRef.current?.clientHeight ?? 300) / 2,
})
const result = placeCfdmServices(nodes, edges, services, origin)
if (result.alreadyIds.length > 0) {
toast.message('Некоторые сервисы уже на схеме')
}
setNodes(result.nodes)
setEdges(normalizeEdges(result.edges))
}
function placeItemAtCenter(item: PaletteItem) {
if (locked) return
const rect = wrapperRef.current?.getBoundingClientRect()
@@ -323,10 +397,26 @@ function TopologyEditorInner({
if (node.type === 'group' && isGroupNodeData(node.data)) {
setEditElement({ kind: 'group', id: node.id, data: node.data })
setElementOpen(true)
return
}
if (node.type === 'service' && isServiceNodeData(node.data)) {
const serviceId = node.id
queueMicrotask(() => {
const ids = new Set(clusterIdsForService(serviceId, getEdges()))
setNodes((ns) => ns.map((n) => ({ ...n, selected: ids.has(n.id) })))
})
}
}
function onNodeDoubleClick(_e: ReactMouseEvent, node: FlowNode) {
if (node.type === 'service' && isServiceNodeData(node.data)) {
setEditElement({ kind: 'service', id: node.id, data: node.data })
setElementOpen(true)
}
}
function onEdgeClick(_e: ReactMouseEvent, edge: FlowEdge) {
if (isMembershipEdge(edge)) return
const data = edge.data ?? defaultEdgeData()
setEditEdgeId(edge.id)
setEditEdgeData(data)
@@ -336,7 +426,7 @@ function TopologyEditorInner({
function handleSaveElement(
id: string,
type: TopologyNodeType,
data: ShapeNodeData | NoteNodeData | GroupNodeData,
data: ShapeNodeData | NoteNodeData | GroupNodeData | ServiceNodeData,
) {
setNodes((ns) =>
ns.map((n) => (n.id === id && n.type === type ? { ...n, data } : n)),
@@ -395,7 +485,9 @@ function TopologyEditorInner({
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
onEdgeClick={onEdgeClick}
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop}
onDrop={onDrop}
onDragOver={onDragOver}
@@ -407,6 +499,8 @@ function TopologyEditorInner({
}}
nodeTypes={topologyNodeTypes}
edgeTypes={topologyEdgeTypes}
connectionMode={ConnectionMode.Loose}
elevateNodesOnSelect={false}
nodesDraggable={!locked}
nodesConnectable={!locked}
elementsSelectable={!locked}
@@ -448,7 +542,9 @@ function TopologyEditorInner({
open={addVpsOpen}
onOpenChange={setAddVpsOpen}
existingVpsIds={existingVpsIds}
existingCfdmServiceIds={existingCfdmServiceIds}
onAdd={handleAddVps}
onAddServices={handleAddCfdmServices}
/>
<VpsDetailSheet
vpsId={detailVpsId}
+63 -2
View File
@@ -1,8 +1,11 @@
import type { Edge, Node } from '@xyflow/react'
import type { TopologyLbMode } from '@cfdm/shared/contracts/integration-cfdm'
import type { Vps } from '@/types/entities'
import { uid } from '@/lib/format'
export type TopologyNodeType = 'vps' | 'shape' | 'note' | 'group'
export type { TopologyLbMode }
export type TopologyNodeType = 'vps' | 'shape' | 'note' | 'group' | 'service'
export type ShapeKind = 'rect' | 'ellipse' | 'diamond'
@@ -23,6 +26,17 @@ export type NoteNodeData = {
export type GroupNodeData = {
label: string
notes?: string
/** Только у немигрированных CFDM-коробок; после миграции поле на `service`. */
cfdmServiceId?: number
lbMode?: TopologyLbMode
}
export type ServiceNodeData = {
label: string
cfdmServiceId: number
fqdn: string
extraFqdns?: string[]
lbMode?: TopologyLbMode
}
export type TopologyNodeData =
@@ -30,6 +44,7 @@ export type TopologyNodeData =
| ShapeNodeData
| NoteNodeData
| GroupNodeData
| ServiceNodeData
/** Тип связи на схеме инфраструктуры */
export type TopologyEdgeRelation =
@@ -39,6 +54,7 @@ export type TopologyEdgeRelation =
| 'vpn'
| 'sync'
| 'custom'
| 'membership'
export type TopologyEdgeDirection = 'forward' | 'bidirectional' | 'none'
@@ -97,8 +113,23 @@ export function isNoteNodeData(data: TopologyNodeData): data is NoteNodeData {
return 'text' in data
}
export function isServiceNodeData(data: TopologyNodeData): data is ServiceNodeData {
return (
typeof (data as ServiceNodeData).cfdmServiceId === 'number' &&
'fqdn' in data &&
'label' in data &&
!('vpsId' in data)
)
}
export function isGroupNodeData(data: TopologyNodeData): data is GroupNodeData {
return 'label' in data && !('kind' in data) && !('vpsId' in data) && !('text' in data)
return (
'label' in data &&
!('kind' in data) &&
!('vpsId' in data) &&
!('text' in data) &&
!isServiceNodeData(data)
)
}
export function defaultEdgeData(): TopologyEdgeData {
@@ -140,6 +171,8 @@ export function edgeRelationShortLabel(relation: TopologyEdgeRelation): string {
return 'Синк'
case 'custom':
return 'Связь'
case 'membership':
return 'Членство'
default:
return relation
}
@@ -154,3 +187,31 @@ export function newNodeId(prefix: string): string {
// uid() falls back when crypto.randomUUID unavailable (HTTP non-localhost)
return `${prefix}-${uid()}`
}
export function isTopologyLbMode(value: unknown): value is TopologyLbMode {
return value === 'round_robin' || value === 'failover' || value === 'weighted'
}
export function lbModeLabel(mode: TopologyLbMode): string {
switch (mode) {
case 'failover':
return 'Failover'
case 'round_robin':
return 'Round robin'
case 'weighted':
return 'Weighted'
}
}
export function lbModeBadgeVariant(
mode: TopologyLbMode,
): 'warning' | 'success' | 'info' {
switch (mode) {
case 'failover':
return 'warning'
case 'round_robin':
return 'success'
case 'weighted':
return 'info'
}
}
+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') ||
+2 -2
View File
@@ -8,7 +8,7 @@ export {
} from '@cfdm/shared/contracts/custom-fields'
import type { ReactNode } from 'react'
import { Badge } from '@cfdm/ui/components/badge'
import { Badge } from '@/components/reui/badge'
import {
type CustomFieldDef,
formatCustomFieldValue,
@@ -39,7 +39,7 @@ export function buildCustomFieldColumns<T extends { customData?: unknown }>(
}
if (def.type === 'bool') {
return (
<Badge variant={val ? 'default' : 'outline'}>
<Badge variant={val ? 'success-light' : 'outline'} size="sm" radius="full">
{formatCustomFieldValue(def, val)}
</Badge>
)
+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,
+31 -24
View File
@@ -20,10 +20,10 @@ import { buildApiCredentials } from '@cfdm/shared/utils/api-credentials'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { Badge } from '@/components/reui/badge'
import { ResourcePage, columnDefFromDataGrid, KpiStatGrid } from '@/components/reui-kit'
import type { DataGridColumn } from '@/components/data-grid-types'
import { dataGridCellStack } from '@/components/data-grid-cells'
import { dataGridCellStack, DataGridNameCell } from '@/components/data-grid-cells'
import { CrudListPage } from '@/components/crud-list-page'
import { RowActions } from '@/components/row-actions'
import { HealthModeBanner } from '@/components/health-mode-banner'
@@ -62,10 +62,13 @@ const accountsSearchSchema = z.object({
health: z.string().optional(),
})
const HEALTH_BADGE_VARIANT: Record<AccountHealthFlag, 'default' | 'secondary' | 'destructive' | 'outline'> = {
'stale-sync': 'secondary',
'low-balance': 'destructive',
'balance-mismatch': 'outline',
const HEALTH_BADGE_VARIANT: Record<
AccountHealthFlag,
'warning-light' | 'destructive-light' | 'outline'
> = {
'stale-sync': 'warning-light',
'low-balance': 'destructive-light',
'balance-mismatch': 'warning-light',
'no-creds': 'outline',
}
@@ -267,7 +270,13 @@ function AccountsPage() {
key: 'name',
header: 'Аккаунт',
icon: UserRoundIcon,
cell: (a) => dataGridCellStack(a.name, providerById.get(a.providerId)?.name ?? '—'),
cell: (a) => (
<DataGridNameCell
icon={UserRoundIcon}
title={a.name}
subtitle={providerById.get(a.providerId)?.name ?? '—'}
/>
),
},
{
key: 'login',
@@ -285,12 +294,12 @@ function AccountsPage() {
cell: (a) => {
const flags = getAccountHealthFlags(a, healthCtx)
if (!flags.length) {
return <Badge variant="outline">OK</Badge>
return <Badge variant="success-light" size="sm" radius="full">OK</Badge>
}
return (
<div className="flex flex-wrap gap-1">
{flags.map((flag) => (
<Badge key={flag} variant={HEALTH_BADGE_VARIANT[flag]}>
<Badge key={flag} variant={HEALTH_BADGE_VARIANT[flag]} size="sm" radius="full">
{ACCOUNT_HEALTH_LABELS[flag]}
</Badge>
))}
@@ -303,7 +312,7 @@ function AccountsPage() {
header: 'API-доступ',
icon: PlugIcon,
cell: (a) => (
<Badge variant={a.apiCredentialsSet ? 'default' : 'outline'}>
<Badge variant={a.apiCredentialsSet ? 'success-light' : 'outline'} size="sm" radius="full">
{a.apiCredentialsSet ? 'установлены' : 'нет'}
</Badge>
),
@@ -323,7 +332,7 @@ function AccountsPage() {
sortValue: (a) => vpsCountByAccount.get(a.id) ?? 0,
cell: (a) => {
const count = vpsCountByAccount.get(a.id) ?? 0
return count ? <Badge variant="secondary">{count}</Badge> : <span className="text-muted-foreground">0</span>
return count ? <Badge variant="secondary" size="sm" radius="full">{count}</Badge> : <span className="text-muted-foreground">0</span>
},
},
{
@@ -358,7 +367,7 @@ function AccountsPage() {
key: 'actions',
header: '',
sortable: false,
className: 'w-32 text-right',
className: 'w-12 text-right',
cell: (a) => {
const provider = providerById.get(a.providerId)
const canSync = accountBillmanagerUiReady(a, provider)
@@ -369,17 +378,16 @@ function AccountsPage() {
deleteTitle="Удалить аккаунт?"
deleteDescription={`«${a.name}» будет удалён.`}
extra={
canSync ? (
<Button
variant="ghost"
size="icon-sm"
aria-label="Синхронизировать"
disabled={syncMut.isPending && syncMut.variables === a.id}
onClick={() => syncMut.mutate(a.id)}
>
<RefreshCwIcon />
</Button>
) : null
canSync
? [
{
label: 'Синхронизировать',
icon: RefreshCwIcon,
disabled: syncMut.isPending && syncMut.variables === a.id,
onSelect: () => syncMut.mutate(a.id),
},
]
: undefined
}
/>
)
@@ -450,7 +458,6 @@ function AccountsPage() {
columns={columnDefFromDataGrid(columns)}
data={filteredAccounts}
getRowId={(a) => a.id}
pinLastColumn
emptyTitle={health || hasActiveAccountFilters(filters) ? 'Нет аккаунтов с этими фильтрами' : 'Нет записей'}
emptyDescription={
health || hasActiveAccountFilters(filters)
+6 -3
View File
@@ -19,7 +19,7 @@ import { toast } from 'sonner'
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { Badge } from '@/components/reui/badge'
import { ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit'
import type { DataGridColumn } from '@/components/data-grid-types'
import { dataGridCellStack } from '@/components/data-grid-cells'
@@ -100,7 +100,11 @@ function BalancePage() {
header: 'Движение',
icon: ArrowLeftRightIcon,
cell: (r) => (
<Badge variant={r.direction === 'credit' ? 'default' : 'destructive'}>
<Badge
variant={r.direction === 'credit' ? 'success-light' : 'destructive-light'}
size="sm"
radius="full"
>
<ArrowDownUpIcon data-icon="inline-start" />
{r.direction === 'credit' ? 'Приход' : 'Списание'}
</Badge>
@@ -236,7 +240,6 @@ function BalancePage() {
columns={columnDefFromDataGrid(columns)}
data={rows}
getRowId={(r) => r.id}
pinLastColumn
footerContent={
<div className="flex flex-wrap justify-end gap-6 px-3 py-2 text-sm tabular-nums">
<span>
+13 -7
View File
@@ -277,7 +277,7 @@ function DashboardPage() {
iconClassName: 'text-primary',
to: '/vps',
footer: (
<Badge variant="outline" size="sm">
<Badge variant="outline" size="sm" radius="full">
{totalCount} всего
</Badge>
),
@@ -290,7 +290,7 @@ function DashboardPage() {
iconClassName: 'text-info',
to: '/reports',
footer: (
<Badge variant="info-light" size="sm">
<Badge variant="info-light" size="sm" radius="full">
оценка
</Badge>
),
@@ -308,7 +308,7 @@ function DashboardPage() {
iconClassName: 'text-success',
to: '/accounts',
footer: (
<Badge variant="success-light" size="sm">
<Badge variant="success-light" size="sm" radius="full">
API
</Badge>
),
@@ -322,11 +322,11 @@ function DashboardPage() {
variant: runwayLow ? 'warning' : 'default',
to: '/accounts',
footer: runwayLow ? (
<Badge variant="warning-light" size="sm">
<Badge variant="warning-light" size="sm" radius="full">
&lt; 14 дн
</Badge>
) : (
<Badge variant="outline" size="sm">
<Badge variant="outline" size="sm" radius="full">
запас
</Badge>
),
@@ -343,6 +343,7 @@ function DashboardPage() {
<Badge
variant={expiringCount > 0 ? 'warning-light' : 'success-light'}
size="sm"
radius="full"
>
{expiringCount > 0 ? 'скоро' : 'в норме'}
</Badge>
@@ -360,6 +361,7 @@ function DashboardPage() {
<Badge
variant={issuesCount > 0 ? 'destructive-light' : 'success-light'}
size="sm"
radius="full"
>
{issuesCount > 0 ? 'требует внимания' : 'в норме'}
</Badge>
@@ -406,7 +408,9 @@ function DashboardPage() {
<TabsTrigger value="issues" className={cn(DASHBOARD_TAB_TRIGGER_CLASS, 'gap-2')}>
Проблемы
{issues.length > 0 ? (
<Badge variant="secondary">{issues.length}</Badge>
<Badge variant="secondary" size="sm" radius="full">
{issues.length}
</Badge>
) : null}
</TabsTrigger>
<TabsTrigger value="recent" className={DASHBOARD_TAB_TRIGGER_CLASS}>
@@ -415,7 +419,9 @@ function DashboardPage() {
<TabsTrigger value="risk" className={cn(DASHBOARD_TAB_TRIGGER_CLASS, 'gap-2')}>
Аккаунты
{atRisk.length > 0 ? (
<Badge variant="outline">{atRisk.length}</Badge>
<Badge variant="outline" size="sm" radius="full">
{atRisk.length}
</Badge>
) : null}
</TabsTrigger>
</TabsList>
+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,
})
+1 -2
View File
@@ -143,7 +143,7 @@ function PaymentsPage() {
key: 'actions',
header: '',
sortable: false,
className: 'w-24 text-right',
className: 'w-12 text-right',
cell: (p) => (
<RowActions
onEdit={() => openEdit(p)}
@@ -250,7 +250,6 @@ function PaymentsPage() {
columns={columnDefFromDataGrid(columns)}
data={sorted}
getRowId={(p) => p.id}
pinLastColumn
virtualization={sorted.length > 200}
height={560}
footerContent={
+3 -4
View File
@@ -25,7 +25,7 @@ import {
type ProjectFiltersState,
} from '@/components/project-filters'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { Badge } from '@/components/reui/badge'
import { EmptyState } from '@/components/empty-state'
import { ProjectColorDot } from '@/components/project-color-dot'
import { ProjectEditSheet, projectFormDefaults } from '@/components/domain/project-edit-sheet'
@@ -153,7 +153,7 @@ function ProjectsPage() {
className: 'text-right tabular-nums',
sortValue: (row) => row.vpsTotal,
cell: (row) => (
<Badge variant="secondary">
<Badge variant="secondary" size="sm" radius="full">
{row.vpsActive}/{row.vpsTotal}
</Badge>
),
@@ -183,7 +183,7 @@ function ProjectsPage() {
key: 'actions',
header: '',
sortable: false,
className: 'w-24 text-right',
className: 'w-12 text-right',
cell: (row) => (
<div onClick={(e) => e.stopPropagation()}>
<RowActions
@@ -298,7 +298,6 @@ function ProjectsPage() {
columns={columnDefFromDataGrid(columns)}
data={rows}
getRowId={(r) => r.id}
pinLastColumn
onRowClick={(row) =>
void navigate({
to: '/projects/$projectId',
+11 -13
View File
@@ -7,15 +7,14 @@ import { toast } from 'sonner'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { Badge } from '@/components/reui/badge'
import { ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit'
import type { DataGridColumn } from '@/components/data-grid-types'
import { dataGridCellWithIcon } from '@/components/data-grid-cells'
import { DataGridNameCell } from '@/components/data-grid-cells'
import { CrudListPage } from '@/components/crud-list-page'
import { RowActions } from '@/components/row-actions'
import { ProviderEditSheet, providerFormDefaults } from '@/components/domain/provider-edit-sheet'
import type { ProviderFormValues } from '@/lib/schemas'
import { faviconUrlFromWebsite } from '@/lib/format'
import type { Provider } from '@/types/entities'
export const Route = createFileRoute('/_auth/providers')({
@@ -79,20 +78,19 @@ function ProvidersPage() {
key: 'name',
header: 'Хостер',
icon: BuildingIcon,
cell: (p) => {
const icon = p.website ? (
<img src={faviconUrlFromWebsite(p.website)} alt="" className="size-4 rounded-sm" />
) : (
<BuildingIcon />
)
return dataGridCellWithIcon(icon, <span className="font-medium">{p.name}</span>)
},
cell: (p) => (
<DataGridNameCell
icon={BuildingIcon}
title={p.name}
subtitle={p.website || undefined}
/>
),
},
{
key: 'api',
header: 'API',
icon: PlugIcon,
cell: (p) => <Badge variant="outline">{p.apiType}</Badge>,
cell: (p) => <Badge variant="outline" size="sm" radius="full">{p.apiType}</Badge>,
},
{
key: 'cur',
@@ -104,7 +102,7 @@ function ProvidersPage() {
key: 'actions',
header: '',
sortable: false,
className: 'w-24 text-right',
className: 'w-12 text-right',
cell: (p) => (
<RowActions
onEdit={() => openEdit(p)}
+6 -2
View File
@@ -16,7 +16,7 @@ import {
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@cfdm/ui/components/badge'
import { Badge } from '@/components/reui/badge'
import { Button } from '@cfdm/ui/components/button'
import { SelectField } from '@/components/select-field'
import { getPaidUntilDate } from '@/lib/paid-until'
@@ -197,7 +197,11 @@ function RenewalsPage() {
<span className="text-xs text-muted-foreground">{item.sublabel}</span>
</div>
<div className="flex items-center gap-2">
{item.overdue ? <Badge variant="destructive">Просрочено</Badge> : null}
{item.overdue ? (
<Badge variant="destructive-light" size="sm" radius="full">
Просрочено
</Badge>
) : null}
<span className="tabular-nums text-sm">{item.date.toLocaleDateString('ru-RU')}</span>
</div>
</div>
+2 -2
View File
@@ -6,7 +6,7 @@ import { toast } from 'sonner'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
import { Badge } from '@cfdm/ui/components/badge'
import { Badge } from '@/components/reui/badge'
import { ResourcePage, columnDefFromDataGrid, loadStoredColumnVisibility, dataGridColumnVisibilityOptions } from '@/components/reui-kit'
import type { ColumnVisibilityState } from '@tanstack/react-table'
import type { DataGridColumn } from '@/components/data-grid-types'
@@ -224,7 +224,7 @@ function TariffsPage() {
header: 'Диск',
icon: HardDriveIcon,
sortValue: (t) => t.diskType ?? '',
cell: (t) => <Badge variant="outline">{t.diskType ?? '—'}</Badge>,
cell: (t) => <Badge variant="outline" size="sm" radius="full">{t.diskType ?? '—'}</Badge>,
},
{
key: 'location',
+11 -3
View File
@@ -17,7 +17,7 @@ import { QueryState } from '@/components/query-state'
import { StatusBadge } from '@/components/status-badge'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { Badge } from '@/components/reui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
import {
Frame,
@@ -200,8 +200,16 @@ function VpsDetailPage() {
<TabsContent value="overview" className="flex flex-col gap-4">
<div className="flex flex-wrap gap-2">
<StatusBadge status={row.status} label={vpsStatusLabel(row.status)} />
{row.project ? <Badge variant="outline">{row.project}</Badge> : null}
{row.environment ? <Badge variant="outline">{row.environment}</Badge> : null}
{row.project ? (
<Badge variant="outline" size="sm" radius="full">
{row.project}
</Badge>
) : null}
{row.environment ? (
<Badge variant="outline" size="sm" radius="full">
{row.environment}
</Badge>
) : null}
</div>
<div className="grid gap-4 md:grid-cols-2">
<DetailFrame title="Сеть" icon={<GlobeIcon className="size-4" />}>
+52 -48
View File
@@ -10,11 +10,12 @@ import { normalizeRatesPayload, effectiveVpsTariffCurrency, formatInProviderCurr
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { Badge } from '@/components/reui/badge'
import { ResourcePage, columnDefFromDataGrid, loadStoredColumnVisibility, dataGridColumnVisibilityOptions } from '@/components/reui-kit'
import type { ColumnVisibilityState } from '@tanstack/react-table'
import type { DataGridColumn } from '@/components/data-grid-types'
import { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells'
import { DataGridNameCell, dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells'
import { StatusBadge } from '@/components/status-badge'
import { CountryFlag } from '@/components/country-flag'
import { QueryState } from '@/components/query-state'
import { EmptyState } from '@/components/empty-state'
@@ -344,30 +345,35 @@ function VpsPage() {
header: 'IP / DNS',
icon: GlobeIcon,
sortValue: (v) => v.ip || v.dns || '',
cell: (v) =>
dataGridCellStack(
v.ip ? (
<Button
type="button"
variant="link"
className="h-auto p-0 font-normal"
onClick={() => void copyText(v.ip, 'IP скопирован')}
>
{v.ip}
</Button>
) : (
<span className="text-muted-foreground">—</span>
),
v.dns ? (
<Button
variant="link"
className="text-muted-foreground h-auto p-0 text-xs font-normal"
render={<Link to="/vps/$vpsId" params={{ vpsId: v.id }} />}
>
{v.dns}
</Button>
) : undefined,
),
cell: (v) => (
<DataGridNameCell
icon={GlobeIcon}
title={
v.ip ? (
<button
type="button"
className="truncate text-left font-medium hover:underline"
onClick={() => void copyText(v.ip, 'IP скопирован')}
>
{v.ip}
</button>
) : (
<span className="text-muted-foreground">—</span>
)
}
subtitle={
v.dns ? (
<Link
to="/vps/$vpsId"
params={{ vpsId: v.id }}
className="truncate hover:underline"
>
{v.dns}
</Link>
) : undefined
}
/>
),
},
{
key: 'domains',
@@ -439,11 +445,11 @@ function VpsPage() {
cell: (v) => (
<div className="flex items-center gap-1.5">
{v.access === 'shared' ? (
<Badge variant="outline">Общий</Badge>
<Badge variant="outline" size="sm" radius="full">
Общий
</Badge>
) : null}
<Badge variant={v.status === 'active' ? 'default' : v.status === 'archived' ? 'outline' : 'secondary'}>
{vpsStatusLabel(v.status)}
</Badge>
<StatusBadge status={v.status} label={vpsStatusLabel(v.status)} />
</div>
),
},
@@ -484,9 +490,9 @@ function VpsPage() {
cell: (v) => {
const ext = v as Vps & { lastHealthStatus?: string; monitoringEnabled?: boolean }
if (!ext.monitoringEnabled) return <span className="text-muted-foreground">—</span>
if (ext.lastHealthStatus === 'up') return <Badge variant="default">up</Badge>
if (ext.lastHealthStatus === 'down') return <Badge variant="destructive">down</Badge>
return <Badge variant="outline">—</Badge>
if (ext.lastHealthStatus === 'up') return <StatusBadge status="up" label="up" />
if (ext.lastHealthStatus === 'down') return <StatusBadge status="down" label="down" />
return <span className="text-muted-foreground">—</span>
},
},
{
@@ -529,7 +535,7 @@ function VpsPage() {
header: '',
sortable: false,
enableHiding: false,
className: 'w-24 text-right',
className: 'w-12 text-right',
cell: (v) => (
<RowActions
onEdit={v.access === 'shared' && v.grantPermission !== 'write' ? undefined : () => openEdit(v)}
@@ -537,19 +543,18 @@ function VpsPage() {
deleteTitle="Удалить VPS?"
deleteDescription={`IP ${v.ip} будет удалён безвозвратно.`}
extra={
v.access !== 'shared' ? (
<Button
variant="ghost"
size="icon-sm"
aria-label="Доступ"
onClick={() => {
setAccessVps(v)
setAccessOpen(true)
}}
>
<Share2Icon />
</Button>
) : null
v.access !== 'shared'
? [
{
label: 'Доступ',
icon: Share2Icon,
onSelect: () => {
setAccessVps(v)
setAccessOpen(true)
},
},
]
: undefined
}
/>
),
@@ -683,7 +688,6 @@ function VpsPage() {
data={section.items}
getRowId={(v) => v.id}
emptyTitle="VPS не найдены"
pinLastColumn
dense={filters.tableCompact}
enableRowSelection
onRowSelectionChange={setSelectedIds}
+1
View File
@@ -154,6 +154,7 @@ export interface VpsDomain {
source: string
matchStatus: 'matched' | 'unmatched' | 'orphaned'
targetIps?: string | null
lbMode?: 'round_robin' | 'failover' | 'weighted' | null
syncedAt: string
}
+1 -1
View File
@@ -84,7 +84,7 @@ nano .env # заполнить секреты и домен
| `CF_DNS_API_TOKEN` | Cloudflare token для ACME DNS-01 (env контейнера **Traefik**) |
| `LETSENCRYPT_EMAIL` | Email для Let's Encrypt |
| `VPS_DOMAIN` | Хост UI в Traefik `Host(…)` (например `vps.example.com`) |
| `VPS_LAUNCHER_DOMAIN` | Короткий хост launcher (`vt.example.com` → тот же контейнер, `GET /cc`) |
| `VPS_LAUNCHER_DOMAIN` | Короткий хост launcher (`vt.example.com` → тот же контейнер, `GET /cc`, `/cc.rsc`, `/ic`, `/ic.rsc`) |
Опционально:
+6 -1
View File
@@ -54,6 +54,7 @@ export type CensorcheckRunDto = {
createdAt: string
completedAt: string
observedSourceIp: string | null
detectedHoster: string | null
vps: CensorcheckVpsInfo | null
results?: CensorcheckResultDto[]
}
@@ -80,6 +81,7 @@ export type CensorcheckInsertRun = {
censorcheckVersion: string | null
summary: CensorcheckSummary
observedSourceIp: string | null
detectedHoster: string | null
results: CensorcheckInsertResult[]
}
@@ -175,6 +177,7 @@ function toRunDto(row: RunRow, includeResults = false): CensorcheckRunDto {
createdAt: row.createdAt,
completedAt: row.completedAt,
observedSourceIp: row.observedSourceIp ?? null,
detectedHoster: row.detectedHoster ?? null,
vps: hydrateVps(row.matchedVpsId ?? null),
}
if (includeResults) {
@@ -248,6 +251,7 @@ export const censorcheckRepository = {
createdAt: now,
completedAt: now,
observedSourceIp: input.observedSourceIp,
detectedHoster: input.detectedHoster,
})
.run()
for (const result of input.results) {
@@ -304,6 +308,7 @@ export const censorcheckRepository = {
like(schema.censorcheckRuns.probePublicIp, pattern),
like(schema.censorcheckRuns.claimedPublicIp, pattern),
like(schema.censorcheckRuns.runId, pattern),
like(schema.censorcheckRuns.detectedHoster, pattern),
)!,
)
}
@@ -334,7 +339,7 @@ export const censorcheckRepository = {
const page = rows.slice(0, limit)
const last = page[page.length - 1]
return {
items: page.map((row) => toRunDto(row, false)),
items: page.map((row) => toRunDto(row, true)),
nextCursor: rows.length > limit && last ? encodeCursor(last.createdAt, last.id) : null,
}
},
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
collectVpsIps,
findVpsIdByIps,
findVpsIdsByIps,
isPrivateOrLoopbackIp,
normalizeIp,
} from './ip-match.js'
@@ -31,6 +32,25 @@ describe('ip-match', () => {
{ id: 'b', ip: '', additionalIps: ['203.0.113.10'] },
]
expect(findVpsIdByIps(all, ['203.0.113.10'])).toBeNull()
expect(findVpsIdsByIps(all, ['203.0.113.10'])).toEqual([])
})
it('собирает оба VPS по разным однозначным IP', () => {
const all = [
{ id: 'a', ip: '203.0.113.10', additionalIps: [] },
{ id: 'b', ip: '203.0.113.20', additionalIps: [] },
]
expect(findVpsIdByIps(all, ['203.0.113.10', '203.0.113.20'])).toBeNull()
expect(findVpsIdsByIps(all, ['203.0.113.10', '203.0.113.20']).sort()).toEqual(['a', 'b'])
})
it('пропускает неоднозначный IP и оставляет однозначные', () => {
const all = [
{ id: 'a', ip: '203.0.113.10', additionalIps: [] },
{ id: 'b', ip: '203.0.113.20', additionalIps: ['203.0.113.10'] },
{ id: 'c', ip: '198.51.100.1', additionalIps: [] },
]
expect(findVpsIdsByIps(all, ['203.0.113.10', '198.51.100.1'])).toEqual(['c'])
})
it('считает loopback и RFC1918 приватными', () => {
+16 -10
View File
@@ -36,18 +36,24 @@ export function collectVpsIps(vps: {
}
export function findVpsIdByIps<T extends VpsIpFields>(allVps: T[], ips: string[]): string | null {
const normalized = [...new Set(ips.map(normalizeIp).filter((ip) => ip && isIpLiteral(ip)))]
if (normalized.length === 0) return null
const ids = findVpsIdsByIps(allVps, ips)
return ids.length === 1 ? ids[0]! : null
}
const matches: string[] = []
for (const v of allVps) {
const vips = collectVpsIps(v)
if (normalized.some((ip) => vips.includes(ip))) {
matches.push(v.id)
}
/**
* Все VPS, у которых IP из списка однозначен (этот IP есть ровно у одного сервера).
* IP, висящий сразу на двух VPS, пропускается.
*/
export function findVpsIdsByIps<T extends VpsIpFields>(allVps: T[], ips: string[]): string[] {
const normalized = [...new Set(ips.map(normalizeIp).filter((ip) => ip && isIpLiteral(ip)))]
if (normalized.length === 0) return []
const result = new Set<string>()
for (const ip of normalized) {
const matches = allVps.filter((v) => collectVpsIps(v).includes(ip))
if (matches.length === 1) result.add(matches[0]!.id)
}
if (matches.length === 1) return matches[0]!
return null
return [...result]
}
function ipv4Octets(ip: string): number[] | null {
+340
View File
@@ -0,0 +1,340 @@
import { and, desc, eq, isNotNull, isNull, like, or, sql } from 'drizzle-orm'
import type {
IpregionGroup,
IpregionRunStatus,
IpregionStatus,
IpregionSummary,
} from '@cfdm/shared/contracts/ipregion'
import { getDb, getSqlite, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
import { generateId } from './utils.js'
import { vpsRepository } from './vps.js'
type RunRow = typeof schema.ipregionRuns.$inferSelect
type ResultRow = typeof schema.ipregionResults.$inferSelect
export type IpregionResultDto = {
id: string
runId: string
serviceKey: string
serviceLabel: string
group: IpregionGroup
countryIpv4: string | null
countryIpv6: string | null
status: IpregionStatus
}
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: IpregionRunStatus
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 type IpregionInsertResult = {
serviceKey: string
serviceLabel: string
group: IpregionGroup
countryIpv4: string | null
countryIpv6: string | null
status: IpregionStatus
}
export type IpregionInsertRun = {
spaceId: string
runId: string
probePublicIp: string
claimedPublicIp: string | null
matchedVpsId: string | null
status: IpregionRunStatus
schemaVersion: number
launcherVersion: string | null
ipregionVersion: string | null
summary: IpregionSummary
observedSourceIp: string | null
detectedHoster: string | null
results: IpregionInsertResult[]
}
export type IpregionHistoryQuery = {
cursor?: string
limit?: number
q?: string
status?: string
matched?: boolean
}
function parseSummary(raw: string | null | undefined): IpregionSummary {
try {
const parsed = raw ? (JSON.parse(raw) as Partial<IpregionSummary>) : {}
return {
total: Number(parsed.total) || 0,
ok: Number(parsed.ok) || 0,
na: Number(parsed.na) || 0,
denied: Number(parsed.denied) || 0,
rate_limit: Number(parsed.rate_limit) || 0,
server_error: Number(parsed.server_error) || 0,
}
} catch {
return {
total: 0,
ok: 0,
na: 0,
denied: 0,
rate_limit: 0,
server_error: 0,
}
}
}
function toResultDto(row: ResultRow): IpregionResultDto {
return {
id: row.id,
runId: row.runId,
serviceKey: row.serviceKey,
serviceLabel: row.serviceLabel,
group: row.serviceGroup as IpregionGroup,
countryIpv4: row.countryIpv4 ?? null,
countryIpv6: row.countryIpv6 ?? null,
status: row.status as IpregionStatus,
}
}
function providerNameById(providerId: string): string {
if (!providerId) return ''
const row = getDb()
.select({ name: schema.providers.name })
.from(schema.providers)
.where(eq(schema.providers.id, providerId))
.get()
return row?.name ?? ''
}
function hydrateVps(matchedVpsId: string | null): IpregionVpsInfo | null {
if (!matchedVpsId) return null
const vps = vpsRepository.getAnySpace(matchedVpsId)
if (!vps) return null
return {
id: vps.id,
ip: vps.ip ?? '',
dns: vps.dns ?? '',
providerId: vps.providerId ?? '',
providerName: providerNameById(vps.providerId ?? ''),
country: vps.country ?? '',
city: vps.city ?? '',
datacenter: vps.datacenter ?? '',
vcpu: Number(vps.vcpu) || 0,
ramGb: Number(vps.ramGb) || 0,
diskGb: Number(vps.diskGb) || 0,
}
}
function toRunDto(row: RunRow, includeResults = false): IpregionRunDto {
const dto: IpregionRunDto = {
id: row.id,
spaceId: row.spaceId,
runId: row.runId,
probePublicIp: row.probePublicIp,
claimedPublicIp: row.claimedPublicIp ?? null,
matchedVpsId: row.matchedVpsId ?? null,
status: row.status as IpregionRunStatus,
schemaVersion: row.schemaVersion,
launcherVersion: row.launcherVersion ?? null,
ipregionVersion: row.ipregionVersion ?? null,
summary: parseSummary(row.summaryJson),
createdAt: row.createdAt,
completedAt: row.completedAt,
observedSourceIp: row.observedSourceIp ?? null,
detectedHoster: row.detectedHoster ?? null,
vps: hydrateVps(row.matchedVpsId ?? null),
}
if (includeResults) {
dto.results = listResults(row.id)
}
return dto
}
function listResults(internalRunId: string): IpregionResultDto[] {
return getDb()
.select()
.from(schema.ipregionResults)
.where(eq(schema.ipregionResults.runId, internalRunId))
.all()
.map(toResultDto)
}
function encodeCursor(createdAt: string, id: string): string {
return Buffer.from(`${createdAt}|${id}`, 'utf8').toString('base64url')
}
function decodeCursor(cursor: string): { createdAt: string; id: string } | null {
try {
const raw = Buffer.from(cursor, 'base64url').toString('utf8')
const idx = raw.indexOf('|')
if (idx <= 0) return null
return { createdAt: raw.slice(0, idx), id: raw.slice(idx + 1) }
} catch {
return null
}
}
export const ipregionRepository = {
getByClientRunId(runId: string): IpregionRunDto | undefined {
const row = getDb()
.select()
.from(schema.ipregionRuns)
.where(eq(schema.ipregionRuns.runId, runId))
.get()
return row ? toRunDto(row, true) : undefined
},
getById(id: string): IpregionRunDto | undefined {
const spaceId = getCurrentSpaceId()
const row = getDb()
.select()
.from(schema.ipregionRuns)
.where(and(eq(schema.ipregionRuns.id, id), eq(schema.ipregionRuns.spaceId, spaceId)))
.get()
return row ? toRunDto(row, true) : undefined
},
create(input: IpregionInsertRun): IpregionRunDto {
const db = getDb()
const now = new Date().toISOString()
const id = generateId('iprun')
db.transaction(() => {
db.insert(schema.ipregionRuns)
.values({
id,
spaceId: input.spaceId,
runId: input.runId,
probePublicIp: input.probePublicIp,
claimedPublicIp: input.claimedPublicIp,
matchedVpsId: input.matchedVpsId,
status: input.status,
schemaVersion: input.schemaVersion,
launcherVersion: input.launcherVersion,
ipregionVersion: input.ipregionVersion,
summaryJson: JSON.stringify(input.summary),
createdAt: now,
completedAt: now,
observedSourceIp: input.observedSourceIp,
detectedHoster: input.detectedHoster,
})
.run()
for (const result of input.results) {
db.insert(schema.ipregionResults)
.values({
id: generateId('ipres'),
runId: id,
serviceKey: result.serviceKey,
serviceLabel: result.serviceLabel,
serviceGroup: result.group,
countryIpv4: result.countryIpv4,
countryIpv6: result.countryIpv6,
status: result.status,
})
.run()
}
})
return this.getByClientRunId(input.runId)!
},
listCurrent(): IpregionRunDto[] {
const spaceId = getCurrentSpaceId()
const sqlite = getSqlite()
const rows = sqlite
.prepare(
`SELECT * FROM ipregion_runs r
WHERE r.spaceId = ?
AND r.id = (
SELECT r2.id FROM ipregion_runs r2
WHERE r2.spaceId = r.spaceId AND r2.probePublicIp = r.probePublicIp
ORDER BY r2.createdAt DESC, r2.id DESC
LIMIT 1
)
ORDER BY r.createdAt DESC`,
)
.all(spaceId) as RunRow[]
return rows.map((row) => toRunDto(row, true))
},
listHistory(query: IpregionHistoryQuery = {}): {
items: IpregionRunDto[]
nextCursor: string | null
} {
const spaceId = getCurrentSpaceId()
const limit = Math.min(Math.max(query.limit ?? 50, 1), 200)
const q = query.q?.trim()
const clauses = [eq(schema.ipregionRuns.spaceId, spaceId)]
if (q) {
const pattern = `%${q}%`
clauses.push(
or(
like(schema.ipregionRuns.probePublicIp, pattern),
like(schema.ipregionRuns.claimedPublicIp, pattern),
like(schema.ipregionRuns.runId, pattern),
like(schema.ipregionRuns.detectedHoster, pattern),
)!,
)
}
if (query.status) {
clauses.push(eq(schema.ipregionRuns.status, query.status))
}
if (query.matched === true) {
clauses.push(isNotNull(schema.ipregionRuns.matchedVpsId))
} else if (query.matched === false) {
clauses.push(isNull(schema.ipregionRuns.matchedVpsId))
}
const cursor = query.cursor ? decodeCursor(query.cursor) : null
if (cursor) {
clauses.push(
sql`(${schema.ipregionRuns.createdAt} < ${cursor.createdAt} OR (${schema.ipregionRuns.createdAt} = ${cursor.createdAt} AND ${schema.ipregionRuns.id} < ${cursor.id}))`,
)
}
const rows = getDb()
.select()
.from(schema.ipregionRuns)
.where(and(...clauses))
.orderBy(desc(schema.ipregionRuns.createdAt), desc(schema.ipregionRuns.id))
.limit(limit + 1)
.all()
const page = rows.slice(0, limit)
const last = page[page.length - 1]
return {
items: page.map((row) => toRunDto(row, true)),
nextCursor: rows.length > limit && last ? encodeCursor(last.createdAt, last.id) : null,
}
},
}
+2
View File
@@ -25,6 +25,8 @@ const ROLE_RANK: Record<SpaceRole, number> = {
/** Tables with spaceId column — purge order (children first). */
const SPACE_DATA_TABLES = [
'vps_grants',
'ipregion_results',
'ipregion_runs',
'censorcheck_results',
'censorcheck_runs',
'notification_log',
@@ -49,6 +49,39 @@ describe('vpsDomainsRepository', () => {
expect(domains).toHaveLength(1)
expect(domains[0]?.fqdn).toBe('vpn.example.com')
expect(domains[0]?.matchStatus).toBe('matched')
expect(domains[0]?.lbMode).toBeNull()
})
it('сохраняет lbMode из binding', () => {
const vps = vpsRepository.create({
ip: '203.0.113.10',
providerId: 'p1',
providerAccountId: 'a1',
status: 'active',
tariffType: 'monthly',
currency: 'RUB',
vcpu: 1,
ramGb: 1,
diskGb: 10,
})
const created = Array.isArray(vps) ? vps[0]! : vps
vpsDomainsRepository.syncBindings([
{
bindingId: 1,
serviceId: 10,
serviceName: 'VPN Node',
serviceSlug: 'vpn-node',
fqdn: 'vpn.example.com',
zoneName: 'example.com',
hostname: 'vpn',
ips: ['203.0.113.10'],
lbMode: 'failover',
},
])
const domains = vpsDomainsRepository.listByVpsId(created.id)
expect(domains[0]?.lbMode).toBe('failover')
})
it('помечает unmatched без совпадения IP', () => {
@@ -147,6 +180,164 @@ describe('vpsDomainsRepository', () => {
const domains = vpsDomainsRepository.listByVpsId(created.id)
expect(domains.map((d) => d.fqdn).sort()).toEqual(['home.rkns.top', 'mhome.rkns.top'])
})
it('привязывает общий FQDN ко всем VPS с однозначными IP', () => {
const vpsA = vpsRepository.create({
ip: '203.0.113.40',
providerId: 'p1',
providerAccountId: 'a1',
status: 'active',
tariffType: 'monthly',
currency: 'RUB',
vcpu: 1,
ramGb: 1,
diskGb: 10,
})
const vpsB = vpsRepository.create({
ip: '203.0.113.41',
providerId: 'p1',
providerAccountId: 'a1',
status: 'active',
tariffType: 'monthly',
currency: 'RUB',
vcpu: 1,
ramGb: 1,
diskGb: 10,
})
const a = Array.isArray(vpsA) ? vpsA[0]! : vpsA
const b = Array.isArray(vpsB) ? vpsB[0]! : vpsB
const result = vpsDomainsRepository.syncBindings([
{
bindingId: 6,
serviceId: 30,
serviceName: 'DNS',
serviceSlug: 'dns',
fqdn: 'dns.example.com',
zoneName: 'example.com',
hostname: 'dns',
ips: ['203.0.113.40', '203.0.113.41'],
},
{
bindingId: 7,
serviceId: 30,
serviceName: 'DNS',
serviceSlug: 'dns',
fqdn: '*.dns.example.com',
zoneName: 'example.com',
hostname: '*.dns',
ips: ['203.0.113.40', '203.0.113.41'],
},
])
expect(result.matched).toBe(2)
expect(result.unmatched).toBe(0)
expect(result.upserted).toBe(4)
expect(vpsDomainsRepository.listUnmatched()).toHaveLength(0)
expect(vpsDomainsRepository.listByVpsId(a.id).map((d) => d.fqdn).sort()).toEqual([
'*.dns.example.com',
'dns.example.com',
])
expect(vpsDomainsRepository.listByVpsId(b.id).map((d) => d.fqdn).sort()).toEqual([
'*.dns.example.com',
'dns.example.com',
])
})
it('привязывает только VPS с совпавшим IP, если второй IP неизвестен', () => {
const vps = vpsRepository.create({
ip: '203.0.113.50',
providerId: 'p1',
providerAccountId: 'a1',
status: 'active',
tariffType: 'monthly',
currency: 'RUB',
vcpu: 1,
ramGb: 1,
diskGb: 10,
})
const created = Array.isArray(vps) ? vps[0]! : vps
const result = vpsDomainsRepository.syncBindings([
{
bindingId: 8,
serviceId: 31,
serviceName: 'DNS',
serviceSlug: 'dns',
fqdn: 'ns.example.com',
zoneName: 'example.com',
hostname: 'ns',
ips: ['203.0.113.50', '198.51.100.9'],
},
])
expect(result.matched).toBe(1)
expect(result.unmatched).toBe(0)
expect(vpsDomainsRepository.listByVpsId(created.id)).toHaveLength(1)
expect(vpsDomainsRepository.listUnmatched()).toHaveLength(0)
})
it('наследует все VPS родителя по CNAME', () => {
const vpsA = vpsRepository.create({
ip: '203.0.113.60',
providerId: 'p1',
providerAccountId: 'a1',
status: 'active',
tariffType: 'monthly',
currency: 'RUB',
vcpu: 1,
ramGb: 1,
diskGb: 10,
})
const vpsB = vpsRepository.create({
ip: '203.0.113.61',
providerId: 'p1',
providerAccountId: 'a1',
status: 'active',
tariffType: 'monthly',
currency: 'RUB',
vcpu: 1,
ramGb: 1,
diskGb: 10,
})
const a = Array.isArray(vpsA) ? vpsA[0]! : vpsA
const b = Array.isArray(vpsB) ? vpsB[0]! : vpsB
const result = vpsDomainsRepository.syncBindings([
{
bindingId: 9,
serviceId: 40,
serviceName: 'DNS',
serviceSlug: 'dns',
fqdn: 'dns.example.com',
zoneName: 'example.com',
hostname: 'dns',
ips: ['203.0.113.60', '203.0.113.61'],
},
{
bindingId: 10,
serviceId: 41,
serviceName: 'Alias',
serviceSlug: 'alias',
fqdn: 'ns.other.example',
zoneName: 'other.example',
hostname: 'ns',
ips: [],
cnameTarget: 'dns.example.com',
},
])
expect(result.matched).toBe(2)
expect(result.unmatched).toBe(0)
expect(vpsDomainsRepository.listByVpsId(a.id).map((d) => d.fqdn).sort()).toEqual([
'dns.example.com',
'ns.other.example',
])
expect(vpsDomainsRepository.listByVpsId(b.id).map((d) => d.fqdn).sort()).toEqual([
'dns.example.com',
'ns.other.example',
])
})
})
describe('settingsRepository integration fields', () => {
+231 -115
View File
@@ -1,13 +1,13 @@
import { and, asc, eq, isNull } from 'drizzle-orm'
// and used in markOrphaned / listUnmatched
import type { CfdmBindingSyncItem } from '@cfdm/shared/contracts/integration-cfdm'
import { getDb, schema } from '../index.js'
import { getCurrentSpaceId } from '../space-context.js'
import { generateId } from './utils.js'
import { findVpsIdByIps, isIpLiteral } from './ip-match.js'
import { findVpsIdsByIps, isIpLiteral } from './ip-match.js'
import { vpsRepository } from './vps.js'
type Row = typeof schema.vpsDomains.$inferSelect
type Db = ReturnType<typeof getDb>
export type VpsDomainDto = Row
@@ -27,33 +27,144 @@ function findVpsIdByDns(
return null
}
function findVpsIdForBinding(
function findVpsIdsForBinding(
allVps: ReturnType<typeof vpsRepository.list>,
item: Pick<CfdmBindingSyncItem, 'ips' | 'fqdn' | 'cnameTarget'>,
): string | null {
return (
findVpsIdByIps(allVps, item.ips) ??
findVpsIdByDns(allVps, item.fqdn) ??
(item.cnameTarget ? findVpsIdByDns(allVps, item.cnameTarget) : null)
)
): string[] {
const byIps = findVpsIdsByIps(allVps, item.ips)
if (byIps.length > 0) return byIps
const byDns = findVpsIdByDns(allVps, item.fqdn)
if (byDns) return [byDns]
if (item.cnameTarget) {
const byCname = findVpsIdByDns(allVps, item.cnameTarget)
if (byCname) return [byCname]
}
return []
}
function resolveMatchStatus(vpsId: string | null): 'matched' | 'unmatched' {
return vpsId ? 'matched' : 'unmatched'
function parseStoredIps(raw: string | null | undefined): string[] {
if (!raw) return []
try {
const parsed: unknown = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []
} catch {
return []
}
}
function applyInheritedMatch(
db: ReturnType<typeof getDb>,
rowId: string,
vpsId: string,
counters: { matched: number; unmatched: number },
): void {
db.update(schema.vpsDomains)
.set({ vpsId, matchStatus: 'matched' })
.where(eq(schema.vpsDomains.id, rowId))
type BindingFields = {
spaceId: string
fqdn: string
zoneName: string
hostname: string
serviceName: string
serviceSlug: string
cfdmServiceId: number
cfdmBindingId: number
source: 'cfdm'
targetIps: string
lbMode: string | null
syncedAt: string
}
function listByCfdmBindingId(bindingId: number): VpsDomainDto[] {
return getDb()
.select()
.from(schema.vpsDomains)
.where(eq(schema.vpsDomains.cfdmBindingId, bindingId))
.all()
}
function deleteAllByCfdmBindingId(bindingId: number): boolean {
const result = getDb()
.delete(schema.vpsDomains)
.where(eq(schema.vpsDomains.cfdmBindingId, bindingId))
.run()
counters.matched++
counters.unmatched = Math.max(0, counters.unmatched - 1)
return result.changes > 0
}
/**
* Одна unmatched-строка (vpsId NULL) либо N matched-строк — по одной на VPS.
* Лишние строки binding удаляются.
*/
function reconcileBindingToVpsIds(
db: Db,
existing: VpsDomainDto[],
fields: BindingFields,
vpsIds: string[],
emptyStatus: 'unmatched' | 'orphaned' = 'unmatched',
): { upserted: number; deleted: number } {
let upserted = 0
let deleted = 0
const keepIds = new Set<string>()
if (vpsIds.length === 0) {
const reusable = existing.find((r) => !r.vpsId) ?? existing[0]
const values = {
...fields,
vpsId: null,
matchStatus: emptyStatus,
}
if (reusable) {
db.update(schema.vpsDomains)
.set(values)
.where(eq(schema.vpsDomains.id, reusable.id))
.run()
keepIds.add(reusable.id)
upserted++
} else {
const id = generateId('vd')
db.insert(schema.vpsDomains).values({ id, ...values }).run()
keepIds.add(id)
upserted++
}
} else {
const byVpsId = new Map<string, VpsDomainDto>()
for (const row of existing) {
if (row.vpsId && !byVpsId.has(row.vpsId)) byVpsId.set(row.vpsId, row)
}
const unmatchedPool = existing.filter((r) => !r.vpsId)
for (const vpsId of vpsIds) {
const values = {
...fields,
vpsId,
matchStatus: 'matched' as const,
}
const row = byVpsId.get(vpsId) ?? unmatchedPool.shift()
if (row) {
db.update(schema.vpsDomains)
.set(values)
.where(eq(schema.vpsDomains.id, row.id))
.run()
keepIds.add(row.id)
} else {
const id = generateId('vd')
db.insert(schema.vpsDomains).values({ id, ...values }).run()
keepIds.add(id)
}
upserted++
}
}
for (const row of existing) {
if (keepIds.has(row.id)) continue
db.delete(schema.vpsDomains).where(eq(schema.vpsDomains.id, row.id)).run()
deleted++
}
return { upserted, deleted }
}
function addToSetMap(map: Map<string, Set<string>>, key: string, ids: string[]): void {
const set = map.get(key) ?? new Set<string>()
for (const id of ids) set.add(id)
map.set(key, set)
}
function addServiceVps(map: Map<number, Set<string>>, serviceId: number, ids: string[]): void {
const set = map.get(serviceId) ?? new Set<string>()
for (const id of ids) set.add(id)
map.set(serviceId, set)
}
export const vpsDomainsRepository = {
@@ -77,18 +188,11 @@ export const vpsDomainsRepository = {
},
getByCfdmBindingId(bindingId: number): VpsDomainDto | undefined {
return getDb()
.select()
.from(schema.vpsDomains)
.where(eq(schema.vpsDomains.cfdmBindingId, bindingId))
.get()
return listByCfdmBindingId(bindingId)[0]
},
deleteByCfdmBindingId(bindingId: number): boolean {
const row = this.getByCfdmBindingId(bindingId)
if (!row) return false
getDb().delete(schema.vpsDomains).where(eq(schema.vpsDomains.id, row.id)).run()
return true
return deleteAllByCfdmBindingId(bindingId)
},
rematchAll(): { updated: number } {
@@ -100,41 +204,51 @@ export const vpsDomainsRepository = {
.from(schema.vpsDomains)
.where(eq(schema.vpsDomains.spaceId, spaceId))
.all()
let updated = 0
const vpsIds = new Set(allVps.map((v) => v.id))
const vpsIdSet = new Set(allVps.map((v) => v.id))
const byBinding = new Map<number, VpsDomainDto[]>()
for (const row of rows) {
let storedIps: string[] = []
try {
storedIps = row.targetIps ? JSON.parse(row.targetIps) : []
} catch {
storedIps = []
}
const group = byBinding.get(row.cfdmBindingId) ?? []
group.push(row)
byBinding.set(row.cfdmBindingId, group)
}
let vpsId = row.vpsId
if (vpsId && !vpsIds.has(vpsId)) {
vpsId = null
let updated = 0
const now = new Date().toISOString()
for (const group of byBinding.values()) {
const seed = group[0]!
const storedIps = parseStoredIps(seed.targetIps)
let wanted = storedIps.length > 0 ? findVpsIdsByIps(allVps, storedIps) : []
if (wanted.length === 0) {
const byDns = findVpsIdByDns(allVps, seed.fqdn)
if (byDns) wanted = [byDns]
}
if (!vpsId && storedIps.length > 0) {
vpsId = findVpsIdByIps(allVps, storedIps)
}
if (!vpsId) {
vpsId = findVpsIdByDns(allVps, row.fqdn)
}
const matchStatus =
vpsId && vpsIds.has(vpsId)
? 'matched'
: row.vpsId && !vpsIds.has(row.vpsId)
? 'orphaned'
: resolveMatchStatus(vpsId)
wanted = wanted.filter((id) => vpsIdSet.has(id))
if (vpsId !== row.vpsId || matchStatus !== row.matchStatus) {
db.update(schema.vpsDomains)
.set({ vpsId, matchStatus })
.where(eq(schema.vpsDomains.id, row.id))
.run()
updated++
const hadMissingVps = group.some((r) => r.vpsId && !vpsIdSet.has(r.vpsId))
const emptyStatus: 'unmatched' | 'orphaned' =
wanted.length === 0 && hadMissingVps ? 'orphaned' : 'unmatched'
const before = new Set(group.map((r) => `${r.vpsId ?? ''}:${r.matchStatus}`))
const fields: BindingFields = {
spaceId,
fqdn: seed.fqdn,
zoneName: seed.zoneName,
hostname: seed.hostname,
serviceName: seed.serviceName,
serviceSlug: seed.serviceSlug,
cfdmServiceId: seed.cfdmServiceId,
cfdmBindingId: seed.cfdmBindingId,
source: 'cfdm',
targetIps: seed.targetIps ?? JSON.stringify(storedIps),
lbMode: seed.lbMode ?? null,
syncedAt: now,
}
const result = reconcileBindingToVpsIds(db, group, fields, wanted, emptyStatus)
const afterRows = listByCfdmBindingId(seed.cfdmBindingId)
const after = new Set(afterRows.map((r) => `${r.vpsId ?? ''}:${r.matchStatus}`))
const same =
before.size === after.size && [...before].every((k) => after.has(k))
if (!same) updated += result.upserted + result.deleted
}
return { updated }
},
@@ -152,94 +266,91 @@ export const vpsDomainsRepository = {
const spaceId = getCurrentSpaceId()
const allVps = vpsRepository.list()
const now = new Date().toISOString()
let matched = 0
let unmatched = 0
let deleted = 0
let upserted = 0
const keptBindingIds = new Set<number>()
const fqdnToVpsId = new Map<string, string>()
const fqdnToVpsIds = new Map<string, Set<string>>()
const serviceMatchedVps = new Map<number, Set<string>>()
const counters = { matched: 0, unmatched: 0 }
const fieldsOf = (item: CfdmBindingSyncItem): BindingFields => ({
spaceId,
fqdn: item.fqdn,
zoneName: item.zoneName,
hostname: item.hostname,
serviceName: item.serviceName,
serviceSlug: item.serviceSlug,
cfdmServiceId: item.serviceId,
cfdmBindingId: item.bindingId,
source: 'cfdm',
targetIps: JSON.stringify(item.ips.filter(isIpLiteral)),
lbMode: item.lbMode ?? null,
syncedAt: now,
})
for (const item of items) {
if (item.deleted) {
if (this.deleteByCfdmBindingId(item.bindingId)) deleted++
if (deleteAllByCfdmBindingId(item.bindingId)) deleted++
continue
}
keptBindingIds.add(item.bindingId)
const vpsId = findVpsIdForBinding(allVps, item)
const matchStatus = resolveMatchStatus(vpsId)
if (matchStatus === 'matched') {
const vpsIds = findVpsIdsForBinding(allVps, item)
if (vpsIds.length > 0) {
counters.matched++
fqdnToVpsId.set(normalizeHost(item.fqdn), vpsId!)
const set = serviceMatchedVps.get(item.serviceId) ?? new Set<string>()
set.add(vpsId!)
serviceMatchedVps.set(item.serviceId, set)
addToSetMap(fqdnToVpsIds, normalizeHost(item.fqdn), vpsIds)
addServiceVps(serviceMatchedVps, item.serviceId, vpsIds)
} else {
counters.unmatched++
}
const existing = this.getByCfdmBindingId(item.bindingId)
const values = {
spaceId,
vpsId,
fqdn: item.fqdn,
zoneName: item.zoneName,
hostname: item.hostname,
serviceName: item.serviceName,
serviceSlug: item.serviceSlug,
cfdmServiceId: item.serviceId,
cfdmBindingId: item.bindingId,
source: 'cfdm' as const,
matchStatus,
targetIps: JSON.stringify(item.ips.filter(isIpLiteral)),
syncedAt: now,
}
if (existing) {
db.update(schema.vpsDomains).set(values).where(eq(schema.vpsDomains.id, existing.id)).run()
} else {
db.insert(schema.vpsDomains).values({ id: generateId('vd'), ...values }).run()
}
upserted++
const result = reconcileBindingToVpsIds(db, listByCfdmBindingId(item.bindingId), fieldsOf(item), vpsIds)
upserted += result.upserted
deleted += result.deleted
}
// CNAME → уже matched FQDN (например imsk → ihome)
let inherited = true
while (inherited) {
inherited = false
for (const item of items) {
if (item.deleted || !item.cnameTarget) continue
const existing = this.getByCfdmBindingId(item.bindingId)
if (!existing || existing.vpsId) continue
const parentVpsId = fqdnToVpsId.get(normalizeHost(item.cnameTarget))
if (!parentVpsId) continue
applyInheritedMatch(db, existing.id, parentVpsId, counters)
fqdnToVpsId.set(normalizeHost(item.fqdn), parentVpsId)
const set = serviceMatchedVps.get(item.serviceId) ?? new Set<string>()
set.add(parentVpsId)
serviceMatchedVps.set(item.serviceId, set)
const existing = listByCfdmBindingId(item.bindingId)
if (existing.some((r) => r.vpsId)) continue
const parentIds = fqdnToVpsIds.get(normalizeHost(item.cnameTarget))
if (!parentIds || parentIds.size === 0) continue
const ids = [...parentIds]
const wasUnmatched = existing.length === 0 || existing.every((r) => !r.vpsId)
const result = reconcileBindingToVpsIds(db, existing, fieldsOf(item), ids)
upserted += result.upserted
deleted += result.deleted
addToSetMap(fqdnToVpsIds, normalizeHost(item.fqdn), ids)
addServiceVps(serviceMatchedVps, item.serviceId, ids)
if (wasUnmatched) {
counters.matched++
counters.unmatched = Math.max(0, counters.unmatched - 1)
}
inherited = true
}
}
// Sibling bindings того же CFDM-сервиса → один уникальный VPS
for (const item of items) {
if (item.deleted) continue
const existing = this.getByCfdmBindingId(item.bindingId)
if (!existing || existing.vpsId) continue
const existing = listByCfdmBindingId(item.bindingId)
if (existing.some((r) => r.vpsId)) continue
const matched = serviceMatchedVps.get(item.serviceId)
if (!matched || matched.size !== 1) continue
const siblingVpsId = [...matched][0]!
applyInheritedMatch(db, existing.id, siblingVpsId, counters)
fqdnToVpsId.set(normalizeHost(item.fqdn), siblingVpsId)
const wasUnmatched = existing.length === 0 || existing.every((r) => !r.vpsId)
const result = reconcileBindingToVpsIds(db, existing, fieldsOf(item), [siblingVpsId])
upserted += result.upserted
deleted += result.deleted
addToSetMap(fqdnToVpsIds, normalizeHost(item.fqdn), [siblingVpsId])
if (wasUnmatched) {
counters.matched++
counters.unmatched = Math.max(0, counters.unmatched - 1)
}
}
matched = counters.matched
unmatched = counters.unmatched
if (opts?.fullSync) {
const rows = db
.select()
@@ -259,7 +370,12 @@ export const vpsDomainsRepository = {
}
}
return { matched, unmatched, deleted, upserted }
return {
matched: counters.matched,
unmatched: counters.unmatched,
deleted,
upserted,
}
},
markOrphanedForMissingBindings(serviceId: number, keptBindingIds: number[]): number {
+94 -3
View File
@@ -258,11 +258,13 @@ const CORE_TABLE_MIGRATIONS: string[] = [
serviceName TEXT NOT NULL,
serviceSlug TEXT NOT NULL,
cfdmServiceId INTEGER NOT NULL,
cfdmBindingId INTEGER NOT NULL UNIQUE,
cfdmBindingId INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT 'cfdm',
matchStatus TEXT NOT NULL DEFAULT 'unmatched',
targetIps TEXT,
syncedAt TEXT NOT NULL
lbMode TEXT,
syncedAt TEXT NOT NULL,
UNIQUE(cfdmBindingId, vpsId)
)`,
`CREATE TABLE IF NOT EXISTS vps_grants (
id TEXT PRIMARY KEY,
@@ -297,7 +299,8 @@ const CORE_TABLE_MIGRATIONS: string[] = [
summaryJson TEXT NOT NULL DEFAULT '{}',
createdAt TEXT NOT NULL,
completedAt TEXT NOT NULL,
observedSourceIp TEXT
observedSourceIp TEXT,
detectedHoster TEXT
)`,
`CREATE TABLE IF NOT EXISTS censorcheck_results (
id TEXT PRIMARY KEY,
@@ -315,6 +318,38 @@ const CORE_TABLE_MIGRATIONS: string[] = [
`CREATE INDEX IF NOT EXISTS censorcheck_runs_created ON censorcheck_runs(createdAt)`,
`CREATE INDEX IF NOT EXISTS censorcheck_results_runId ON censorcheck_results(runId)`,
`CREATE INDEX IF NOT EXISTS censorcheck_results_service_status ON censorcheck_results(serviceKey, status)`,
`CREATE TABLE IF NOT EXISTS ipregion_runs (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main' REFERENCES spaces(id),
runId TEXT NOT NULL UNIQUE,
probePublicIp TEXT NOT NULL,
claimedPublicIp TEXT,
matchedVpsId TEXT REFERENCES vps(id) ON DELETE SET NULL,
status TEXT NOT NULL,
schemaVersion INTEGER NOT NULL DEFAULT 1,
launcherVersion TEXT,
ipregionVersion TEXT,
summaryJson TEXT NOT NULL DEFAULT '{}',
createdAt TEXT NOT NULL,
completedAt TEXT NOT NULL,
observedSourceIp TEXT,
detectedHoster TEXT
)`,
`CREATE TABLE IF NOT EXISTS ipregion_results (
id TEXT PRIMARY KEY,
runId TEXT NOT NULL REFERENCES ipregion_runs(id) ON DELETE CASCADE,
serviceKey TEXT NOT NULL,
serviceLabel TEXT NOT NULL,
"group" TEXT NOT NULL,
countryIpv4 TEXT,
countryIpv6 TEXT,
status TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS ipregion_runs_probe_created ON ipregion_runs(probePublicIp, createdAt)`,
`CREATE INDEX IF NOT EXISTS ipregion_runs_matched_created ON ipregion_runs(matchedVpsId, createdAt)`,
`CREATE INDEX IF NOT EXISTS ipregion_runs_created ON ipregion_runs(createdAt)`,
`CREATE INDEX IF NOT EXISTS ipregion_results_runId ON ipregion_results(runId)`,
`CREATE INDEX IF NOT EXISTS ipregion_results_service_status ON ipregion_results(serviceKey, status)`,
]
/** Additive columns for DBs created before spaces / notifications / etc. */
@@ -342,6 +377,7 @@ const COLUMN_MIGRATIONS: string[] = [
`ALTER TABLE settings ADD COLUMN notifyLowBalanceEnabled INTEGER`,
`ALTER TABLE settings ADD COLUMN notifySyncDigestEnabled INTEGER`,
`ALTER TABLE vps_domains ADD COLUMN targetIps TEXT`,
`ALTER TABLE vps_domains ADD COLUMN lbMode TEXT`,
`ALTER TABLE providers ADD COLUMN spaceId TEXT`,
`ALTER TABLE provider_accounts ADD COLUMN spaceId TEXT`,
`ALTER TABLE provider_accounts ADD COLUMN balance_api REAL`,
@@ -366,6 +402,7 @@ const COLUMN_MIGRATIONS: string[] = [
`ALTER TABLE sync_log ADD COLUMN summary TEXT`,
`ALTER TABLE active_tariffs ADD COLUMN spaceId TEXT`,
`ALTER TABLE tariff_sync_options ADD COLUMN spaceId TEXT`,
`ALTER TABLE censorcheck_runs ADD COLUMN detectedHoster TEXT`,
]
const SPACE_BACKFILL_TABLES = [
@@ -428,6 +465,59 @@ function backfillSpaceIds(sqlite: Database.Database): void {
}
}
function rebuildVpsDomainsBindingUnique(sqlite: Database.Database): void {
const row = sqlite
.prepare(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'vps_domains'`)
.get() as { sql?: string } | undefined
const sql = row?.sql ?? ''
if (!sql) return
if (sql.includes('UNIQUE(cfdmBindingId, vpsId)') || sql.includes('UNIQUE (cfdmBindingId, vpsId)')) {
return
}
const colRows = sqlite.prepare(`PRAGMA table_info(vps_domains)`).all() as { name: string }[]
const hasLbMode = colRows.some((c) => c.name === 'lbMode')
const lbSelect = hasLbMode ? 'lbMode' : 'NULL'
sqlite.exec('PRAGMA foreign_keys = OFF')
sqlite.exec('BEGIN')
try {
sqlite.exec(`CREATE TABLE vps_domains_new (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
vpsId TEXT REFERENCES vps(id) ON DELETE SET NULL,
fqdn TEXT NOT NULL,
zoneName TEXT NOT NULL,
hostname TEXT NOT NULL,
serviceName TEXT NOT NULL,
serviceSlug TEXT NOT NULL,
cfdmServiceId INTEGER NOT NULL,
cfdmBindingId INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT 'cfdm',
matchStatus TEXT NOT NULL DEFAULT 'unmatched',
targetIps TEXT,
lbMode TEXT,
syncedAt TEXT NOT NULL,
UNIQUE(cfdmBindingId, vpsId)
)`)
sqlite.exec(`INSERT INTO vps_domains_new (
id, spaceId, vpsId, fqdn, zoneName, hostname, serviceName, serviceSlug,
cfdmServiceId, cfdmBindingId, source, matchStatus, targetIps, lbMode, syncedAt
) SELECT
id, COALESCE(spaceId, 'space-main'), vpsId, fqdn, zoneName, hostname, serviceName, serviceSlug,
cfdmServiceId, cfdmBindingId, source, matchStatus, targetIps, ${lbSelect}, syncedAt
FROM vps_domains`)
sqlite.exec('DROP TABLE vps_domains')
sqlite.exec('ALTER TABLE vps_domains_new RENAME TO vps_domains')
sqlite.exec('COMMIT')
} catch (err) {
sqlite.exec('ROLLBACK')
throw err
} finally {
sqlite.exec('PRAGMA foreign_keys = ON')
}
}
let migrated = false
export function resetRuntimeMigrate(): void {
@@ -446,6 +536,7 @@ export function ensureRuntimeSchema(sqlite: Database.Database): void {
/* column exists */
}
}
rebuildVpsDomainsBindingUnique(sqlite)
ensureMainSpace(sqlite)
backfillSpaceIds(sqlite)
migrated = true
+77 -19
View File
@@ -213,25 +213,32 @@ export const settings = sqliteTable('settings', {
showQuickActions: integer('showQuickActions'),
})
export const vpsDomains = sqliteTable('vps_domains', {
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
vpsId: text('vpsId').references(() => vps.id, { onDelete: 'set null' }),
fqdn: text('fqdn').notNull(),
zoneName: text('zoneName').notNull(),
hostname: text('hostname').notNull(),
serviceName: text('serviceName').notNull(),
serviceSlug: text('serviceSlug').notNull(),
cfdmServiceId: integer('cfdmServiceId').notNull(),
cfdmBindingId: integer('cfdmBindingId').notNull(),
source: text('source').notNull().default('cfdm'),
matchStatus: text('matchStatus').notNull().default('unmatched'),
targetIps: text('targetIps'),
syncedAt: text('syncedAt').notNull(),
})
export const vpsDomains = sqliteTable(
'vps_domains',
{
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
vpsId: text('vpsId').references(() => vps.id, { onDelete: 'set null' }),
fqdn: text('fqdn').notNull(),
zoneName: text('zoneName').notNull(),
hostname: text('hostname').notNull(),
serviceName: text('serviceName').notNull(),
serviceSlug: text('serviceSlug').notNull(),
cfdmServiceId: integer('cfdmServiceId').notNull(),
cfdmBindingId: integer('cfdmBindingId').notNull(),
source: text('source').notNull().default('cfdm'),
matchStatus: text('matchStatus').notNull().default('unmatched'),
targetIps: text('targetIps'),
lbMode: text('lbMode'),
syncedAt: text('syncedAt').notNull(),
},
(t) => ({
bindingVpsUniq: uniqueIndex('vps_domains_binding_vps').on(t.cfdmBindingId, t.vpsId),
}),
)
export const notificationLog = sqliteTable('notification_log', {
id: text('id').primaryKey(),
@@ -384,6 +391,7 @@ export const censorcheckRuns = sqliteTable(
createdAt: text('createdAt').notNull(),
completedAt: text('completedAt').notNull(),
observedSourceIp: text('observedSourceIp'),
detectedHoster: text('detectedHoster'),
},
(t) => ({
runIdUniq: uniqueIndex('censorcheck_runs_runId').on(t.runId),
@@ -414,4 +422,54 @@ export const censorcheckResults = sqliteTable(
}),
)
export const ipregionRuns = sqliteTable(
'ipregion_runs',
{
id: text('id').primaryKey(),
spaceId: text('spaceId')
.notNull()
.default('space-main')
.references(() => spaces.id),
runId: text('runId').notNull(),
probePublicIp: text('probePublicIp').notNull(),
claimedPublicIp: text('claimedPublicIp'),
matchedVpsId: text('matchedVpsId').references(() => vps.id, { onDelete: 'set null' }),
status: text('status').notNull(),
schemaVersion: integer('schemaVersion').notNull().default(1),
launcherVersion: text('launcherVersion'),
ipregionVersion: text('ipregionVersion'),
summaryJson: text('summaryJson').notNull().default('{}'),
createdAt: text('createdAt').notNull(),
completedAt: text('completedAt').notNull(),
observedSourceIp: text('observedSourceIp'),
detectedHoster: text('detectedHoster'),
},
(t) => ({
runIdUniq: uniqueIndex('ipregion_runs_runId').on(t.runId),
probeCreated: index('ipregion_runs_probe_created').on(t.probePublicIp, t.createdAt),
matchedCreated: index('ipregion_runs_matched_created').on(t.matchedVpsId, t.createdAt),
created: index('ipregion_runs_created').on(t.createdAt),
}),
)
export const ipregionResults = sqliteTable(
'ipregion_results',
{
id: text('id').primaryKey(),
runId: text('runId')
.notNull()
.references(() => ipregionRuns.id, { onDelete: 'cascade' }),
serviceKey: text('serviceKey').notNull(),
serviceLabel: text('serviceLabel').notNull(),
serviceGroup: text('group').notNull(),
countryIpv4: text('countryIpv4'),
countryIpv6: text('countryIpv6'),
status: text('status').notNull(),
},
(t) => ({
runIdx: index('ipregion_results_runId').on(t.runId),
serviceStatus: index('ipregion_results_service_status').on(t.serviceKey, t.status),
}),
)
export const now = sql`(datetime('now'))`
+33 -2
View File
@@ -225,11 +225,12 @@ CREATE TABLE IF NOT EXISTS vps_domains (
serviceName TEXT NOT NULL,
serviceSlug TEXT NOT NULL,
cfdmServiceId INTEGER NOT NULL,
cfdmBindingId INTEGER NOT NULL UNIQUE,
cfdmBindingId INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT 'cfdm',
matchStatus TEXT NOT NULL DEFAULT 'unmatched',
targetIps TEXT,
syncedAt TEXT NOT NULL,
UNIQUE(cfdmBindingId, vpsId),
FOREIGN KEY (vpsId) REFERENCES vps(id) ON DELETE SET NULL
);
@@ -299,7 +300,8 @@ CREATE TABLE IF NOT EXISTS censorcheck_runs (
summaryJson TEXT NOT NULL DEFAULT '{}',
createdAt TEXT NOT NULL,
completedAt TEXT NOT NULL,
observedSourceIp TEXT
observedSourceIp TEXT,
detectedHoster TEXT
);
CREATE TABLE IF NOT EXISTS censorcheck_results (
@@ -313,6 +315,35 @@ CREATE TABLE IF NOT EXISTS censorcheck_results (
detail TEXT,
rawJson TEXT
);
CREATE TABLE IF NOT EXISTS ipregion_runs (
id TEXT PRIMARY KEY,
spaceId TEXT NOT NULL DEFAULT 'space-main',
runId TEXT NOT NULL UNIQUE,
probePublicIp TEXT NOT NULL,
claimedPublicIp TEXT,
matchedVpsId TEXT,
status TEXT NOT NULL,
schemaVersion INTEGER NOT NULL DEFAULT 1,
launcherVersion TEXT,
ipregionVersion TEXT,
summaryJson TEXT NOT NULL DEFAULT '{}',
createdAt TEXT NOT NULL,
completedAt TEXT NOT NULL,
observedSourceIp TEXT,
detectedHoster TEXT
);
CREATE TABLE IF NOT EXISTS ipregion_results (
id TEXT PRIMARY KEY,
runId TEXT NOT NULL,
serviceKey TEXT NOT NULL,
serviceLabel TEXT NOT NULL,
"group" TEXT NOT NULL,
countryIpv4 TEXT,
countryIpv6 TEXT,
status TEXT NOT NULL
);
`
export function resetTestDb(): void {
@@ -77,6 +77,7 @@ export const censorcheckIngestBodySchema = z.object({
runId: z.string().trim().min(8).max(80),
probe: z.object({
publicIp: z.string().trim().min(1).max(64),
hoster: z.string().trim().max(160).optional(),
}),
censorcheck: z
.object({
@@ -112,3 +113,56 @@ export function emptyCensorcheckSummary(): CensorcheckSummary {
error: 0,
}
}
const HOSTER_ALIASES: ReadonlyArray<{ test: RegExp; name: string }> = [
{ test: /digitalocean|digital ocean|\bas14061\b/i, name: 'DigitalOcean' },
{ test: /hetzner|\bas24940\b/i, name: 'Hetzner' },
{ test: /\bovh|\bas16276\b/i, name: 'OVH' },
{ test: /linode|akamai|\bas63949\b/i, name: 'Linode' },
{ test: /\bvultr|\bas20473\b/i, name: 'Vultr' },
{ test: /amazon|aws|\bec2\b|\bas16509\b|\bas14618\b/i, name: 'AWS' },
{ test: /google cloud|\bgcp\b|\bas15169\b/i, name: 'Google Cloud' },
{ test: /microsoft|azure|\bas8075\b/i, name: 'Azure' },
{ test: /oracle cloud|\bas31898\b/i, name: 'Oracle Cloud' },
{ test: /selectel|\bas49505\b/i, name: 'Selectel' },
{ test: /timeweb|\bas197695\b/i, name: 'Timeweb' },
{ test: /vdsina/i, name: 'VDSina' },
{ test: /4vps/i, name: '4VPS' },
{ test: /macloud/i, name: 'Macloud' },
{ test: /veesp/i, name: 'Veesp' },
{ test: /ruvds|ru-vds|ru vds/i, name: 'RUVDS' },
{ test: /\baeza\b/i, name: 'Aeza' },
{ test: /firstvds|firstdedic/i, name: 'FirstVDS' },
{ test: /yandex cloud|\bas200350\b/i, name: 'Yandex Cloud' },
{ test: /cloud\.ru|sbercloud|\bas208677\b/i, name: 'Cloud.ru' },
{ test: /\bbeget\b/i, name: 'Beget' },
{ test: /fornex/i, name: 'Fornex' },
{ test: /adminvps/i, name: 'AdminVPS' },
{ test: /\bihor\b/i, name: 'Ihor' },
{ test: /netcup/i, name: 'Netcup' },
{ test: /contabo/i, name: 'Contabo' },
{ test: /leaseweb/i, name: 'Leaseweb' },
]
function tidyOrgName(value: string): string {
const stripped = value
.replace(/^as\d+\s+/i, '')
.replace(/\s*[,;]?\s*\b(llc|ltd|inc|gmbh|corp|limited|jsc|ooo|ооо|ао)\b\.?$/i, '')
.replace(/\s+/g, ' ')
.trim()
if (!stripped) return value.slice(0, 80)
const words = stripped.split(' ')
if (stripped.length > 42 && words.length > 3) return words.slice(0, 3).join(' ')
return stripped.slice(0, 80)
}
/** Нормализует ASN/org/PTR/cloud-id в короткое имя хостера. */
export function canonicalizeHoster(raw: string | null | undefined): string | null {
const value = raw?.replace(/\s+/g, ' ').trim()
if (!value) return null
const sliced = value.slice(0, 160)
for (const alias of HOSTER_ALIASES) {
if (alias.test.test(sliced)) return alias.name
}
return tidyOrgName(sliced)
}
@@ -1,5 +1,8 @@
import { z } from 'zod'
export const topologyLbModeSchema = z.enum(['round_robin', 'failover', 'weighted'])
export type TopologyLbMode = z.infer<typeof topologyLbModeSchema>
export const cfdmBindingSyncItemSchema = z.object({
bindingId: z.number().int().positive(),
serviceId: z.number().int().positive(),
@@ -11,6 +14,8 @@ export const cfdmBindingSyncItemSchema = z.object({
ips: z.array(z.string()),
/** CNAME-цель (FQDN), если binding — CNAME; для матчинга по dns / цепочке. */
cnameTarget: z.string().optional(),
/** HA-режим binding (fallback — service group). Optional для старых payload. */
lbMode: topologyLbModeSchema.optional(),
deleted: z.boolean().optional(),
})
+178
View File
@@ -0,0 +1,178 @@
import { z } from 'zod'
export const IPREGION_STATUSES = [
'ok',
'na',
'denied',
'rate_limit',
'server_error',
] as const
export type IpregionStatus = (typeof IPREGION_STATUSES)[number]
export const IPREGION_GROUPS = ['primary', 'custom', 'cdn'] as const
export type IpregionGroup = (typeof IPREGION_GROUPS)[number]
export const IPREGION_RUN_STATUSES = ['complete', 'partial'] as const
export type IpregionRunStatus = (typeof IPREGION_RUN_STATUSES)[number]
/** GeoIP-сервисы vernette/ipregion (pin 7d1c25c), display names из JSON. */
export const IPREGION_PRIMARY_SERVICES = [
'maxmind.com',
'rdap.db.ripe.net',
'ipinfo.io',
'cloudflare.com',
'ipregistry.co',
'ipapi.co',
'ifconfig.co',
'ip2location.io',
'iplocation.com',
'country.is',
'geoapify.com',
'geojs.io',
'ipapi.is',
'ipbase.com',
'ipquery.io',
'ipwho.is',
'ip-api.com',
] as const
export const IPREGION_CUSTOM_SERVICES = [
'google',
'youtube',
'twitch',
'chatgpt',
'netflix',
'spotify',
'reddit',
'disney+',
'gemini supported',
'reddit (guest access)',
'youtube premium',
'google search captcha',
'spotify signup',
'disney+ access',
'apple',
'steam',
'tiktok',
'ookla speedtest',
'jetbrains',
'playstation',
'microsoft',
] as const
export const IPREGION_CDN_SERVICES = [
'cloudflare cdn',
'youtube cdn',
'netflix cdn',
] as const
const PRIMARY_SET = new Set<string>(IPREGION_PRIMARY_SERVICES)
const CUSTOM_SET = new Set<string>(IPREGION_CUSTOM_SERVICES)
const CDN_SET = new Set<string>(IPREGION_CDN_SERVICES)
export function inferIpregionGroup(serviceKey: string): IpregionGroup {
const key = serviceKey.trim().toLowerCase()
if (PRIMARY_SET.has(key)) return 'primary'
if (CDN_SET.has(key)) return 'cdn'
if (CUSTOM_SET.has(key)) return 'custom'
return 'custom'
}
export const ipregionStatusSchema = z.enum(IPREGION_STATUSES)
export const ipregionGroupSchema = z.enum(IPREGION_GROUPS)
export const ipregionIngestResultSchema = z.object({
service: z.string().trim().min(1).max(253),
group: ipregionGroupSchema.optional(),
ipv4: z.string().trim().max(64).nullish(),
ipv6: z.string().trim().max(64).nullish(),
})
export const ipregionIngestBodySchema = z.object({
schemaVersion: z.literal(1),
runId: z.string().trim().min(8).max(80),
probe: z.object({
publicIp: z.string().trim().min(1).max(64),
hoster: z.string().trim().max(160).optional(),
}),
ipregion: z
.object({
version: z.string().trim().max(32).optional(),
})
.optional(),
launcherVersion: z.string().trim().max(32).optional(),
results: z.array(ipregionIngestResultSchema).min(1).max(200),
})
export type IpregionIngestBody = z.infer<typeof ipregionIngestBodySchema>
export type IpregionIngestResult = z.infer<typeof ipregionIngestResultSchema>
export type IpregionSummary = {
total: number
ok: number
na: number
denied: number
rate_limit: number
server_error: number
}
export function emptyIpregionSummary(): IpregionSummary {
return {
total: 0,
ok: 0,
na: 0,
denied: 0,
rate_limit: 0,
server_error: 0,
}
}
const ISO_RE = /^[A-Z]{2}$/
const ISO_IATA_RE = /^([A-Z]{2})\s*\(([A-Z]{3})\)$/
const IATA_RE = /^[A-Z]{3}$/
export type CanonicalCountry = {
status: IpregionStatus
country: string | null
iata: string | null
}
/** ISO `RU` / CDN `SE (ARN)` → ok; иначе статусы ipregion (`N/A`, `Denied`, `Rate-limit`, `Server error`). */
export function canonicalizeCountryValue(
raw: string | null | undefined,
): CanonicalCountry {
const value = raw?.replace(/\s+/g, ' ').trim() ?? ''
if (!value || /^null$/i.test(value) || /^n\/a$/i.test(value)) {
return { status: 'na', country: null, iata: null }
}
if (/^denied$/i.test(value)) return { status: 'denied', country: null, iata: null }
if (/^rate[- ]?limit$/i.test(value)) return { status: 'rate_limit', country: null, iata: null }
if (/^server error$/i.test(value)) return { status: 'server_error', country: null, iata: null }
const upper = value.toUpperCase()
const withIata = upper.match(ISO_IATA_RE)
if (withIata) {
return { status: 'ok', country: withIata[1]!, iata: withIata[2]! }
}
if (ISO_RE.test(upper)) {
return { status: 'ok', country: upper, iata: null }
}
const iataOnly = upper.match(/^\(([A-Z]{3})\)$/)
if (iataOnly) {
return { status: 'ok', country: null, iata: iataOnly[1]! }
}
if (IATA_RE.test(upper)) {
return { status: 'ok', country: null, iata: upper }
}
return { status: 'server_error', country: null, iata: null }
}
export function formatCanonicalCountry(parsed: CanonicalCountry): string | null {
if (parsed.country && parsed.iata) return `${parsed.country} (${parsed.iata})`
if (parsed.country) return parsed.country
if (parsed.iata) return parsed.iata
return null
}
+1 -1
View File
@@ -310,7 +310,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
"relative flex min-w-0 w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}