Fix profile server selection activation state
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-08-11 10:11:13 +03:00
parent 17849ffd73
commit 7e4da4bdcf
11 changed files with 60 additions and 62 deletions
@@ -199,11 +199,16 @@ export function createSubscriptionService(dependencies: SubscriptionServiceDepen
if (!profile.servers.some((server) => server.id === serverId)) { if (!profile.servers.some((server) => server.id === serverId)) {
throw new HarborError('SERVER_NOT_FOUND'); throw new HarborError('SERVER_NOT_FOUND');
} }
if (profile.desiredServerId === serverId) return mutationResult(profile); if (profile.desiredServerId === serverId && state.desiredProfileId === profile.id) {
const selected = { ...profile, desiredServerId: serverId }; return mutationResult(profile);
}
const selected = profile.desiredServerId === serverId
? profile
: { ...profile, desiredServerId: serverId };
dependencies.state.update((current) => ({ dependencies.state.update((current) => ({
...current, ...current,
profiles: replaceProfile(current, selected), profiles: replaceProfile(current, selected),
desiredProfileId: profile.id,
})); }));
return mutationResult(selected); return mutationResult(selected);
}); });
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.23.11', macClient: '0.24.0',
gatewayClient: '0.24.11', gatewayClient: '0.25.0',
gatewayBackend: '0.24.0', gatewayBackend: '0.25.0',
}); });
export interface ParsedVersion { export interface ParsedVersion {
-7
View File
@@ -255,13 +255,6 @@ export function App() {
`${profileId}:${serverId}`, `${profileId}:${serverId}`,
profileId, profileId,
)} )}
onActivateProfile={(profileId: string) => run(
'profileActivate',
() => api.profiles.activate(profileId, revisionRef.current),
'connection',
profileId,
profileId,
)}
onRefreshProfile={(profileId: string) => run( onRefreshProfile={(profileId: string) => run(
'profileRefresh', 'profileRefresh',
() => api.profiles.refresh(profileId, revisionRef.current), () => api.profiles.refresh(profileId, revisionRef.current),
+3 -6
View File
@@ -105,7 +105,6 @@ interface ClientOverviewPageProps {
error: UiError | null; error: UiError | null;
onAddProfile: (label: string, url: string) => Promise<unknown>; onAddProfile: (label: string, url: string) => Promise<unknown>;
onSelectProfileServer: (profileId: string, serverId: string) => Promise<unknown>; onSelectProfileServer: (profileId: string, serverId: string) => Promise<unknown>;
onActivateProfile: (profileId: string) => Promise<unknown>;
onRefreshProfile: (profileId: string) => Promise<unknown>; onRefreshProfile: (profileId: string) => Promise<unknown>;
onForgetProfile: (profileId: string, mode: 'delete' | 'stop-and-delete') => Promise<unknown>; onForgetProfile: (profileId: string, mode: 'delete' | 'stop-and-delete') => Promise<unknown>;
onApply: (profileId: string, serverId: string) => Promise<unknown>; onApply: (profileId: string, serverId: string) => Promise<unknown>;
@@ -411,7 +410,6 @@ export function ClientOverviewPage({
error, error,
onAddProfile, onAddProfile,
onSelectProfileServer, onSelectProfileServer,
onActivateProfile,
onRefreshProfile, onRefreshProfile,
onForgetProfile, onForgetProfile,
onApply, onApply,
@@ -494,7 +492,6 @@ export function ClientOverviewPage({
onAdd: onAddProfile, onAdd: onAddProfile,
onRefresh: onRefreshProfile, onRefresh: onRefreshProfile,
onForget: onForgetProfile, onForget: onForgetProfile,
onActivate: onActivateProfile,
onDismissError, onDismissError,
}); });
const routingFeature = useRoutingFeature({ const routingFeature = useRoutingFeature({
@@ -553,7 +550,7 @@ export function ClientOverviewPage({
}, []); }, []);
function selectServer(profile: ProfileSnapshot, serverId: string) { function selectServer(profile: ProfileSnapshot, serverId: string) {
if (connected && !gatewayDirect && state.selection.appliedProfileId === profile.id) { if (connected && !gatewayDirect) {
onApply(profile.id, serverId); onApply(profile.id, serverId);
return; return;
} }
@@ -723,9 +720,9 @@ export function ClientOverviewPage({
profileId={profile.id} profileId={profile.id}
pingServers={actions.pingServers} pingServers={actions.pingServers}
servers={profile.servers} servers={profile.servers}
selectedServerId={profile.desiredServerId} selectedServerId={pickerState.selectedServerId}
disabled={serverApplyBlocked || pickerState.disabled} disabled={serverApplyBlocked || pickerState.disabled}
prompt={!profile.desiredServerId} prompt={!pickerState.selectedServerId}
leaving={pickerState.leaving} leaving={pickerState.leaving}
revealVersion={pickerState.revealVersion} revealVersion={pickerState.revealVersion}
anchorServerId={pickerState.anchorServerId} anchorServerId={pickerState.anchorServerId}
@@ -32,6 +32,7 @@ interface ServerPickerRenderState {
leaving: boolean; leaving: boolean;
revealVersion: number; revealVersion: number;
anchorServerId: string; anchorServerId: string;
selectedServerId: string;
} }
interface SubscriptionFeatureOptions { interface SubscriptionFeatureOptions {
@@ -45,7 +46,6 @@ interface SubscriptionFeatureOptions {
onAdd: (label: string, url: string) => Promise<unknown>; onAdd: (label: string, url: string) => Promise<unknown>;
onRefresh: (profileId: string) => Promise<unknown>; onRefresh: (profileId: string) => Promise<unknown>;
onForget: (profileId: string, mode: 'delete' | 'stop-and-delete') => Promise<unknown>; onForget: (profileId: string, mode: 'delete' | 'stop-and-delete') => Promise<unknown>;
onActivate: (profileId: string) => Promise<unknown>;
onDismissError: () => void; onDismissError: () => void;
} }
@@ -64,7 +64,6 @@ export function useSubscriptionFeature({
onAdd, onAdd,
onRefresh, onRefresh,
onForget, onForget,
onActivate,
onDismissError, onDismissError,
}: SubscriptionFeatureOptions) { }: SubscriptionFeatureOptions) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -281,7 +280,6 @@ export function useSubscriptionFeature({
addBlocked: operationBlocked(operations, 'profileAdd'), addBlocked: operationBlocked(operations, 'profileAdd'),
refreshBlocked: operationBlocked(operations, 'profileRefresh'), refreshBlocked: operationBlocked(operations, 'profileRefresh'),
deleteBlocked: operationBlocked(operations, 'profileDelete'), deleteBlocked: operationBlocked(operations, 'profileDelete'),
activateBlocked: operationBlocked(operations, 'profileActivate'),
toggle: () => setOpen((current) => !current), toggle: () => setOpen((current) => !current),
close: () => setOpen(false), close: () => setOpen(false),
showAdd, showAdd,
@@ -296,7 +294,6 @@ export function useSubscriptionFeature({
cancelDelete: () => setDeleteId(''), cancelDelete: () => setDeleteId(''),
confirmDelete, confirmDelete,
refresh, refresh,
onActivate,
}; };
} }
@@ -394,8 +391,9 @@ function ProfileGroup({
const refreshing = feature.refreshingIds.includes(profile.id) const refreshing = feature.refreshingIds.includes(profile.id)
|| feature.operations.profileRefresh?.target === profile.id; || feature.operations.profileRefresh?.target === profile.id;
const controlsBlocked = operationBlocked(feature.operations, 'profileSelect'); const controlsBlocked = operationBlocked(feature.operations, 'profileSelect');
const selectedServer = profileServer(profile, profile.desiredServerId); const visibleServerId = applied
const visibleServerId = applied ? feature.selection.appliedServerId : profile.desiredServerId; ? feature.selection.appliedServerId
: desired ? profile.desiredServerId : '';
const visibleServer = profileServer(profile, visibleServerId) const visibleServer = profileServer(profile, visibleServerId)
|| (applied && feature.selection.appliedServerSnapshot?.id === visibleServerId || (applied && feature.selection.appliedServerSnapshot?.id === visibleServerId
? feature.selection.appliedServerSnapshot ? feature.selection.appliedServerSnapshot
@@ -403,7 +401,6 @@ function ProfileGroup({
const visibleServerLocation = [visibleServer?.city, visibleServer?.country] const visibleServerLocation = [visibleServer?.city, visibleServer?.country]
.filter((value): value is string => typeof value === 'string' && Boolean(value)) .filter((value): value is string => typeof value === 'string' && Boolean(value))
.join(' · '); .join(' · ');
const canActivate = !applied && feature.selection.desiredProfileId !== profile.id;
const localStatus = profileError?.message || (profile.subscription.status === 'stale' const localStatus = profileError?.message || (profile.subscription.status === 'stale'
? `Последнее обновление не удалось. Данные от ${profile.subscription.fetchedAt ? `Последнее обновление не удалось. Данные от ${profile.subscription.fetchedAt
? new Date(profile.subscription.fetchedAt).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' }) ? new Date(profile.subscription.fetchedAt).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' })
@@ -431,12 +428,7 @@ function ProfileGroup({
<small>{profileDetails}</small> <small>{profileDetails}</small>
</span> </span>
</button> </button>
{canActivate ? <button {(applied || desired) && <ProfileUsage profile={profile} />}
className="client-profile-activate"
type="button"
disabled={feature.activateBlocked || !selectedServer}
onClick={() => feature.onActivate(profile.id)}
>Сделать активной</button> : <ProfileUsage profile={profile} />}
<button <button
className={`client-profile-refresh${refreshing ? ' is-refreshing' : ''}`} className={`client-profile-refresh${refreshing ? ' is-refreshing' : ''}`}
type="button" type="button"
@@ -483,12 +475,13 @@ function ProfileGroup({
inert={!expanded ? true : undefined} inert={!expanded ? true : undefined}
> >
<div className="client-profile-body-inner"> <div className="client-profile-body-inner">
{!profile.desiredServerId && <p className="client-profile-server-hint">Выберите сервер этой подписки</p>} {!visibleServerId && <p className="client-profile-server-hint">Выберите сервер этой подписки</p>}
{renderServerPicker(profile, { {renderServerPicker(profile, {
disabled: controlsBlocked, disabled: controlsBlocked,
leaving: false, leaving: false,
revealVersion: feature.revealVersions[profile.id] || 0, revealVersion: feature.revealVersions[profile.id] || 0,
anchorServerId: visibleServer?.id || '', anchorServerId: visibleServer?.id || '',
selectedServerId: visibleServerId,
})} })}
</div> </div>
</div>} </div>}
+10 -13
View File
@@ -149,6 +149,8 @@
} }
.client-profile-usage { .client-profile-usage {
grid-column: 2;
justify-self: end;
max-width: 126px; max-width: 126px;
overflow: hidden; overflow: hidden;
color: var(--client-muted); color: var(--client-muted);
@@ -188,6 +190,14 @@
transition: transform 600ms cubic-bezier(0.16, 1, 0.3, 1); transition: transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
} }
.client-profile-refresh {
grid-column: 3;
}
.client-profile-delete {
grid-column: 4;
}
.client-profile-delete-lid { .client-profile-delete-lid {
transform-origin: center 7px; transform-origin: center 7px;
transition: transform 260ms cubic-bezier(0.16, 1, 0.3, 1); transition: transform 260ms cubic-bezier(0.16, 1, 0.3, 1);
@@ -327,7 +337,6 @@
outline-offset: -2px; outline-offset: -2px;
} }
.client-profile-activate,
.client-profile-add-trigger { .client-profile-add-trigger {
min-height: 36px; min-height: 36px;
padding: 0; padding: 0;
@@ -365,18 +374,6 @@
} }
} }
.client-profile-activate {
font: var(--type-micro);
letter-spacing: var(--type-micro-tracking);
text-transform: var(--type-micro-transform);
white-space: nowrap;
}
.client-profile-activate:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.client-profile-add-trigger { .client-profile-add-trigger {
width: 100%; width: 100%;
margin-top: 10px; margin-top: 10px;
+1
View File
@@ -487,6 +487,7 @@ setInterval(() => {}, 60_000);
selected.state.profiles.find(({ id }) => id === workProfileId).desiredServerId, selected.state.profiles.find(({ id }) => id === workProfileId).desiredServerId,
testServerId, testServerId,
); );
assert.equal(selected.state.selection.desiredProfileId, workProfileId);
assert.equal(selected.state.profiles.find(({ id }) => id === primaryProfileId).desiredServerId, testServerId); assert.equal(selected.state.profiles.find(({ id }) => id === primaryProfileId).desiredServerId, testServerId);
const applied = await mutation('/api/apply', 'POST', { const applied = await mutation('/api/apply', 'POST', {
@@ -179,6 +179,13 @@ test('rename and server selection are profile-scoped and reject stale or duplica
await scoped.service.selectProfileServer('work', 'next', state.revision); await scoped.service.selectProfileServer('work', 'next', state.revision);
assert.equal(scoped.snapshot().state.profiles.find(({ id }) => id === 'work').desiredServerId, 'next'); assert.equal(scoped.snapshot().state.profiles.find(({ id }) => id === 'work').desiredServerId, 'next');
assert.equal(scoped.snapshot().state.profiles.find(({ id }) => id === 'personal').desiredServerId, 'shared'); assert.equal(scoped.snapshot().state.profiles.find(({ id }) => id === 'personal').desiredServerId, 'shared');
assert.equal(scoped.snapshot().state.desiredProfileId, 'work');
const sameServer = scoped.snapshot().state;
sameServer.desiredProfileId = 'personal';
const reactivated = createHarness({ state: sameServer });
await reactivated.service.selectProfileServer('work', 'next', sameServer.revision);
assert.equal(reactivated.snapshot().state.desiredProfileId, 'work');
}); });
test('refreshing an inactive profile does not mutate config, runtime, or the applied pair', async () => { test('refreshing an inactive profile does not mutate config, runtime, or the applied pair', async () => {
+2 -1
View File
@@ -27,7 +27,8 @@ test('server picker has one public feature owner without legacy shims', () => {
assert.equal((overview.match(/<ServerPicker/g) || []).length, 1); assert.equal((overview.match(/<ServerPicker/g) || []).length, 1);
assert.doesNotMatch(picker, /from ['"][^'"]*\/api\/|\bapi\./); assert.doesNotMatch(picker, /from ['"][^'"]*\/api\/|\bapi\./);
assert.match(overview, /const selectedServerId = desiredProfile\?\.desiredServerId \|\| ''/); assert.match(overview, /const selectedServerId = desiredProfile\?\.desiredServerId \|\| ''/);
assert.match(overview, /function selectServer\(profile: ProfileSnapshot, serverId: string\)[\s\S]*onApply\(profile\.id, serverId\)[\s\S]*onSelectProfileServer\(profile\.id, serverId\)/); assert.match(overview, /function selectServer\(profile: ProfileSnapshot, serverId: string\)[\s\S]*if \(connected && !gatewayDirect\)[\s\S]*onApply\(profile\.id, serverId\)[\s\S]*onSelectProfileServer\(profile\.id, serverId\)/);
assert.match(overview, /selectedServerId=\{pickerState\.selectedServerId\}/);
}); });
const fixtures = (count) => Array.from({ length: count }, (_, index) => ({ const fixtures = (count) => Array.from({ length: count }, (_, index) => ({
+14 -14
View File
@@ -37,26 +37,26 @@ const expectedImports = [
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex'); const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
const acceptedLedger = { const acceptedLedger = {
counts: { counts: {
cascadeEdges: 752, cascadeEdges: 749,
customProperties: 103, customProperties: 103,
declarations: 3202, declarations: 3200,
important: 0, important: 0,
keyframes: 56, keyframes: 56,
media: 13, media: 13,
rules: 917, rules: 917,
variableReferences: 794, variableReferences: 791,
}, },
hashes: { hashes: {
cascadeEdges: '5c09bfa1ab715bdd522969d017f4ff974e2a8fc391305e68943e39612245f815', cascadeEdges: 'a47dbf5212045c865de7a41d420e78df8b992a6ec1b06fb18a8d459cd480f0ae',
customProperties: 'fc8401b40b8d2cc8a1fc1716360ba8e5baccb694cdabde68427d3c92c04a84d4', customProperties: 'fc8401b40b8d2cc8a1fc1716360ba8e5baccb694cdabde68427d3c92c04a84d4',
declarations: '0cacb55296aad8af6790036c75831f7a87429ec055481d9eb71d564e4d8a828c', declarations: '198ccda82654a42a5ba3f452fed7a6e9a0ef1c0ff13d243eceb2c1965b4a91cd',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: '419962e669cbfdf5c8186318cb26688477fc0199cae98df8e1cd9bf5cddd76c2', duplicateSelectors: '257eff4727dab9ce25f4e1f9320e89bf2270eb29feae921b96601f103ad7f036',
keyframes: 'fb859c4d0d1bfd2f5a18904e30a5f79c6ce6931d74fe88cbd506fb28c334b7ac', keyframes: 'fb859c4d0d1bfd2f5a18904e30a5f79c6ce6931d74fe88cbd506fb28c334b7ac',
ruleDeclarationSequences: 'b2b02e78df98c48405a5243bb07e0cd6dfcfac83f7fea7847e82041741a56e14', ruleDeclarationSequences: 'e4d989c091659ad82e79de7281c2b94b7b76d5f032f2a814e630275b7f7b7c4b',
selectors: 'e4c3dc48d8998e9fc76a0a3eb147fe6366ebfc93e22ba9ebe77f08f1fe84de2f', selectors: 'd1948bb0fb3899264af80cd1f86b9c8e9cdcf1a4473ef77a0dec4ffad10d1f8d',
variableReferences: '252702ab07ce1c790d77a60f8e674cb3b55f7ffe6e52329a50b69e5306442f1f', variableReferences: '00551e699e5530d624d364db9e546c0ffc05d7c79d04abc42930a98be874b3bf',
witnesses: 'f51f070cc2c7dba0f8d08518cdeee49eaf176aac82e3571cdd8498315ba8db83', witnesses: '07f462edf94d90909902283f5af130f290c957c3f10ddaa5a2b2339eb64d468a',
}, },
}; };
@@ -209,7 +209,7 @@ test('client typography uses the shared semantic scale outside the token owner',
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => { test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
const witnesses = readStyleWitnesses(root); const witnesses = readStyleWitnesses(root);
assert.equal(witnesses.length, 730); assert.equal(witnesses.length, 729);
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0); assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
const ledger = createStyleLedger(readStyleSource(root), { witnesses }); const ledger = createStyleLedger(readStyleSource(root), { witnesses });
assert.deepEqual(ledger.counts, acceptedLedger.counts); assert.deepEqual(ledger.counts, acceptedLedger.counts);
@@ -405,8 +405,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1); assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css')); const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
assert.deepEqual(assets, ['index-dA_YmbDB.css']); assert.deepEqual(assets, ['index-SYp2glgN.css']);
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0])); const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
assert.equal(built.byteLength, 123538); assert.equal(built.byteLength, 123402);
assert.equal(sha256(built), '7c506bdd5d073515239a4d779f6f5365a07ef245c057772ba6a343b6d90838e7'); assert.equal(sha256(built), '8050f9e31292529b97d716d38140da72364c26368af92dd5b3a8d56c9fe752ad');
}); });
@@ -111,7 +111,10 @@ test('profiles render as flat accordion groups with scoped controls', () => {
assert.match(feature, /feature\.operations\.profileRefresh\?\.target === profile\.id/); assert.match(feature, /feature\.operations\.profileRefresh\?\.target === profile\.id/);
assert.match(feature, /\{visibleServer && <button[\s\S]*client-profile-selected-server[\s\S]*aria-expanded=\{expanded\}/); assert.match(feature, /\{visibleServer && <button[\s\S]*client-profile-selected-server[\s\S]*aria-expanded=\{expanded\}/);
assert.match(feature, /anchorServerId: visibleServer\?\.id \|\| ''/); assert.match(feature, /anchorServerId: visibleServer\?\.id \|\| ''/);
assert.match(feature, /const visibleServerId = applied \? feature\.selection\.appliedServerId : profile\.desiredServerId/); assert.match(feature, /const visibleServerId = applied[\s\S]*feature\.selection\.appliedServerId[\s\S]*desired \? profile\.desiredServerId : ''/);
assert.match(feature, /selectedServerId: visibleServerId/);
assert.doesNotMatch(feature, /Сделать активной|client-profile-activate|onActivate/);
assert.doesNotMatch(page, /onActivateProfile/);
assert.match(feature, /const desired = !feature\.connected[\s\S]*&& !feature\.gatewayDirect/); assert.match(feature, /const desired = !feature\.connected[\s\S]*&& !feature\.gatewayDirect/);
assert.match(feature, /const profileDomain = subscriptionDomain\(profile\.subscription\.host\)/); assert.match(feature, /const profileDomain = subscriptionDomain\(profile\.subscription\.host\)/);
assert.match(feature, /const profileDetails = `Серверов: \$\{profile\.servers\.length\}/); assert.match(feature, /const profileDetails = `Серверов: \$\{profile\.servers\.length\}/);
@@ -122,6 +125,7 @@ test('profiles render as flat accordion groups with scoped controls', () => {
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-profile-body \*/); assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-profile-body \*/);
assert.match(styles, /\.client-profile-refresh\.is-refreshing/); assert.match(styles, /\.client-profile-refresh\.is-refreshing/);
assert.match(styles, /\.client-profile-refresh,[\s\S]*\.client-profile-delete\s*\{[\s\S]*width:\s*36px;[\s\S]*height:\s*36px;[\s\S]*display:\s*grid;[\s\S]*place-items:\s*center/); assert.match(styles, /\.client-profile-refresh,[\s\S]*\.client-profile-delete\s*\{[\s\S]*width:\s*36px;[\s\S]*height:\s*36px;[\s\S]*display:\s*grid;[\s\S]*place-items:\s*center/);
assert.match(styles, /\.client-profile-activate\s*\{[\s\S]*font:\s*var\(--type-micro\)/); assert.match(styles, /\.client-profile-refresh\s*\{[\s\S]*grid-column:\s*3/);
assert.match(styles, /\.client-profile-delete\s*\{[\s\S]*grid-column:\s*4/);
assert.doesNotMatch(feature, /client-subscription-card|role="tab"/); assert.doesNotMatch(feature, /client-subscription-card|role="tab"/);
}); });