Retry state conflicts and always render server picker
Build and Deploy Gateway / build-and-push (push) Successful in 25s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-08-19 00:36:39 +03:00
parent dc1fd76c44
commit 7cb25d0633
4 changed files with 27 additions and 10 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.26.5', macClient: '0.26.6',
gatewayClient: '0.27.5', gatewayClient: '0.27.6',
gatewayBackend: '0.27.0', gatewayBackend: '0.27.0',
}); });
+20 -6
View File
@@ -53,6 +53,13 @@ const operationErrorContext: Record<string, string> = {
'route-rules': 'routing', 'route-rules': 'routing',
}; };
function asHarborApiError(error: unknown) {
const candidate = error && typeof error === 'object' ? error as Record<string, unknown> : {};
return error instanceof HarborApiError
? error
: new HarborApiError({ code: candidate.code }, Number(candidate.status));
}
export function App() { export function App() {
const previewReady = new URLSearchParams(window.location.search).has('preview-ready'); const previewReady = new URLSearchParams(window.location.search).has('preview-ready');
const [{ snapshot: state, transport }, dispatch] = useReducer( const [{ snapshot: state, transport }, dispatch] = useReducer(
@@ -135,19 +142,26 @@ export function App() {
try { try {
return await applyMutation(action); return await applyMutation(action);
} catch (err) { } 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(); await loadState();
const candidate = err && typeof err === 'object' ? err as Record<string, unknown> : {};
const safeError = err instanceof HarborApiError
? err
: new HarborApiError({ code: candidate.code }, Number(candidate.status));
setError({ setError({
context, context,
profileId, profileId,
message: context === 'routing' && safeError.code === 'STATE_CONFLICT' message: context === 'routing' && safeError.code === 'STATE_CONFLICT'
? 'Правила уже изменились в другом окне. Проверьте статусы строк и сохраните ещё раз.' ? 'Правила уже изменились в другом окне. Проверьте статусы строк и сохраните ещё раз.'
: safeError.message, : safeError.code === 'STATE_CONFLICT'
? 'Harbor снова получил новые данные во время действия. Повторите ещё раз.'
: safeError.message,
code: safeError.code, code: safeError.code,
correlationId: safeError.correlationId, correlationId: safeError.code === 'STATE_CONFLICT' ? '' : safeError.correlationId,
retry: safeError.retryable && safeError.code !== 'STATE_CONFLICT' retry: safeError.retryable && safeError.code !== 'STATE_CONFLICT'
? () => run(key, action, context, target, profileId) ? () => run(key, action, context, target, profileId)
: null, : null,
@@ -487,7 +487,7 @@ function ProfileGroup({
inert={!expanded ? true : undefined} inert={!expanded ? true : undefined}
> >
<div className="client-profile-body-inner"> <div className="client-profile-body-inner">
{!localStatus && renderServerPicker(profile, { {renderServerPicker(profile, {
disabled: controlsBlocked, disabled: controlsBlocked,
leaving: false, leaving: false,
revealVersion: feature.revealVersions[profile.id] || 0, revealVersion: feature.revealVersions[profile.id] || 0,
@@ -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\.add\(label, url, revisionRef\.current\)/);
assert.match(app, /api\.profiles\.refresh\(profileId, revisionRef\.current\)/); assert.match(app, /api\.profiles\.refresh\(profileId, revisionRef\.current\)/);
assert.match(app, /api\.profiles\.forget\(profileId, mode, 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.match(page, /profiles,[\s\S]*onAdd: onAddProfile,[\s\S]*onRefresh: onRefreshProfile,[\s\S]*onForget: onForgetProfile/);
assert.doesNotMatch(page, /onRenameProfile/); assert.doesNotMatch(page, /onRenameProfile/);
assert.doesNotMatch(feature, /from ['"][^'"]*\/api\/|\bapi\./); 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 && <div/); assert.match(feature, /\{!expired && visited && <div/);
assert.match(page, /const subscriptionError = error\?\.context === 'subscription'[\s\S]*profile\.id === error\.profileId[\s\S]*profile\.subscription\.errorCode === 'SUBSCRIPTION_EXPIRED'[\s\S]*error: subscriptionError/); assert.match(page, /const subscriptionError = error\?\.context === 'subscription'[\s\S]*profile\.id === error\.profileId[\s\S]*profile\.subscription\.errorCode === 'SUBSCRIPTION_EXPIRED'[\s\S]*error: subscriptionError/);
assert.match(feature, /profile\.subscription\.status === 'stale'[\s\S]*profile\.subscription\.fetchedAt/); assert.match(feature, /profile\.subscription\.status === 'stale'[\s\S]*profile\.subscription\.fetchedAt/);
assert.match(feature, /\{!localStatus && renderServerPicker\(profile/); assert.match(feature, /\{renderServerPicker\(profile/);
assert.doesNotMatch(feature, /!localStatus && renderServerPicker/);
assert.doesNotMatch(feature, /Выберите сервер этой подписки|client-profile-server-hint/); assert.doesNotMatch(feature, /Выберите сервер этой подписки|client-profile-server-hint/);
assert.match(feature, /feature\.operations\.profileRefresh\?\.target === profile\.id/); assert.match(feature, /feature\.operations\.profileRefresh\?\.target === profile\.id/);
assert.match(feature, /\{!expired && visibleServer && <button[\s\S]*client-profile-selected-server[\s\S]*aria-expanded=\{expanded\}/); assert.match(feature, /\{!expired && visibleServer && <button[\s\S]*client-profile-selected-server[\s\S]*aria-expanded=\{expanded\}/);