diff --git a/src/shared/versions.ts b/src/shared/versions.ts index f9ff9bb..89803a9 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,6 +1,6 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.26.5', - gatewayClient: '0.27.5', + macClient: '0.26.6', + gatewayClient: '0.27.6', gatewayBackend: '0.27.0', }); diff --git a/src/web/App.tsx b/src/web/App.tsx index 4e58d62..5dee45a 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -53,6 +53,13 @@ const operationErrorContext: Record = { 'route-rules': 'routing', }; +function asHarborApiError(error: unknown) { + const candidate = error && typeof error === 'object' ? error as Record : {}; + return error instanceof HarborApiError + ? error + : new HarborApiError({ code: candidate.code }, Number(candidate.status)); +} + export function App() { const previewReady = new URLSearchParams(window.location.search).has('preview-ready'); const [{ snapshot: state, transport }, dispatch] = useReducer( @@ -135,19 +142,26 @@ export function App() { try { return await applyMutation(action); } catch (err) { + let safeError = asHarborApiError(err); + if (safeError.code === 'STATE_CONFLICT' && context !== 'routing') { + await loadState(); + try { + return await applyMutation(action); + } catch (retryError) { + safeError = asHarborApiError(retryError); + } + } await loadState(); - const candidate = err && typeof err === 'object' ? err as Record : {}; - const safeError = err instanceof HarborApiError - ? err - : new HarborApiError({ code: candidate.code }, Number(candidate.status)); setError({ context, profileId, message: context === 'routing' && safeError.code === 'STATE_CONFLICT' ? 'Правила уже изменились в другом окне. Проверьте статусы строк и сохраните ещё раз.' - : safeError.message, + : safeError.code === 'STATE_CONFLICT' + ? 'Harbor снова получил новые данные во время действия. Повторите ещё раз.' + : safeError.message, code: safeError.code, - correlationId: safeError.correlationId, + correlationId: safeError.code === 'STATE_CONFLICT' ? '' : safeError.correlationId, retry: safeError.retryable && safeError.code !== 'STATE_CONFLICT' ? () => run(key, action, context, target, profileId) : null, diff --git a/src/web/features/subscription/SubscriptionFeature.tsx b/src/web/features/subscription/SubscriptionFeature.tsx index a76de4e..8fd146a 100644 --- a/src/web/features/subscription/SubscriptionFeature.tsx +++ b/src/web/features/subscription/SubscriptionFeature.tsx @@ -487,7 +487,7 @@ function ProfileGroup({ inert={!expanded ? true : undefined} >
- {!localStatus && renderServerPicker(profile, { + {renderServerPicker(profile, { disabled: controlsBlocked, leaving: false, revealVersion: feature.revealVersions[profile.id] || 0, diff --git a/test/web/subscription-feature-contract.test.js b/test/web/subscription-feature-contract.test.js index 67b0678..4940600 100644 --- a/test/web/subscription-feature-contract.test.js +++ b/test/web/subscription-feature-contract.test.js @@ -35,6 +35,8 @@ test('App owns profile mutations and always uses the latest canonical revision', assert.match(app, /api\.profiles\.add\(label, url, revisionRef\.current\)/); assert.match(app, /api\.profiles\.refresh\(profileId, revisionRef\.current\)/); assert.match(app, /api\.profiles\.forget\(profileId, mode, revisionRef\.current\)/); + assert.match(app, /safeError\.code === 'STATE_CONFLICT' && context !== 'routing'[\s\S]*await loadState\(\)[\s\S]*return await applyMutation\(action\)/); + assert.match(app, /safeError\.code === 'STATE_CONFLICT' \? '' : safeError\.correlationId/); assert.match(page, /profiles,[\s\S]*onAdd: onAddProfile,[\s\S]*onRefresh: onRefreshProfile,[\s\S]*onForget: onForgetProfile/); assert.doesNotMatch(page, /onRenameProfile/); assert.doesNotMatch(feature, /from ['"][^'"]*\/api\/|\bapi\./); @@ -113,7 +115,8 @@ test('profiles render as flat accordion groups with scoped controls', () => { assert.match(feature, /\{!expired && visited &&