diff --git a/app/app/Http/Controllers/Api/AdminDashboardController.php b/app/app/Http/Controllers/Api/AdminDashboardController.php index fd86bdd2..758c947b 100644 --- a/app/app/Http/Controllers/Api/AdminDashboardController.php +++ b/app/app/Http/Controllers/Api/AdminDashboardController.php @@ -407,7 +407,7 @@ class AdminDashboardController extends Controller /** Известные сервисы плитки — фильтр от осиротевших строк (jivosite и пр.). */ private const KNOWN_SERVICE_KEYS = [ 'dadata', 'supplier', 'yandex_cloud', 'email', 'yookassa', 'captcha', - 'aitunnel', 'exa', 'xfetch', 'self_render', 'sales_finder', + 'aitunnel', 'exa', 'xfetch', 'self_render', 'sales_finder', 'exa_tunnel', ]; /** diff --git a/app/app/Services/External/ExaTunnelLivenessProbe.php b/app/app/Services/External/ExaTunnelLivenessProbe.php new file mode 100644 index 00000000..cdc12483 --- /dev/null +++ b/app/app/Services/External/ExaTunnelLivenessProbe.php @@ -0,0 +1,42 @@ + $proxy])->timeout(8)->get($url); + + return $resp->ok() + ? LivenessReading::alive('exa_tunnel', 'тоннель жив') + : LivenessReading::down('exa_tunnel', 'HTTP '.$resp->status()); + } catch (\Throwable $e) { + return LivenessReading::down('exa_tunnel', $e->getMessage()); + } + } +} diff --git a/app/app/Services/External/ExternalBalanceRefresher.php b/app/app/Services/External/ExternalBalanceRefresher.php index da0633d5..4b2c7a54 100644 --- a/app/app/Services/External/ExternalBalanceRefresher.php +++ b/app/app/Services/External/ExternalBalanceRefresher.php @@ -53,6 +53,7 @@ class ExternalBalanceRefresher 'captcha' => ['kind' => 'liveness', 'heavy' => false, 'class' => CaptchaLivenessProbe::class], 'self_render' => ['kind' => 'liveness', 'heavy' => false, 'class' => SelfRenderLivenessProbe::class], 'sales_finder' => ['kind' => 'liveness', 'heavy' => false, 'class' => SalesFinderLivenessProbe::class], + 'exa_tunnel' => ['kind' => 'liveness', 'heavy' => false, 'class' => ExaTunnelLivenessProbe::class], ]; } @@ -218,6 +219,7 @@ class ExternalBalanceRefresher app(CaptchaLivenessProbe::class), app(SelfRenderLivenessProbe::class), app(SalesFinderLivenessProbe::class), + app(ExaTunnelLivenessProbe::class), ]; } diff --git a/app/config/services.php b/app/config/services.php index bf7ceb76..e13f10a8 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -128,6 +128,9 @@ return [ 'balance_url' => env('EXA_BALANCE_URL'), 'red_floor_rub' => (int) env('EXA_RED_FLOOR_RUB', 500), 'amber_floor_rub' => (int) env('EXA_AMBER_FLOOR_RUB', 2000), + // Плитка балансов (16.07): нейтральный адрес для проверки живости SSH-тоннеля + // (запрос через exa.proxy на него). Не EXA — чтобы отделить «тоннель жив» от «EXA жив». + 'tunnel_check_url' => env('EXA_TUNNEL_CHECK_URL', 'https://api.ipify.org'), ], // Плитка балансов (28.06): Yandex Cloud биллинг (серверы + Managed PG ~18к/мес). diff --git a/app/resources/js/views/admin/AdminDashboardView.vue b/app/resources/js/views/admin/AdminDashboardView.vue index 148a25a9..dc25ef09 100644 --- a/app/resources/js/views/admin/AdminDashboardView.vue +++ b/app/resources/js/views/admin/AdminDashboardView.vue @@ -109,6 +109,7 @@ const SERVICE_LABELS: Record = { email: 'Почта', yookassa: 'ЮKassa', captcha: 'Капча', + exa_tunnel: 'EXA-тоннель', aitunnel: 'AITUNNEL', exa: 'EXA', xfetch: 'xfetch', @@ -122,6 +123,7 @@ const SERVICE_ICONS: Record = { email: '✉️', yookassa: '💳', captcha: '🛡', + exa_tunnel: '🔌', aitunnel: '🤖', exa: '🔎', xfetch: '🌐', @@ -142,7 +144,7 @@ function daysLeftLabel(days: number | null): string { } /** Сервисы БЕЗ денежного баланса — следим только за живостью (не за деньгами). */ -const LIVENESS_ONLY_KEYS = new Set(['yookassa', 'captcha', 'self_render', 'sales_finder']); +const LIVENESS_ONLY_KEYS = new Set(['yookassa', 'captcha', 'self_render', 'sales_finder', 'exa_tunnel']); function isLivenessOnly(key: string): boolean { return LIVENESS_ONLY_KEYS.has(key); } diff --git a/app/tests/Unit/External/ExaTunnelLivenessProbeTest.php b/app/tests/Unit/External/ExaTunnelLivenessProbeTest.php new file mode 100644 index 00000000..e219ff4a --- /dev/null +++ b/app/tests/Unit/External/ExaTunnelLivenessProbeTest.php @@ -0,0 +1,34 @@ +set('services.exa.proxy', 'socks5h://127.0.0.1:10808'); + config()->set('services.exa.tunnel_check_url', 'https://api.ipify.org'); +}); + +it('серый, когда прокси-тоннель не настроен', function () { + config()->set('services.exa.proxy', ''); + expect((new ExaTunnelLivenessProbe)->check()->light)->toBe('grey'); +}); + +it('зелёный при 200 через прокси (тоннель жив)', function () { + Http::fake(['api.ipify.org*' => Http::response('1.2.3.4', 200)]); + $r = (new ExaTunnelLivenessProbe)->check(); + expect($r->serviceKey)->toBe('exa_tunnel'); + expect($r->light)->toBe('green'); +}); + +it('красный при 500 и при сетевой ошибке (тоннель лёг)', function () { + Http::fake(['api.ipify.org*' => Http::response('err', 500)]); + expect((new ExaTunnelLivenessProbe)->check()->light)->toBe('red'); + + Http::fake(fn () => throw new RuntimeException('tunnel down')); + expect((new ExaTunnelLivenessProbe)->check()->light)->toBe('red'); +}); diff --git a/app/tests/Unit/External/ExternalBalanceRefresherTest.php b/app/tests/Unit/External/ExternalBalanceRefresherTest.php index ef04f173..bd7d59f3 100644 --- a/app/tests/Unit/External/ExternalBalanceRefresherTest.php +++ b/app/tests/Unit/External/ExternalBalanceRefresherTest.php @@ -101,7 +101,7 @@ it('sendAlerts=true шлёт письмо на переход green→red (св Mail::assertSent(ExternalServiceDownMail::class, 1); }); -it('allKeys содержит 11 сервисов и refresh(allKeys) их пишет', function () { +it('allKeys содержит 12 сервисов и refresh(allKeys) их пишет', function () { config()->set('services.aitunnel.key', 'k'); config()->set('services.aitunnel.balance_url', ''); config()->set('services.exa.key', 'k'); @@ -110,13 +110,14 @@ it('allKeys содержит 11 сервисов и refresh(allKeys) их пиш config()->set('services.xfetch.balance_url', ''); config()->set('services.self_render.health_url', 'https://vm.local/h'); config()->set('services.sales_finder.base_url', 'https://lk.liderra.ru/finder/'); + config()->set('services.exa.proxy', 'socks5h://127.0.0.1:10808'); Http::fake(['*' => Http::response('ok', 200)]); $refresher = app(ExternalBalanceRefresher::class); - expect($refresher->allKeys())->toHaveCount(11); + expect($refresher->allKeys())->toHaveCount(12); $refresher->refresh($refresher->allKeys(), force: true, sendAlerts: false); $keys = DB::connection('pgsql_supplier')->table('external_service_balances')->pluck('service_key'); - foreach (['aitunnel', 'exa', 'xfetch', 'self_render', 'sales_finder'] as $k) { + foreach (['aitunnel', 'exa', 'xfetch', 'self_render', 'sales_finder', 'exa_tunnel'] as $k) { expect($keys)->toContain($k); } });