fix(cabinet): recover "Сервис недоступен" false-positive + top-up fixes

- health probe: tolerant timeout (12s) + retry before flagging the backend down. A
  hardcoded 5s probe racing auth bootstrap falsely showed ServiceUnavailableScreen on
  slow devices / cold mobile connections while the 30s API requests would have
  succeeded ("works on one device, not another on the same Wi-Fi"). The recovery poll
  self-reschedules with the tolerant timeout so slow devices auto-recover.
- TopUpAmount: fetch payment methods with a real query (fixes the infinite spinner on a
  cold cache / browser-back) and use canonical RUB for quick-amount chips so FX rounding
  can't reject a min-amount selection in non-RUB locales.
- settings UI: render secret values as a masked password input (pairs with the backend
  secret masking); leaving the field empty keeps the stored secret.
- deps: npm audit fix (18 -> 5 advisories).

Also bundles in-progress settings env-lock UI work.
This commit is contained in:
c0mrade
2026-06-10 16:15:55 +03:00
parent 75a570b862
commit 16fad9f4fe
14 changed files with 391 additions and 227 deletions

View File

@@ -35,6 +35,11 @@ export interface SettingDefinition {
original: unknown;
has_override: boolean;
read_only: boolean;
// Secret-bearing key (token/secret/password/key). `current`/`original` come back masked
// (••••••••); the UI renders a password input and only sends a value when actually changed.
is_secret?: boolean;
// Pinned in .env: value shadows the DB and can't be edited here (only viewed).
env_locked: boolean;
choices: SettingChoice[];
hint?: SettingHint | null;
}

View File

@@ -1,5 +1,6 @@
import axios from 'axios';
import { useBlockingStore } from '../store/blocking';
import { HEALTH } from '../config/constants';
/**
* Backend liveness probe + "service unavailable" detection.
@@ -44,9 +45,9 @@ const GATEWAY_DOWN_STATUSES = new Set([502, 503, 504]);
* proxy is up but the API upstream is dead, so it counts as NOT reachable.
* Resolves false on a transport-level failure with no response.
*/
export async function pingBackend(): Promise<boolean> {
export async function pingBackend(timeoutMs: number = HEALTH.PROBE_TIMEOUT_MS): Promise<boolean> {
try {
await axios.get(HEALTH_URL, { timeout: 5000 });
await axios.get(HEALTH_URL, { timeout: timeoutMs });
return true;
} catch (err) {
if (axios.isAxiosError(err) && err.response) {
@@ -56,6 +57,22 @@ export async function pingBackend(): Promise<boolean> {
}
}
/**
* Confirm a *real* outage before blanking the app. Probes with the tolerant timeout and
* retries once: returns true only when the backend is unreachable across all attempts. A
* single slow/cold-connection blip on a working backend (common on mobile / in-app webviews)
* must not flip the full-screen ServiceUnavailableScreen.
*/
async function confirmBackendDown(): Promise<boolean> {
for (let attempt = 0; attempt <= HEALTH.CONFIRM_RETRIES; attempt++) {
if (await pingBackend()) return false;
if (attempt < HEALTH.CONFIRM_RETRIES) {
await new Promise((resolve) => setTimeout(resolve, HEALTH.CONFIRM_RETRY_DELAY_MS));
}
}
return true;
}
let everReachedBackend = false;
/** Called on the first successful API response: the app reached the backend at
@@ -99,9 +116,13 @@ export async function reportPossibleBackendDown(): Promise<void> {
if (confirmInFlight) return;
confirmInFlight = true;
try {
const reachable = await pingBackend();
if (!reachable) {
useBlockingStore.getState().setBackendUnavailable();
// Confirm with a tolerant, retried probe so a single cold-connection blip on a working
// backend can't blank an already-loaded session.
if (await confirmBackendDown()) {
// Re-check: a concurrent request may have reached the backend during the probe.
if (useBlockingStore.getState().blockingType !== 'backend_unavailable') {
useBlockingStore.getState().setBackendUnavailable();
}
}
} finally {
confirmInFlight = false;
@@ -110,17 +131,22 @@ export async function reportPossibleBackendDown(): Promise<void> {
/**
* Eager liveness check fired once at app launch (from main.tsx), in parallel
* with auth bootstrap. If the backend is already down at launch this paints the
* ServiceUnavailableScreen immediately — before the auth flow can flash the
* /login page — even on the no-stored-token path that makes no early request.
* No-op if the app has already reached the backend or another blocking screen
* is showing.
* with auth bootstrap. If the backend is genuinely down at launch this paints the
* ServiceUnavailableScreen — before the auth flow can flash the /login page — even
* on the no-stored-token path that makes no early request.
*
* Uses the tolerant, retried confirm probe (NOT a single short-timeout ping): the old
* hardcoded 5s probe falsely flagged slow devices / cold mobile connections as
* "service unavailable" while the real 30s API requests racing alongside it would have
* succeeded — the exact "works on one device, not another on the same Wi-Fi" report.
* No-op if the app has already reached the backend or another blocking screen is showing.
*/
export async function checkBackendOnStartup(): Promise<void> {
if (everReachedBackend) return;
if (useBlockingStore.getState().blockingType !== null) return;
const reachable = await pingBackend();
if (!reachable && !everReachedBackend && useBlockingStore.getState().blockingType === null) {
useBlockingStore.getState().setBackendUnavailable();
if (await confirmBackendDown()) {
if (!everReachedBackend && useBlockingStore.getState().blockingType === null) {
useBlockingStore.getState().setBackendUnavailable();
}
}
}