Compare commits
19
Commits
a5f83c6ecb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a64d8b9755 | ||
|
|
5a61ce33b4 | ||
|
|
4dc5609f17 | ||
|
|
29325179f4 | ||
|
|
573ca097aa | ||
|
|
6e401533c7 | ||
|
|
adea0ab070 | ||
|
|
f53ca1ed7d | ||
|
|
d7bcd0d4ec | ||
|
|
ecd053b832 | ||
|
|
72aad059bd | ||
|
|
a9e3cce113 | ||
|
|
c844874b0a | ||
|
|
b03318800f | ||
|
|
7480b9dc11 | ||
|
|
235ae03489 | ||
|
|
60a22b9450 | ||
|
|
efebcaf16d | ||
|
|
9254b8dc22 |
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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' }])
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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'))
|
||||
})
|
||||
}
|
||||
@@ -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}`),
|
||||
]
|
||||
|
||||
@@ -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)} />
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -448,6 +448,37 @@ export const api = {
|
||||
fetchApi<import('@/components/censorcheck/types').CensorcheckRunDto>(
|
||||
`/api/censorcheck/runs/${encodeURIComponent(id)}`,
|
||||
),
|
||||
|
||||
fetchIpregionCurrent: () =>
|
||||
fetchApi<{ items: import('@/components/ipregion/types').IpregionRunDto[] }>(
|
||||
'/api/ipregion/current',
|
||||
),
|
||||
|
||||
fetchIpregionRuns: (params: {
|
||||
cursor?: string
|
||||
limit?: number
|
||||
q?: string
|
||||
status?: string
|
||||
matched?: boolean
|
||||
} = {}) => {
|
||||
const search = new URLSearchParams()
|
||||
if (params.cursor) search.set('cursor', params.cursor)
|
||||
if (params.limit) search.set('limit', String(params.limit))
|
||||
if (params.q) search.set('q', params.q)
|
||||
if (params.status) search.set('status', params.status)
|
||||
if (params.matched === true) search.set('matched', '1')
|
||||
if (params.matched === false) search.set('matched', '0')
|
||||
const qs = search.toString()
|
||||
return fetchApi<{
|
||||
items: import('@/components/ipregion/types').IpregionRunDto[]
|
||||
nextCursor: string | null
|
||||
}>(`/api/ipregion/runs${qs ? `?${qs}` : ''}`)
|
||||
},
|
||||
|
||||
fetchIpregionRun: (id: string) =>
|
||||
fetchApi<import('@/components/ipregion/types').IpregionRunDto>(
|
||||
`/api/ipregion/runs/${encodeURIComponent(id)}`,
|
||||
),
|
||||
}
|
||||
|
||||
export type {
|
||||
|
||||
@@ -235,6 +235,7 @@ export function permissionForPath(pathname: string): string | null {
|
||||
if (
|
||||
pathname.startsWith('/vps') ||
|
||||
pathname.startsWith('/blocking') ||
|
||||
pathname.startsWith('/geo') ||
|
||||
pathname.startsWith('/topology') ||
|
||||
pathname.startsWith('/tariffs') ||
|
||||
pathname.startsWith('/projects') ||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { queryClient } from '../lib/queryClient'
|
||||
import { api } from '../lib/api-client'
|
||||
import { getStoredSpaceId } from '../lib/space'
|
||||
|
||||
export const ipregionKeys = {
|
||||
all: ['ipregion'] as const,
|
||||
current: (spaceId: string | null) => ['ipregion', 'current', spaceId ?? 'default'] as const,
|
||||
history: (spaceId: string | null, params: Record<string, unknown>) =>
|
||||
['ipregion', 'history', spaceId ?? 'default', params] as const,
|
||||
detail: (id: string) => ['ipregion', 'run', id] as const,
|
||||
}
|
||||
|
||||
export const ipregionCurrentQueryOptions = (spaceId?: string | null) => {
|
||||
const id = spaceId === undefined ? getStoredSpaceId() : spaceId
|
||||
return {
|
||||
queryKey: ipregionKeys.current(id),
|
||||
queryFn: () => api.fetchIpregionCurrent(),
|
||||
staleTime: 15_000,
|
||||
}
|
||||
}
|
||||
|
||||
export const ipregionHistoryQueryOptions = (
|
||||
params: { cursor?: string; limit?: number; q?: string; status?: string; matched?: boolean } = {},
|
||||
spaceId?: string | null,
|
||||
) => {
|
||||
const id = spaceId === undefined ? getStoredSpaceId() : spaceId
|
||||
return {
|
||||
queryKey: ipregionKeys.history(id, params),
|
||||
queryFn: () => api.fetchIpregionRuns({ limit: 50, ...params }),
|
||||
staleTime: 15_000,
|
||||
}
|
||||
}
|
||||
|
||||
export const ipregionRunQueryOptions = (id: string | null) => ({
|
||||
queryKey: ipregionKeys.detail(id ?? ''),
|
||||
queryFn: () => api.fetchIpregionRun(id!),
|
||||
enabled: Boolean(id),
|
||||
})
|
||||
|
||||
export { queryClient }
|
||||
@@ -23,6 +23,7 @@ import { Route as AuthRenewalsRouteImport } from './routes/_auth/renewals'
|
||||
import { Route as AuthProvidersRouteImport } from './routes/_auth/providers'
|
||||
import { Route as AuthProjectsRouteImport } from './routes/_auth/projects'
|
||||
import { Route as AuthPaymentsRouteImport } from './routes/_auth/payments'
|
||||
import { Route as AuthGeoRouteImport } from './routes/_auth/geo'
|
||||
import { Route as AuthDashboardRouteImport } from './routes/_auth/dashboard'
|
||||
import { Route as AuthBlockingRouteImport } from './routes/_auth/blocking'
|
||||
import { Route as AuthBalanceRouteImport } from './routes/_auth/balance'
|
||||
@@ -105,6 +106,11 @@ const AuthPaymentsRoute = AuthPaymentsRouteImport.update({
|
||||
path: '/payments',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthGeoRoute = AuthGeoRouteImport.update({
|
||||
id: '/geo',
|
||||
path: '/geo',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthDashboardRoute = AuthDashboardRouteImport.update({
|
||||
id: '/dashboard',
|
||||
path: '/dashboard',
|
||||
@@ -176,6 +182,7 @@ export interface FileRoutesByFullPath {
|
||||
'/balance': typeof AuthBalanceRoute
|
||||
'/blocking': typeof AuthBlockingRoute
|
||||
'/dashboard': typeof AuthDashboardRoute
|
||||
'/geo': typeof AuthGeoRoute
|
||||
'/payments': typeof AuthPaymentsRoute
|
||||
'/projects': typeof AuthProjectsRouteWithChildren
|
||||
'/providers': typeof AuthProvidersRoute
|
||||
@@ -202,6 +209,7 @@ export interface FileRoutesByTo {
|
||||
'/balance': typeof AuthBalanceRoute
|
||||
'/blocking': typeof AuthBlockingRoute
|
||||
'/dashboard': typeof AuthDashboardRoute
|
||||
'/geo': typeof AuthGeoRoute
|
||||
'/payments': typeof AuthPaymentsRoute
|
||||
'/projects': typeof AuthProjectsRouteWithChildren
|
||||
'/providers': typeof AuthProvidersRoute
|
||||
@@ -231,6 +239,7 @@ export interface FileRoutesById {
|
||||
'/_auth/balance': typeof AuthBalanceRoute
|
||||
'/_auth/blocking': typeof AuthBlockingRoute
|
||||
'/_auth/dashboard': typeof AuthDashboardRoute
|
||||
'/_auth/geo': typeof AuthGeoRoute
|
||||
'/_auth/payments': typeof AuthPaymentsRoute
|
||||
'/_auth/projects': typeof AuthProjectsRouteWithChildren
|
||||
'/_auth/providers': typeof AuthProvidersRoute
|
||||
@@ -260,6 +269,7 @@ export interface FileRouteTypes {
|
||||
| '/balance'
|
||||
| '/blocking'
|
||||
| '/dashboard'
|
||||
| '/geo'
|
||||
| '/payments'
|
||||
| '/projects'
|
||||
| '/providers'
|
||||
@@ -286,6 +296,7 @@ export interface FileRouteTypes {
|
||||
| '/balance'
|
||||
| '/blocking'
|
||||
| '/dashboard'
|
||||
| '/geo'
|
||||
| '/payments'
|
||||
| '/projects'
|
||||
| '/providers'
|
||||
@@ -314,6 +325,7 @@ export interface FileRouteTypes {
|
||||
| '/_auth/balance'
|
||||
| '/_auth/blocking'
|
||||
| '/_auth/dashboard'
|
||||
| '/_auth/geo'
|
||||
| '/_auth/payments'
|
||||
| '/_auth/projects'
|
||||
| '/_auth/providers'
|
||||
@@ -440,6 +452,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthPaymentsRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/geo': {
|
||||
id: '/_auth/geo'
|
||||
path: '/geo'
|
||||
fullPath: '/geo'
|
||||
preLoaderRoute: typeof AuthGeoRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/dashboard': {
|
||||
id: '/_auth/dashboard'
|
||||
path: '/dashboard'
|
||||
@@ -574,6 +593,7 @@ interface AuthRouteChildren {
|
||||
AuthBalanceRoute: typeof AuthBalanceRoute
|
||||
AuthBlockingRoute: typeof AuthBlockingRoute
|
||||
AuthDashboardRoute: typeof AuthDashboardRoute
|
||||
AuthGeoRoute: typeof AuthGeoRoute
|
||||
AuthPaymentsRoute: typeof AuthPaymentsRoute
|
||||
AuthProjectsRoute: typeof AuthProjectsRouteWithChildren
|
||||
AuthProvidersRoute: typeof AuthProvidersRoute
|
||||
@@ -594,6 +614,7 @@ const AuthRouteChildren: AuthRouteChildren = {
|
||||
AuthBalanceRoute: AuthBalanceRoute,
|
||||
AuthBlockingRoute: AuthBlockingRoute,
|
||||
AuthDashboardRoute: AuthDashboardRoute,
|
||||
AuthGeoRoute: AuthGeoRoute,
|
||||
AuthPaymentsRoute: AuthPaymentsRoute,
|
||||
AuthProjectsRoute: AuthProjectsRouteWithChildren,
|
||||
AuthProvidersRoute: AuthProvidersRoute,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
< 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>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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={
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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" />}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -154,6 +154,7 @@ export interface VpsDomain {
|
||||
source: string
|
||||
matchStatus: 'matched' | 'unmatched' | 'orphaned'
|
||||
targetIps?: string | null
|
||||
lbMode?: 'round_robin' | 'failover' | 'weighted' | null
|
||||
syncedAt: string
|
||||
}
|
||||
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
Опционально:
|
||||
|
||||
|
||||
@@ -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 приватными', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))`
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user