Merge pull request #1501 from debpalash/fix/remote-backend-recovery-1496
fix: recover from unreachable remote backends
This commit is contained in:
@@ -54,6 +54,7 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
### Fixed
|
||||
|
||||
- Leaving a screen while its waveform is still loading no longer opens a bug-report prompt for a normal cancelled request. (#1498)
|
||||
- An unreachable remote backend now opens a retryable recovery screen instead of sending the app into local model setup, with clear TLS, CORS, network, HTTP, and wrong-port guidance — thanks @debpalash! (#1501)
|
||||
- Linux production test launches now stop their own extracted AppImage before resetting SQLite and logs. (#1494)
|
||||
- Restored the pre-release version to 0.4.2 while the next release remains in preparation. (#1488)
|
||||
- Large multi-language dubbing batches now use compact searchable language and track managers instead of overflowing the editor. (#1492)
|
||||
|
||||
+35
-1
@@ -46,6 +46,7 @@ import FloatingPill from './components/FloatingPill';
|
||||
import GlobalAudioPlayer from './components/GlobalAudioPlayer';
|
||||
import BackendCrashNotice from './components/BackendCrashNotice';
|
||||
import BackendStartFailureNotice from './components/BackendStartFailureNotice';
|
||||
import RemoteBackendRecovery from './components/RemoteBackendRecovery';
|
||||
import AnalyticsConsentBanner from './components/AnalyticsConsentBanner';
|
||||
import LanguageSwitchPrompt from './components/LanguageSwitchPrompt';
|
||||
import { initAnalyticsFromConsent } from './utils/analytics';
|
||||
@@ -72,6 +73,7 @@ import { toastErrorWithReport } from './utils/errorToast';
|
||||
import { listenDictationNotice, showDictationNotice } from './utils/dictationNotice';
|
||||
import { addBreadcrumb } from './utils/breadcrumbs';
|
||||
import { appShellClasses } from './utils/appShellClasses';
|
||||
import { configuredRemoteBackend, probeRemoteBackend } from './utils/remoteBackendProbe';
|
||||
import { applyUiScale } from './utils/uiScaleEngine';
|
||||
import { recordValueMoment } from './utils/donationMoments';
|
||||
import {
|
||||
@@ -567,6 +569,7 @@ function App() {
|
||||
const setActiveProject = useAppStore((s) => s.setActiveProject);
|
||||
const sidebarTab = useAppStore((s) => s.sidebarTab);
|
||||
const setSidebarTab = useAppStore((s) => s.setSidebarTab);
|
||||
const openSettingsTab = useAppStore((s) => s.openSettingsTab);
|
||||
|
||||
// Snap sidebar to a valid tab when view changes
|
||||
useEffect(() => {
|
||||
@@ -591,6 +594,17 @@ function App() {
|
||||
// the studio in front of a user who actually needs the wizard.
|
||||
const [setupNeeded, setSetupNeeded] = useState(false);
|
||||
const [setupChecked, setSetupChecked] = useState(false);
|
||||
const [remoteFailure, setRemoteFailure] = useState(null);
|
||||
const [remoteProbeAttempt, setRemoteProbeAttempt] = useState(0);
|
||||
const retryRemoteBackend = useCallback(() => {
|
||||
setRemoteFailure(null);
|
||||
setSetupChecked(false);
|
||||
setRemoteProbeAttempt((attempt) => attempt + 1);
|
||||
}, []);
|
||||
const openRemoteBackendSettings = useCallback(() => {
|
||||
setRemoteFailure(null);
|
||||
openSettingsTab('sharing');
|
||||
}, [openSettingsTab]);
|
||||
useEffect(() => {
|
||||
// Gate the probe on the bootstrap being 'ready' — before that there is
|
||||
// no backend to answer. Probing from mount burned the 30-attempt ceiling
|
||||
@@ -601,6 +615,15 @@ function App() {
|
||||
if (bootstrapStage !== 'ready') return undefined;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const remote = configuredRemoteBackend();
|
||||
if (remote) {
|
||||
const result = await probeRemoteBackend(remote.url, remote.key);
|
||||
if (cancelled) return;
|
||||
setRemoteFailure(result.ok ? null : result);
|
||||
setSetupNeeded(false);
|
||||
setSetupChecked(true);
|
||||
return;
|
||||
}
|
||||
const { setupStatus } = await import('./api/setup');
|
||||
// ~30 attempts × ~1s ≈ 30s ceiling; enough for a cold sidecar on slow disks.
|
||||
for (let attempt = 0; attempt < 30 && !cancelled; attempt++) {
|
||||
@@ -619,7 +642,7 @@ function App() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [bootstrapStage]);
|
||||
}, [bootstrapStage, remoteProbeAttempt]);
|
||||
|
||||
// ── First sound ──
|
||||
// Onboarding should end with the product doing the thing: the moment the
|
||||
@@ -1194,6 +1217,17 @@ function App() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (remoteFailure) {
|
||||
return (
|
||||
<div className="app-bootstrap-scale" style={{ '--ui-scale': uiScale }}>
|
||||
<RemoteBackendRecovery
|
||||
failure={remoteFailure}
|
||||
onRetry={retryRemoteBackend}
|
||||
onOpenSettings={openRemoteBackendSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (setupNeeded && bootstrapStage === 'ready') {
|
||||
// Render outside the `app-container` grid so the wizard spans the full
|
||||
// viewport instead of getting squeezed into whatever grid cell the
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ServerOff } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../ui';
|
||||
import { disableRemoteBackend } from '../utils/remoteBackendProbe';
|
||||
|
||||
const DETAIL_KEYS = {
|
||||
tls: 'settings.remote_backend_error_tls',
|
||||
cors: 'settings.remote_backend_error_cors',
|
||||
network: 'settings.remote_backend_error_network',
|
||||
timeout: 'settings.remote_backend_error_timeout',
|
||||
http: 'settings.remote_backend_error_http',
|
||||
wrong_port: 'settings.remote_backend_error_wrong_port',
|
||||
};
|
||||
|
||||
export default function RemoteBackendRecovery({
|
||||
failure,
|
||||
onRetry,
|
||||
onOpenSettings,
|
||||
reload = () => location.reload(),
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const detail = t(DETAIL_KEYS[failure.kind], { status: failure.status });
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-bg px-4">
|
||||
<section className="w-full max-w-lg rounded-xl bg-bg-elev-1 p-6 shadow-xl" role="alert">
|
||||
<ServerOff size={28} aria-hidden="true" className="mb-3 text-danger" />
|
||||
<h1 className="m-0 text-lg font-semibold">{t('settings.remote_backend_recovery_title')}</h1>
|
||||
<p className="text-sm text-fg-muted">{detail}</p>
|
||||
<code className="block break-all text-xs text-fg-subtle">{failure.target}</code>
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<Button variant="primary" onClick={onRetry}>
|
||||
{t('bootstrap.retry')}
|
||||
</Button>
|
||||
<Button variant="subtle" onClick={() => disableRemoteBackend(reload)}>
|
||||
{t('settings.remote_backend_use_local')}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onOpenSettings}>
|
||||
{t('nav.settings')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mb-0 mt-4 text-xs text-fg-subtle">
|
||||
{t('settings.remote_backend_recovery_hint')}
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import RemoteBackendRecovery from './RemoteBackendRecovery';
|
||||
|
||||
describe('RemoteBackendRecovery', () => {
|
||||
it('explains the classified failure and supports retry', () => {
|
||||
const onRetry = vi.fn();
|
||||
const onOpenSettings = vi.fn();
|
||||
render(
|
||||
<RemoteBackendRecovery
|
||||
failure={{
|
||||
ok: false,
|
||||
kind: 'wrong_port',
|
||||
target: 'https://gpu-box:7443',
|
||||
}}
|
||||
onRetry={onRetry}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('7443');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }));
|
||||
expect(onOpenSettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('clears the remote URL and API key before reloading locally', () => {
|
||||
localStorage.setItem('ov_backend_url', 'https://gpu-box:3900');
|
||||
localStorage.setItem('ov_api_key', 'secret');
|
||||
const reload = vi.fn();
|
||||
render(
|
||||
<RemoteBackendRecovery
|
||||
failure={{ ok: false, kind: 'tls', target: 'https://gpu-box:3900' }}
|
||||
onRetry={vi.fn()}
|
||||
onOpenSettings={vi.fn()}
|
||||
reload={reload}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Use local backend' }));
|
||||
expect(localStorage.getItem('ov_backend_url')).toBeNull();
|
||||
expect(localStorage.getItem('ov_api_key')).toBeNull();
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import toast from 'react-hot-toast';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { LS_BACKEND_URL, LS_API_KEY, API } from '../../api/client';
|
||||
import { askConfirm } from '../../utils/dialog';
|
||||
import { disableRemoteBackend, probeRemoteBackend } from '../../utils/remoteBackendProbe';
|
||||
import { SettingsSection, SettingRow, InfoHint, SettingsInput } from './primitives';
|
||||
import { Button, Badge } from '../../ui';
|
||||
|
||||
@@ -30,7 +31,13 @@ export function isValidBackendUrl(value) {
|
||||
if (!value) return false;
|
||||
try {
|
||||
const u = new URL(value);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:';
|
||||
return (
|
||||
(u.protocol === 'http:' || u.protocol === 'https:') &&
|
||||
!u.username &&
|
||||
!u.password &&
|
||||
!u.search &&
|
||||
!u.hash
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -42,6 +49,7 @@ export default function RemoteBackendPanel({ reload = () => window.location.relo
|
||||
const [key, setKey] = useState(() => localStorage.getItem(LS_API_KEY) || '');
|
||||
const [probe, setProbe] = useState(null); // {ok, detail, target}
|
||||
const [testing, setTesting] = useState(false);
|
||||
const hasSavedRemote = Boolean(localStorage.getItem(LS_BACKEND_URL));
|
||||
|
||||
const normalized = url.trim().replace(/\/+$/, '');
|
||||
|
||||
@@ -50,23 +58,7 @@ export default function RemoteBackendPanel({ reload = () => window.location.relo
|
||||
setProbe(null);
|
||||
const target = normalized || API;
|
||||
try {
|
||||
const res = await fetch(`${target}/health`, {
|
||||
headers: key.trim() ? { Authorization: `Bearer ${key.trim()}` } : {},
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body?.detail || `HTTP ${res.status}`);
|
||||
setProbe({
|
||||
ok: true,
|
||||
detail: `${body.version || '?'} on ${body.device || '?'}`,
|
||||
target,
|
||||
});
|
||||
} catch (e) {
|
||||
setProbe({
|
||||
ok: false,
|
||||
detail:
|
||||
e?.message || t('settings.remote_backend_unreachable', { defaultValue: 'unreachable' }),
|
||||
target,
|
||||
});
|
||||
setProbe(await probeRemoteBackend(target, key.trim()));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
@@ -172,6 +164,16 @@ export default function RemoteBackendPanel({ reload = () => window.location.relo
|
||||
<Button variant="subtle" size="sm" onClick={onSave} data-testid="remote-backend-save">
|
||||
{t('settings.remote_backend_save', { defaultValue: 'Save & reload' })}
|
||||
</Button>
|
||||
{hasSavedRemote && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => disableRemoteBackend(reload)}
|
||||
data-testid="remote-backend-disable"
|
||||
>
|
||||
{t('settings.remote_backend_use_local')}
|
||||
</Button>
|
||||
)}
|
||||
{probe && (
|
||||
<Badge tone={probe.ok ? 'success' : 'danger'} dot role="status">
|
||||
{probe.ok
|
||||
@@ -180,7 +182,9 @@ export default function RemoteBackendPanel({ reload = () => window.location.relo
|
||||
defaultValue: 'OK — {{detail}}',
|
||||
})
|
||||
: t('settings.remote_backend_probe_fail', {
|
||||
detail: probe.detail,
|
||||
detail: t(`settings.remote_backend_error_${probe.kind}`, {
|
||||
status: probe.status,
|
||||
}),
|
||||
defaultValue: 'Failed — {{detail}}',
|
||||
})}
|
||||
</Badge>
|
||||
|
||||
@@ -19,6 +19,9 @@ vi.mock('../../utils/dialog', () => ({ askConfirm }));
|
||||
import toast from 'react-hot-toast';
|
||||
import RemoteBackendPanel, { isValidBackendUrl } from './RemoteBackendPanel';
|
||||
|
||||
const healthResponse = (version) =>
|
||||
new Response(JSON.stringify({ status: 'ok', version, device: 'cuda' }), { status: 200 });
|
||||
|
||||
describe('isValidBackendUrl', () => {
|
||||
it('accepts absolute http(s) URLs only', () => {
|
||||
expect(isValidBackendUrl('http://gpu-box:3900')).toBe(true);
|
||||
@@ -28,6 +31,8 @@ describe('isValidBackendUrl', () => {
|
||||
expect(isValidBackendUrl('gpu-box:3900')).toBe(false);
|
||||
expect(isValidBackendUrl('not a url')).toBe(false);
|
||||
expect(isValidBackendUrl('ftp://gpu-box')).toBe(false);
|
||||
expect(isValidBackendUrl('https://user:secret@gpu-box:3900')).toBe(false);
|
||||
expect(isValidBackendUrl('https://gpu-box:3900?key=secret')).toBe(false);
|
||||
expect(isValidBackendUrl('')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -79,10 +84,7 @@ describe('RemoteBackendPanel', () => {
|
||||
});
|
||||
|
||||
it('skips the confirmation when the exact URL passed a connection test', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ version: '0.3.15', device: 'cuda' }),
|
||||
});
|
||||
global.fetch = vi.fn().mockResolvedValue(healthResponse('0.3.15'));
|
||||
render(<RemoteBackendPanel reload={reload} />);
|
||||
setUrl('http://gpu-box:3900');
|
||||
|
||||
@@ -95,6 +97,32 @@ describe('RemoteBackendPanel', () => {
|
||||
expect(localStorage.getItem('ov_backend_url')).toBe('http://gpu-box:3900');
|
||||
});
|
||||
|
||||
it('classifies a wrong 7443 service and succeeds when retried with the HTTP API', async () => {
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||
.mockResolvedValueOnce(healthResponse('0.4.2'));
|
||||
render(<RemoteBackendPanel reload={reload} />);
|
||||
setUrl('https://gpu-box:7443');
|
||||
fireEvent.click(screen.getByTestId('remote-backend-test'));
|
||||
await screen.findByText(/7443/);
|
||||
|
||||
setUrl('http://gpu-box:3900');
|
||||
fireEvent.click(screen.getByTestId('remote-backend-test'));
|
||||
await screen.findByText('OK — 0.4.2 on cuda');
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('offers an explicit disable action that clears the remote URL and key', () => {
|
||||
localStorage.setItem('ov_backend_url', 'http://old-box:3900');
|
||||
localStorage.setItem('ov_api_key', 'secret');
|
||||
render(<RemoteBackendPanel reload={reload} />);
|
||||
fireEvent.click(screen.getByTestId('remote-backend-disable'));
|
||||
expect(localStorage.getItem('ov_backend_url')).toBeNull();
|
||||
expect(localStorage.getItem('ov_api_key')).toBeNull();
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('clears both settings and reloads without confirmation when the URL is emptied', async () => {
|
||||
localStorage.setItem('ov_backend_url', 'http://old-box:3900');
|
||||
localStorage.setItem('ov_api_key', 'k');
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
"workspaces": "مساحات العمل"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_recovery_title": "تعذّر الوصول إلى الواجهة الخلفية البعيدة",
|
||||
"remote_backend_recovery_hint": "يمكنك تغيير الواجهة الخلفية البعيدة لاحقًا من الإعدادات ← المشاركة.",
|
||||
"remote_backend_use_local": "استخدام الواجهة الخلفية المحلية",
|
||||
"remote_backend_error_tls": "تعذّر التحقق من الاتصال الآمن. تحقّق من الشهادة واستخدم منفذ HTTP API الخاص بـ VoiceStudio.",
|
||||
"remote_backend_error_cors": "حظرت الواجهة الخلفية البعيدة هذا التطبيق. اسمح بمصدر التطبيق في إعدادات CORS.",
|
||||
"remote_backend_error_network": "تعذّر الوصول إلى الواجهة الخلفية البعيدة. تحقّق من العنوان والشبكة وجدار الحماية.",
|
||||
"remote_backend_error_timeout": "لم تستجب الواجهة الخلفية البعيدة في الوقت المحدد.",
|
||||
"remote_backend_error_http": "أعادت الواجهة الخلفية البعيدة حالة HTTP {{status}}.",
|
||||
"remote_backend_error_wrong_port": "لا تبدو هذه واجهة VoiceStudio HTTP API. المنفذ 7443 مخصص عادةً للتحكم بالعامل البعيد؛ جرّب المنفذ 3900.",
|
||||
"openapi": "واجهة برمجة تطبيقات VoiceStudio",
|
||||
"general": "عام",
|
||||
"theme": "المظهر",
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
"workspaces": "Arbeitsbereiche"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_recovery_title": "Remote-Backend nicht erreichbar",
|
||||
"remote_backend_recovery_hint": "Du kannst das Remote-Backend später unter Einstellungen → Freigabe ändern.",
|
||||
"remote_backend_use_local": "Lokales Backend verwenden",
|
||||
"remote_backend_error_tls": "Die sichere Verbindung konnte nicht überprüft werden. Prüfe das Zertifikat und verwende den HTTP-API-Port von VoiceStudio.",
|
||||
"remote_backend_error_cors": "Das Remote-Backend hat diese App blockiert. Erlaube den Ursprung der App in den CORS-Einstellungen.",
|
||||
"remote_backend_error_network": "Das Remote-Backend ist nicht erreichbar. Prüfe Adresse, Netzwerk und Firewall.",
|
||||
"remote_backend_error_timeout": "Das Remote-Backend hat nicht rechtzeitig geantwortet.",
|
||||
"remote_backend_error_http": "Das Remote-Backend hat HTTP {{status}} zurückgegeben.",
|
||||
"remote_backend_error_wrong_port": "Dies scheint nicht die VoiceStudio-HTTP-API zu sein. Port 7443 ist normalerweise der Steuerport des Remote-Workers; versuche Port 3900.",
|
||||
"openapi": "VoiceStudio-API",
|
||||
"general": "Allgemein",
|
||||
"theme": "Theme",
|
||||
|
||||
@@ -466,6 +466,15 @@
|
||||
"remote_backend_invalid_url": "Enter a valid URL starting with http:// or https:// (e.g. http://gpu-box:3900).",
|
||||
"remote_backend_confirm_unverified": "This backend URL hasn't passed a connection test. Save it and reload anyway? If it's wrong, the app can't reach any backend until you change it back here.",
|
||||
"remote_backend_confirm_title": "Use unverified backend?",
|
||||
"remote_backend_recovery_title": "Can't reach the remote backend",
|
||||
"remote_backend_recovery_hint": "You can change the remote backend later in Settings → Sharing.",
|
||||
"remote_backend_use_local": "Use local backend",
|
||||
"remote_backend_error_tls": "The secure connection could not be verified. Check the certificate and use the VoiceStudio HTTP API port.",
|
||||
"remote_backend_error_cors": "The remote backend blocked this app. Allow this app's origin in the backend CORS settings.",
|
||||
"remote_backend_error_network": "The remote backend could not be reached. Check its address, network, and firewall.",
|
||||
"remote_backend_error_timeout": "The remote backend did not respond in time.",
|
||||
"remote_backend_error_http": "The remote backend returned HTTP {{status}}.",
|
||||
"remote_backend_error_wrong_port": "This does not look like the VoiceStudio HTTP API. Port 7443 is usually the remote-worker control port; try port 3900.",
|
||||
"ffmpeg": "FFmpeg",
|
||||
"ffmpeg_found": "Found",
|
||||
"ffmpeg_missing": "Not found",
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
"workspaces": "Espacios de trabajo"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_recovery_title": "No se puede acceder al backend remoto",
|
||||
"remote_backend_recovery_hint": "Puedes cambiar el backend remoto más tarde en Ajustes → Compartir.",
|
||||
"remote_backend_use_local": "Usar backend local",
|
||||
"remote_backend_error_tls": "No se pudo verificar la conexión segura. Revisa el certificado y usa el puerto de la API HTTP de VoiceStudio.",
|
||||
"remote_backend_error_cors": "El backend remoto bloqueó esta aplicación. Permite el origen de la aplicación en la configuración CORS.",
|
||||
"remote_backend_error_network": "No se pudo acceder al backend remoto. Revisa la dirección, la red y el cortafuegos.",
|
||||
"remote_backend_error_timeout": "El backend remoto no respondió a tiempo.",
|
||||
"remote_backend_error_http": "El backend remoto devolvió HTTP {{status}}.",
|
||||
"remote_backend_error_wrong_port": "Esto no parece la API HTTP de VoiceStudio. El puerto 7443 suele ser el puerto de control del trabajador remoto; prueba el 3900.",
|
||||
"openapi": "API de VoiceStudio",
|
||||
"general": "General",
|
||||
"theme": "Tema",
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
"workspaces": "Espaces de travail"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_recovery_title": "Impossible de joindre le backend distant",
|
||||
"remote_backend_recovery_hint": "Vous pourrez modifier le backend distant plus tard dans Réglages → Partage.",
|
||||
"remote_backend_use_local": "Utiliser le backend local",
|
||||
"remote_backend_error_tls": "La connexion sécurisée n’a pas pu être vérifiée. Vérifiez le certificat et utilisez le port de l’API HTTP VoiceStudio.",
|
||||
"remote_backend_error_cors": "Le backend distant a bloqué cette application. Autorisez l’origine de l’application dans les réglages CORS.",
|
||||
"remote_backend_error_network": "Le backend distant est inaccessible. Vérifiez son adresse, le réseau et le pare-feu.",
|
||||
"remote_backend_error_timeout": "Le backend distant n’a pas répondu à temps.",
|
||||
"remote_backend_error_http": "Le backend distant a renvoyé HTTP {{status}}.",
|
||||
"remote_backend_error_wrong_port": "Ce service ne semble pas être l’API HTTP VoiceStudio. Le port 7443 sert généralement au contrôle du worker distant ; essayez le port 3900.",
|
||||
"openapi": "API VoiceStudio",
|
||||
"general": "Général",
|
||||
"theme": "Thème",
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
"workspaces": "वर्कस्पेस"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_recovery_title": "रिमोट बैकएंड से संपर्क नहीं हो सका",
|
||||
"remote_backend_recovery_hint": "आप बाद में सेटिंग्स → शेयरिंग में रिमोट बैकएंड बदल सकते हैं।",
|
||||
"remote_backend_use_local": "लोकल बैकएंड इस्तेमाल करें",
|
||||
"remote_backend_error_tls": "सुरक्षित कनेक्शन सत्यापित नहीं हो सका। प्रमाणपत्र जाँचें और VoiceStudio HTTP API पोर्ट इस्तेमाल करें।",
|
||||
"remote_backend_error_cors": "रिमोट बैकएंड ने इस ऐप को रोक दिया। CORS सेटिंग्स में ऐप के ओरिजिन को अनुमति दें।",
|
||||
"remote_backend_error_network": "रिमोट बैकएंड तक पहुँचा नहीं जा सका। पता, नेटवर्क और फ़ायरवॉल जाँचें।",
|
||||
"remote_backend_error_timeout": "रिमोट बैकएंड ने समय पर जवाब नहीं दिया।",
|
||||
"remote_backend_error_http": "रिमोट बैकएंड ने HTTP {{status}} लौटाया।",
|
||||
"remote_backend_error_wrong_port": "यह VoiceStudio HTTP API नहीं लगता। पोर्ट 7443 आम तौर पर रिमोट वर्कर नियंत्रण पोर्ट है; पोर्ट 3900 आज़माएँ।",
|
||||
"openapi": "VoiceStudio API",
|
||||
"general": "सामान्य",
|
||||
"theme": "थीम",
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
"workspaces": "Ruang kerja"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_recovery_title": "Backend jarak jauh tidak dapat dijangkau",
|
||||
"remote_backend_recovery_hint": "Anda dapat mengubah backend jarak jauh nanti di Pengaturan → Berbagi.",
|
||||
"remote_backend_use_local": "Gunakan backend lokal",
|
||||
"remote_backend_error_tls": "Koneksi aman tidak dapat diverifikasi. Periksa sertifikat dan gunakan port API HTTP VoiceStudio.",
|
||||
"remote_backend_error_cors": "Backend jarak jauh memblokir aplikasi ini. Izinkan origin aplikasi di pengaturan CORS.",
|
||||
"remote_backend_error_network": "Backend jarak jauh tidak dapat dijangkau. Periksa alamat, jaringan, dan firewall.",
|
||||
"remote_backend_error_timeout": "Backend jarak jauh tidak merespons tepat waktu.",
|
||||
"remote_backend_error_http": "Backend jarak jauh mengembalikan HTTP {{status}}.",
|
||||
"remote_backend_error_wrong_port": "Layanan ini tampaknya bukan API HTTP VoiceStudio. Port 7443 biasanya merupakan port kontrol pekerja jarak jauh; coba port 3900.",
|
||||
"openapi": "API VoiceStudio",
|
||||
"general": "Umum",
|
||||
"theme": "Tema",
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
"workspaces": "Aree di lavoro"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_recovery_title": "Impossibile raggiungere il backend remoto",
|
||||
"remote_backend_recovery_hint": "Puoi modificare il backend remoto in seguito in Impostazioni → Condivisione.",
|
||||
"remote_backend_use_local": "Usa il backend locale",
|
||||
"remote_backend_error_tls": "Impossibile verificare la connessione sicura. Controlla il certificato e usa la porta dell’API HTTP di VoiceStudio.",
|
||||
"remote_backend_error_cors": "Il backend remoto ha bloccato questa app. Consenti l’origine dell’app nelle impostazioni CORS.",
|
||||
"remote_backend_error_network": "Impossibile raggiungere il backend remoto. Controlla indirizzo, rete e firewall.",
|
||||
"remote_backend_error_timeout": "Il backend remoto non ha risposto in tempo.",
|
||||
"remote_backend_error_http": "Il backend remoto ha restituito HTTP {{status}}.",
|
||||
"remote_backend_error_wrong_port": "Questo servizio non sembra l’API HTTP di VoiceStudio. La porta 7443 è solitamente la porta di controllo del worker remoto; prova la porta 3900.",
|
||||
"openapi": "API VoiceStudio",
|
||||
"general": "Generale",
|
||||
"theme": "Tema",
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
"workspaces": "ワークスペース"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_recovery_title": "リモートバックエンドに接続できません",
|
||||
"remote_backend_recovery_hint": "リモートバックエンドは後で「設定」→「共有」から変更できます。",
|
||||
"remote_backend_use_local": "ローカルバックエンドを使用",
|
||||
"remote_backend_error_tls": "安全な接続を検証できませんでした。証明書を確認し、VoiceStudio HTTP API のポートを使用してください。",
|
||||
"remote_backend_error_cors": "リモートバックエンドがこのアプリをブロックしました。CORS 設定でアプリのオリジンを許可してください。",
|
||||
"remote_backend_error_network": "リモートバックエンドに到達できません。アドレス、ネットワーク、ファイアウォールを確認してください。",
|
||||
"remote_backend_error_timeout": "リモートバックエンドが時間内に応答しませんでした。",
|
||||
"remote_backend_error_http": "リモートバックエンドが HTTP {{status}} を返しました。",
|
||||
"remote_backend_error_wrong_port": "VoiceStudio HTTP API ではないようです。ポート 7443 は通常リモートワーカーの制御用です。ポート 3900 をお試しください。",
|
||||
"openapi": "VoiceStudio API",
|
||||
"general": "一般",
|
||||
"theme": "テーマ",
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
"workspaces": "작업 공간"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_recovery_title": "원격 백엔드에 연결할 수 없음",
|
||||
"remote_backend_recovery_hint": "나중에 설정 → 공유에서 원격 백엔드를 변경할 수 있습니다.",
|
||||
"remote_backend_use_local": "로컬 백엔드 사용",
|
||||
"remote_backend_error_tls": "보안 연결을 확인할 수 없습니다. 인증서를 확인하고 VoiceStudio HTTP API 포트를 사용하세요.",
|
||||
"remote_backend_error_cors": "원격 백엔드가 이 앱을 차단했습니다. CORS 설정에서 앱의 출처를 허용하세요.",
|
||||
"remote_backend_error_network": "원격 백엔드에 연결할 수 없습니다. 주소, 네트워크 및 방화벽을 확인하세요.",
|
||||
"remote_backend_error_timeout": "원격 백엔드가 제시간에 응답하지 않았습니다.",
|
||||
"remote_backend_error_http": "원격 백엔드가 HTTP {{status}}을(를) 반환했습니다.",
|
||||
"remote_backend_error_wrong_port": "VoiceStudio HTTP API가 아닌 것 같습니다. 포트 7443은 일반적으로 원격 워커 제어 포트입니다. 포트 3900을 사용해 보세요.",
|
||||
"openapi": "VoiceStudio API",
|
||||
"general": "일반",
|
||||
"theme": "테마",
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
"workspaces": "Werkruimtes"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_recovery_title": "Kan de externe backend niet bereiken",
|
||||
"remote_backend_recovery_hint": "Je kunt de externe backend later wijzigen via Instellingen → Delen.",
|
||||
"remote_backend_use_local": "Lokale backend gebruiken",
|
||||
"remote_backend_error_tls": "De beveiligde verbinding kon niet worden geverifieerd. Controleer het certificaat en gebruik de HTTP-API-poort van VoiceStudio.",
|
||||
"remote_backend_error_cors": "De externe backend heeft deze app geblokkeerd. Sta de oorsprong van de app toe in de CORS-instellingen.",
|
||||
"remote_backend_error_network": "De externe backend is niet bereikbaar. Controleer het adres, netwerk en de firewall.",
|
||||
"remote_backend_error_timeout": "De externe backend reageerde niet op tijd.",
|
||||
"remote_backend_error_http": "De externe backend gaf HTTP {{status}} terug.",
|
||||
"remote_backend_error_wrong_port": "Dit lijkt niet de VoiceStudio HTTP-API. Poort 7443 is meestal de besturingspoort van de externe worker; probeer poort 3900.",
|
||||
"openapi": "VoiceStudio-API",
|
||||
"general": "Algemeen",
|
||||
"theme": "Thema",
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
"workspaces": "Obszary robocze"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_recovery_title": "Nie można połączyć się ze zdalnym backendem",
|
||||
"remote_backend_recovery_hint": "Zdalny backend możesz później zmienić w Ustawienia → Udostępnianie.",
|
||||
"remote_backend_use_local": "Użyj lokalnego backendu",
|
||||
"remote_backend_error_tls": "Nie udało się zweryfikować bezpiecznego połączenia. Sprawdź certyfikat i użyj portu HTTP API VoiceStudio.",
|
||||
"remote_backend_error_cors": "Zdalny backend zablokował tę aplikację. Zezwól na źródło aplikacji w ustawieniach CORS.",
|
||||
"remote_backend_error_network": "Nie można połączyć się ze zdalnym backendem. Sprawdź adres, sieć i zaporę.",
|
||||
"remote_backend_error_timeout": "Zdalny backend nie odpowiedział na czas.",
|
||||
"remote_backend_error_http": "Zdalny backend zwrócił HTTP {{status}}.",
|
||||
"remote_backend_error_wrong_port": "To nie wygląda na HTTP API VoiceStudio. Port 7443 zwykle służy do sterowania zdalnym workerem; spróbuj portu 3900.",
|
||||
"openapi": "API VoiceStudio",
|
||||
"general": "Ogólne",
|
||||
"theme": "Motyw",
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
"workspaces": "Áreas de trabalho"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_recovery_title": "Não foi possível acessar o backend remoto",
|
||||
"remote_backend_recovery_hint": "Você pode alterar o backend remoto depois em Configurações → Compartilhamento.",
|
||||
"remote_backend_use_local": "Usar backend local",
|
||||
"remote_backend_error_tls": "Não foi possível verificar a conexão segura. Confira o certificado e use a porta da API HTTP do VoiceStudio.",
|
||||
"remote_backend_error_cors": "O backend remoto bloqueou este aplicativo. Permita a origem do aplicativo nas configurações de CORS.",
|
||||
"remote_backend_error_network": "Não foi possível acessar o backend remoto. Confira o endereço, a rede e o firewall.",
|
||||
"remote_backend_error_timeout": "O backend remoto não respondeu a tempo.",
|
||||
"remote_backend_error_http": "O backend remoto retornou HTTP {{status}}.",
|
||||
"remote_backend_error_wrong_port": "Este serviço não parece ser a API HTTP do VoiceStudio. A porta 7443 geralmente controla o worker remoto; tente a porta 3900.",
|
||||
"openapi": "API do VoiceStudio",
|
||||
"general": "Geral",
|
||||
"theme": "Tema",
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
"workspaces": "Рабочие пространства"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_recovery_title": "Не удалось подключиться к удалённому бэкенду",
|
||||
"remote_backend_recovery_hint": "Позже удалённый бэкенд можно изменить в разделе «Настройки → Общий доступ».",
|
||||
"remote_backend_use_local": "Использовать локальный бэкенд",
|
||||
"remote_backend_error_tls": "Не удалось проверить защищённое соединение. Проверьте сертификат и используйте порт HTTP API VoiceStudio.",
|
||||
"remote_backend_error_cors": "Удалённый бэкенд заблокировал приложение. Разрешите источник приложения в настройках CORS.",
|
||||
"remote_backend_error_network": "Удалённый бэкенд недоступен. Проверьте адрес, сеть и брандмауэр.",
|
||||
"remote_backend_error_timeout": "Удалённый бэкенд не ответил вовремя.",
|
||||
"remote_backend_error_http": "Удалённый бэкенд вернул HTTP {{status}}.",
|
||||
"remote_backend_error_wrong_port": "Похоже, это не HTTP API VoiceStudio. Порт 7443 обычно служит для управления удалённым worker; попробуйте порт 3900.",
|
||||
"openapi": "API VoiceStudio",
|
||||
"general": "Общие",
|
||||
"theme": "Тема",
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
"workspaces": "Arbetsytor"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_recovery_title": "Det går inte att nå fjärrbackend",
|
||||
"remote_backend_recovery_hint": "Du kan ändra fjärrbackend senare under Inställningar → Delning.",
|
||||
"remote_backend_use_local": "Använd lokal backend",
|
||||
"remote_backend_error_tls": "Den säkra anslutningen kunde inte verifieras. Kontrollera certifikatet och använd VoiceStudios HTTP API-port.",
|
||||
"remote_backend_error_cors": "Fjärrbackend blockerade appen. Tillåt appens ursprung i CORS-inställningarna.",
|
||||
"remote_backend_error_network": "Fjärrbackend kunde inte nås. Kontrollera adressen, nätverket och brandväggen.",
|
||||
"remote_backend_error_timeout": "Fjärrbackend svarade inte i tid.",
|
||||
"remote_backend_error_http": "Fjärrbackend returnerade HTTP {{status}}.",
|
||||
"remote_backend_error_wrong_port": "Det här verkar inte vara VoiceStudios HTTP API. Port 7443 är vanligtvis kontrollporten för fjärrworkern; prova port 3900.",
|
||||
"openapi": "VoiceStudio API",
|
||||
"general": "Allmänt",
|
||||
"theme": "Tema",
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
"workspaces": "พื้นที่ทำงาน"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_recovery_title": "ไม่สามารถเชื่อมต่อแบ็กเอนด์ระยะไกลได้",
|
||||
"remote_backend_recovery_hint": "คุณเปลี่ยนแบ็กเอนด์ระยะไกลภายหลังได้ที่ การตั้งค่า → การแชร์",
|
||||
"remote_backend_use_local": "ใช้แบ็กเอนด์ในเครื่อง",
|
||||
"remote_backend_error_tls": "ไม่สามารถยืนยันการเชื่อมต่อที่ปลอดภัยได้ โปรดตรวจสอบใบรับรองและใช้พอร์ต HTTP API ของ VoiceStudio",
|
||||
"remote_backend_error_cors": "แบ็กเอนด์ระยะไกลบล็อกแอปนี้ โปรดอนุญาตต้นทางของแอปในการตั้งค่า CORS",
|
||||
"remote_backend_error_network": "ไม่สามารถเข้าถึงแบ็กเอนด์ระยะไกลได้ โปรดตรวจสอบที่อยู่ เครือข่าย และไฟร์วอลล์",
|
||||
"remote_backend_error_timeout": "แบ็กเอนด์ระยะไกลไม่ตอบสนองภายในเวลาที่กำหนด",
|
||||
"remote_backend_error_http": "แบ็กเอนด์ระยะไกลส่งคืน HTTP {{status}}",
|
||||
"remote_backend_error_wrong_port": "บริการนี้ดูเหมือนไม่ใช่ HTTP API ของ VoiceStudio โดยปกติพอร์ต 7443 ใช้ควบคุมเวิร์กเกอร์ระยะไกล โปรดลองพอร์ต 3900",
|
||||
"openapi": "API ของ VoiceStudio",
|
||||
"general": "ทั่วไป",
|
||||
"theme": "ธีม",
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
"workspaces": "Çalışma alanları"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_recovery_title": "Uzak arka uca ulaşılamıyor",
|
||||
"remote_backend_recovery_hint": "Uzak arka ucu daha sonra Ayarlar → Paylaşım bölümünden değiştirebilirsiniz.",
|
||||
"remote_backend_use_local": "Yerel arka ucu kullan",
|
||||
"remote_backend_error_tls": "Güvenli bağlantı doğrulanamadı. Sertifikayı kontrol edin ve VoiceStudio HTTP API bağlantı noktasını kullanın.",
|
||||
"remote_backend_error_cors": "Uzak arka uç bu uygulamayı engelledi. CORS ayarlarında uygulamanın kaynağına izin verin.",
|
||||
"remote_backend_error_network": "Uzak arka uca ulaşılamadı. Adresi, ağı ve güvenlik duvarını kontrol edin.",
|
||||
"remote_backend_error_timeout": "Uzak arka uç zamanında yanıt vermedi.",
|
||||
"remote_backend_error_http": "Uzak arka uç HTTP {{status}} döndürdü.",
|
||||
"remote_backend_error_wrong_port": "Bu hizmet VoiceStudio HTTP API gibi görünmüyor. 7443 bağlantı noktası genellikle uzak worker denetimi içindir; 3900 bağlantı noktasını deneyin.",
|
||||
"openapi": "VoiceStudio API",
|
||||
"general": "Genel",
|
||||
"theme": "Tema",
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
"workspaces": "Робочі простори"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_recovery_title": "Не вдалося підключитися до віддаленого бекенду",
|
||||
"remote_backend_recovery_hint": "Згодом віддалений бекенд можна змінити в Налаштування → Спільний доступ.",
|
||||
"remote_backend_use_local": "Використовувати локальний бекенд",
|
||||
"remote_backend_error_tls": "Не вдалося перевірити захищене з’єднання. Перевірте сертифікат і використовуйте порт HTTP API VoiceStudio.",
|
||||
"remote_backend_error_cors": "Віддалений бекенд заблокував застосунок. Дозвольте джерело застосунку в налаштуваннях CORS.",
|
||||
"remote_backend_error_network": "Віддалений бекенд недоступний. Перевірте адресу, мережу та брандмауер.",
|
||||
"remote_backend_error_timeout": "Віддалений бекенд не відповів вчасно.",
|
||||
"remote_backend_error_http": "Віддалений бекенд повернув HTTP {{status}}.",
|
||||
"remote_backend_error_wrong_port": "Схоже, це не HTTP API VoiceStudio. Порт 7443 зазвичай керує віддаленим worker; спробуйте порт 3900.",
|
||||
"openapi": "API VoiceStudio",
|
||||
"general": "Загальні",
|
||||
"theme": "Тема",
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
"workspaces": "Không gian làm việc"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_recovery_title": "Không thể kết nối với backend từ xa",
|
||||
"remote_backend_recovery_hint": "Bạn có thể đổi backend từ xa sau trong Cài đặt → Chia sẻ.",
|
||||
"remote_backend_use_local": "Dùng backend cục bộ",
|
||||
"remote_backend_error_tls": "Không thể xác minh kết nối bảo mật. Hãy kiểm tra chứng chỉ và dùng cổng HTTP API của VoiceStudio.",
|
||||
"remote_backend_error_cors": "Backend từ xa đã chặn ứng dụng này. Hãy cho phép nguồn của ứng dụng trong cài đặt CORS.",
|
||||
"remote_backend_error_network": "Không thể truy cập backend từ xa. Hãy kiểm tra địa chỉ, mạng và tường lửa.",
|
||||
"remote_backend_error_timeout": "Backend từ xa không phản hồi kịp thời.",
|
||||
"remote_backend_error_http": "Backend từ xa trả về HTTP {{status}}.",
|
||||
"remote_backend_error_wrong_port": "Dịch vụ này có vẻ không phải HTTP API của VoiceStudio. Cổng 7443 thường dùng để điều khiển worker từ xa; hãy thử cổng 3900.",
|
||||
"openapi": "API VoiceStudio",
|
||||
"general": "Chung",
|
||||
"theme": "Giao diện",
|
||||
|
||||
@@ -250,6 +250,15 @@
|
||||
"seed_reroll_hint": "滚动一个新的随机种子并保留它"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_recovery_title": "无法连接远程后端",
|
||||
"remote_backend_recovery_hint": "你可以稍后在“设置 → 共享”中更改远程后端。",
|
||||
"remote_backend_use_local": "使用本地后端",
|
||||
"remote_backend_error_tls": "无法验证安全连接。请检查证书并使用 VoiceStudio HTTP API 端口。",
|
||||
"remote_backend_error_cors": "远程后端阻止了此应用。请在 CORS 设置中允许此应用的来源。",
|
||||
"remote_backend_error_network": "无法访问远程后端。请检查地址、网络和防火墙。",
|
||||
"remote_backend_error_timeout": "远程后端未及时响应。",
|
||||
"remote_backend_error_http": "远程后端返回了 HTTP {{status}}。",
|
||||
"remote_backend_error_wrong_port": "这似乎不是 VoiceStudio HTTP API。端口 7443 通常用于远程工作进程控制;请尝试端口 3900。",
|
||||
"openapi": "VoiceStudio API",
|
||||
"title": "设置",
|
||||
"general": "通用",
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
"workspaces": "工作區"
|
||||
},
|
||||
"settings": {
|
||||
"remote_backend_recovery_title": "無法連線至遠端後端",
|
||||
"remote_backend_recovery_hint": "你可以稍後在「設定 → 分享」中變更遠端後端。",
|
||||
"remote_backend_use_local": "使用本機後端",
|
||||
"remote_backend_error_tls": "無法驗證安全連線。請檢查憑證並使用 VoiceStudio HTTP API 連接埠。",
|
||||
"remote_backend_error_cors": "遠端後端封鎖了此應用程式。請在 CORS 設定中允許此應用程式的來源。",
|
||||
"remote_backend_error_network": "無法存取遠端後端。請檢查位址、網路和防火牆。",
|
||||
"remote_backend_error_timeout": "遠端後端未及時回應。",
|
||||
"remote_backend_error_http": "遠端後端回傳 HTTP {{status}}。",
|
||||
"remote_backend_error_wrong_port": "這似乎不是 VoiceStudio HTTP API。連接埠 7443 通常用於遠端工作程序控制;請嘗試連接埠 3900。",
|
||||
"openapi": "VoiceStudio API",
|
||||
"general": "一般",
|
||||
"theme": "佈景主題",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import appSource from '../App.jsx?raw';
|
||||
|
||||
describe('App remote-backend startup gate', () => {
|
||||
it('probes a configured remote before the local setup-status path', () => {
|
||||
const remoteProbe = appSource.indexOf('const remote = configuredRemoteBackend()');
|
||||
const localSetup = appSource.indexOf("import('./api/setup')", remoteProbe);
|
||||
expect(remoteProbe).toBeGreaterThan(-1);
|
||||
expect(localSetup).toBeGreaterThan(remoteProbe);
|
||||
expect(appSource.slice(remoteProbe, localSetup)).toContain('probeRemoteBackend');
|
||||
expect(appSource.slice(remoteProbe, localSetup)).toContain('return;');
|
||||
});
|
||||
|
||||
it('renders recovery before it can route to the local SetupWizard', () => {
|
||||
const recovery = appSource.indexOf('if (remoteFailure)');
|
||||
const wizard = appSource.indexOf("if (setupNeeded && bootstrapStage === 'ready')", recovery);
|
||||
expect(recovery).toBeGreaterThan(-1);
|
||||
expect(wizard).toBeGreaterThan(recovery);
|
||||
expect(appSource.slice(recovery, wizard)).toContain('RemoteBackendRecovery');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
configuredRemoteBackend,
|
||||
disableRemoteBackend,
|
||||
probeRemoteBackend,
|
||||
} from './remoteBackendProbe';
|
||||
|
||||
const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
|
||||
|
||||
describe('remote backend probe', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('reads a configured remote without exposing its key in the target', () => {
|
||||
localStorage.setItem('ov_backend_url', 'https://gpu-box:3900/');
|
||||
localStorage.setItem('ov_api_key', 'secret');
|
||||
expect(configuredRemoteBackend()).toEqual({ url: 'https://gpu-box:3900', key: 'secret' });
|
||||
});
|
||||
|
||||
it('probes the exact health endpoint with authentication', async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(response({ status: 'ok', version: '0.4.2', device: 'cuda' }));
|
||||
|
||||
await expect(
|
||||
probeRemoteBackend('https://gpu-box:3900/', 'secret', { fetchImpl }),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
detail: '0.4.2 on cuda',
|
||||
target: 'https://gpu-box:3900',
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://gpu-box:3900/health',
|
||||
expect.objectContaining({ headers: { Authorization: 'Bearer secret' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['https://gpu-box:3900', new TypeError('certificate verify failed'), 'tls'],
|
||||
['https://gpu-box:3900', new TypeError('Failed to fetch'), 'network'],
|
||||
['https://gpu-box:3900', new TypeError('blocked by CORS policy'), 'cors'],
|
||||
['http://gpu-box:3900', new TypeError('Failed to fetch'), 'network'],
|
||||
['https://gpu-box:7443', new TypeError('Failed to fetch'), 'wrong_port'],
|
||||
])('classifies %s transport failures as %s', async (url, error, kind) => {
|
||||
const result = await probeRemoteBackend(url, '', {
|
||||
fetchImpl: vi.fn().mockRejectedValue(error),
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, kind, target: url });
|
||||
});
|
||||
|
||||
it('classifies HTTP failures and non-VoiceStudio services', async () => {
|
||||
await expect(
|
||||
probeRemoteBackend('https://gpu-box:3900', '', {
|
||||
fetchImpl: vi.fn().mockResolvedValue(response({}, 503)),
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: false, kind: 'http', status: 503 });
|
||||
await expect(
|
||||
probeRemoteBackend('https://gpu-box:7443', '', {
|
||||
fetchImpl: vi.fn().mockResolvedValue(response({ status: 'ok' })),
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: false, kind: 'wrong_port' });
|
||||
});
|
||||
|
||||
it('rejects an oversized health response without consuming it', async () => {
|
||||
const getReader = vi.fn();
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ 'content-length': '20000' }),
|
||||
body: { getReader },
|
||||
});
|
||||
await expect(
|
||||
probeRemoteBackend('https://gpu-box:3900', '', {
|
||||
fetchImpl: fetchImpl as typeof fetch,
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: false, kind: 'wrong_port' });
|
||||
expect(getReader).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels a chunked health response once it exceeds the byte limit', async () => {
|
||||
const cancel = vi.fn().mockResolvedValue(undefined);
|
||||
const releaseLock = vi.fn();
|
||||
const chunks = [new Uint8Array(10_000), new Uint8Array(7_000)];
|
||||
const read = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ done: false, value: chunks[0] })
|
||||
.mockResolvedValueOnce({ done: false, value: chunks[1] });
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
body: { getReader: () => ({ read, cancel, releaseLock }) },
|
||||
});
|
||||
|
||||
await expect(
|
||||
probeRemoteBackend('https://gpu-box:3900', '', {
|
||||
fetchImpl: fetchImpl as typeof fetch,
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: false, kind: 'wrong_port' });
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
expect(read).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not request or disclose credentials embedded in a legacy URL', async () => {
|
||||
const fetchImpl = vi.fn();
|
||||
await expect(
|
||||
probeRemoteBackend('https://user:secret@gpu-box:3900', '', { fetchImpl }),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
kind: 'network',
|
||||
target: 'https://gpu-box:3900',
|
||||
});
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts a probe at its bounded deadline', async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchImpl = vi.fn((_url, init) => {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () =>
|
||||
reject(new DOMException('aborted', 'AbortError')),
|
||||
);
|
||||
});
|
||||
});
|
||||
const pending = probeRemoteBackend('http://gpu-box:3900', '', {
|
||||
fetchImpl: fetchImpl as typeof fetch,
|
||||
timeoutMs: 25,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
await expect(pending).resolves.toMatchObject({ ok: false, kind: 'timeout' });
|
||||
});
|
||||
|
||||
it('clears both credentials and reloads', () => {
|
||||
localStorage.setItem('ov_backend_url', 'http://gpu-box:3900');
|
||||
localStorage.setItem('ov_api_key', 'secret');
|
||||
const reload = vi.fn();
|
||||
disableRemoteBackend(reload);
|
||||
expect(localStorage.getItem('ov_backend_url')).toBeNull();
|
||||
expect(localStorage.getItem('ov_api_key')).toBeNull();
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { LS_API_KEY, LS_BACKEND_URL } from '../api/client.ts';
|
||||
|
||||
export type RemoteProbeKind = 'tls' | 'cors' | 'network' | 'timeout' | 'http' | 'wrong_port';
|
||||
|
||||
export type RemoteProbeResult =
|
||||
| { ok: true; detail: string; target: string }
|
||||
| { ok: false; kind: RemoteProbeKind; status?: number; target: string };
|
||||
|
||||
const MAX_HEALTH_BYTES = 16 * 1024;
|
||||
|
||||
export function configuredRemoteBackend(): { url: string; key: string } | null {
|
||||
try {
|
||||
const url = localStorage.getItem(LS_BACKEND_URL)?.trim().replace(/\/+$/, '') || '';
|
||||
return url ? { url, key: localStorage.getItem(LS_API_KEY)?.trim() || '' } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function disableRemoteBackend(reload: () => void): void {
|
||||
try {
|
||||
localStorage.removeItem(LS_BACKEND_URL);
|
||||
localStorage.removeItem(LS_API_KEY);
|
||||
} finally {
|
||||
reload();
|
||||
}
|
||||
}
|
||||
|
||||
function transportKind(url: URL, error: unknown): RemoteProbeKind {
|
||||
const text = String((error as Error)?.message || error).toLowerCase();
|
||||
if ((error as Error)?.name === 'AbortError') return 'timeout';
|
||||
if (url.port === '7443') return 'wrong_port';
|
||||
if (/certificate|cert_|ssl|tls/.test(text)) return 'tls';
|
||||
if (/cors|cross-origin/.test(text)) return 'cors';
|
||||
// Browser fetch deliberately hides DNS, connection, CORS, and TLS details
|
||||
// behind the same generic TypeError. Only report TLS when the error says so.
|
||||
return 'network';
|
||||
}
|
||||
|
||||
async function readBoundedBody(response: Response): Promise<string | null> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) return null;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const parts: string[] = [];
|
||||
let bytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > MAX_HEALTH_BYTES) {
|
||||
await reader.cancel();
|
||||
return null;
|
||||
}
|
||||
parts.push(decoder.decode(value, { stream: true }));
|
||||
}
|
||||
parts.push(decoder.decode());
|
||||
return parts.join('');
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeRemoteBackend(
|
||||
rawUrl: string,
|
||||
key = '',
|
||||
{ fetchImpl = fetch, timeoutMs = 5000 }: { fetchImpl?: typeof fetch; timeoutMs?: number } = {},
|
||||
): Promise<RemoteProbeResult> {
|
||||
const rawTarget = rawUrl.trim().replace(/\/+$/, '');
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawTarget);
|
||||
} catch {
|
||||
return { ok: false, kind: 'network', target: rawTarget };
|
||||
}
|
||||
const hasEmbeddedCredentials = Boolean(url.username || url.password);
|
||||
const hasQueryOrFragment = Boolean(url.search || url.hash);
|
||||
url.username = '';
|
||||
url.password = '';
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
const target = url.toString().replace(/\/+$/, '');
|
||||
if (
|
||||
(url.protocol !== 'http:' && url.protocol !== 'https:') ||
|
||||
hasEmbeddedCredentials ||
|
||||
hasQueryOrFragment
|
||||
) {
|
||||
return { ok: false, kind: 'network', target };
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetchImpl(`${target}/health`, {
|
||||
headers: key ? { Authorization: `Bearer ${key}` } : {},
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) return { ok: false, kind: 'http', status: response.status, target };
|
||||
const advertisedBytes = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(advertisedBytes) && advertisedBytes > MAX_HEALTH_BYTES) {
|
||||
return { ok: false, kind: 'wrong_port', target };
|
||||
}
|
||||
const payload = await readBoundedBody(response);
|
||||
if (payload === null) {
|
||||
return { ok: false, kind: 'wrong_port', target };
|
||||
}
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = JSON.parse(payload);
|
||||
} catch {
|
||||
// A healthy VoiceStudio API always returns a small JSON object.
|
||||
}
|
||||
if (
|
||||
!body ||
|
||||
typeof body !== 'object' ||
|
||||
(body as any).status !== 'ok' ||
|
||||
typeof (body as any).version !== 'string' ||
|
||||
typeof (body as any).device !== 'string'
|
||||
) {
|
||||
return { ok: false, kind: 'wrong_port', target };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
detail: `${(body as any).version} on ${(body as any).device}`,
|
||||
target,
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false, kind: transportKind(url, error), target };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user