Handle expired subscriptions in client profile UI
Build and Deploy Gateway / build-and-push (push) Successful in 21s
Build and Deploy Gateway / deploy (push) Successful in 6s

This commit is contained in:
2026-08-13 15:06:13 +03:00
parent 0ea2f9d548
commit 019930924d
8 changed files with 68 additions and 39 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.25.8', macClient: '0.25.9',
gatewayClient: '0.26.7', gatewayClient: '0.26.8',
gatewayBackend: '0.26.5', gatewayBackend: '0.26.5',
}); });
+7 -2
View File
@@ -482,12 +482,17 @@ export function ClientOverviewPage({
&& (canonicalSwitch || localSwitch) && (canonicalSwitch || localSwitch)
&& Boolean(operationProfile && operationServer) && Boolean(operationProfile && operationServer)
&& (operationProfile?.id !== appliedProfile?.id || operationServer?.id !== appliedServer?.id); && (operationProfile?.id !== appliedProfile?.id || operationServer?.id !== appliedServer?.id);
const subscriptionError = error?.context === 'subscription'
&& profiles.some((profile) => profile.id === error.profileId
&& profile.subscription.errorCode === 'SUBSCRIPTION_EXPIRED')
? null
: error;
const subscriptionFeature = useSubscriptionFeature({ const subscriptionFeature = useSubscriptionFeature({
profiles, profiles,
selection: state.selection, selection: state.selection,
connected, connected,
operations: visibleOperations, operations: visibleOperations,
error, error: subscriptionError,
isGateway, isGateway,
gatewayDirect, gatewayDirect,
onAdd: onAddProfile, onAdd: onAddProfile,
@@ -716,7 +721,7 @@ export function ClientOverviewPage({
<SubscriptionPanel <SubscriptionPanel
feature={subscriptionFeature} feature={subscriptionFeature}
statusSlot={<> statusSlot={<>
<InlineError error={subscriptionFeature.error || error} context="subscription" /> <InlineError error={subscriptionFeature.error} context="subscription" />
</>} </>}
renderServerPicker={(profile, pickerState) => <ServerPicker renderServerPicker={(profile, pickerState) => <ServerPicker
profileId={profile.id} profileId={profile.id}
@@ -378,7 +378,8 @@ function ProfileGroup({
profile: ProfileSnapshot; profile: ProfileSnapshot;
renderServerPicker: (profile: ProfileSnapshot, state: ServerPickerRenderState) => ReactNode; renderServerPicker: (profile: ProfileSnapshot, state: ServerPickerRenderState) => ReactNode;
}) { }) {
const expanded = feature.expanded.includes(profile.id); const expired = profile.subscription.errorCode === 'SUBSCRIPTION_EXPIRED';
const expanded = !expired && feature.expanded.includes(profile.id);
const closing = feature.closing.includes(profile.id); const closing = feature.closing.includes(profile.id);
const visited = feature.visited.includes(profile.id); const visited = feature.visited.includes(profile.id);
const applied = feature.connected const applied = feature.connected
@@ -401,18 +402,29 @@ 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 localStatus = profileError?.message || (profile.subscription.status === 'stale' const localStatus = !expired && (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' })
: 'предыдущей загрузки'}.` : 'предыдущей загрузки'}.`
: ''); : ''));
const profileDomain = subscriptionDomain(profile.subscription.host); const profileDomain = subscriptionDomain(profile.subscription.host);
const profileDetails = `Серверов: ${profile.servers.length}${profile.subscription.fetchedAt const profileDetails = expired
? ` · обновлено ${new Date(profile.subscription.fetchedAt).toLocaleDateString('ru-RU')}` ? 'Срок действия подписки истёк'
: ''}`; : `Серверов: ${profile.servers.length}${profile.subscription.fetchedAt
return <section className={`client-profile-group${expanded ? ' is-expanded' : ''}${applied ? ' is-active' : ''}`}> ? ` · обновлено ${new Date(profile.subscription.fetchedAt).toLocaleDateString('ru-RU')}`
: ''}`;
const profileTitle = <span className="client-profile-title">
<span>
<strong>{profileDomain}</strong>
{(applied || desired) && <em>{applied ? 'АКТИВНА' : 'ВЫБРАНА'}</em>}
</span>
<small>{profileDetails}</small>
</span>;
return <section className={`client-profile-group${expanded ? ' is-expanded' : ''}${applied ? ' is-active' : ''}${expired ? ' is-expired' : ''}`}>
<div className="client-profile-header"> <div className="client-profile-header">
<button {expired ? <div className="client-profile-disclosure is-static">
{profileTitle}
</div> : <button
className="client-profile-disclosure" className="client-profile-disclosure"
type="button" type="button"
aria-expanded={expanded} aria-expanded={expanded}
@@ -420,15 +432,9 @@ function ProfileGroup({
onClick={() => feature.toggleProfile(profile.id)} onClick={() => feature.toggleProfile(profile.id)}
> >
<span className="client-profile-chevron" aria-hidden="true"></span> <span className="client-profile-chevron" aria-hidden="true"></span>
<span className="client-profile-title"> {profileTitle}
<span> </button>}
<strong>{profileDomain}</strong> {!expired && (applied || desired) && <ProfileUsage profile={profile} />}
{(applied || desired) && <em>{applied ? 'АКТИВНА' : 'ВЫБРАНА'}</em>}
</span>
<small>{profileDetails}</small>
</span>
</button>
{(applied || desired) && <ProfileUsage profile={profile} />}
<button <button
className={`client-profile-refresh${refreshing ? ' is-refreshing' : ''}`} className={`client-profile-refresh${refreshing ? ' is-refreshing' : ''}`}
type="button" type="button"
@@ -456,7 +462,7 @@ function ProfileGroup({
{localStatus && <div className="client-profile-local-status" role="status">{localStatus}</div>} {localStatus && <div className="client-profile-local-status" role="status">{localStatus}</div>}
{visibleServer && <button {!expired && visibleServer && <button
className="client-profile-selected-server" className="client-profile-selected-server"
type="button" type="button"
aria-expanded={expanded} aria-expanded={expanded}
@@ -468,7 +474,7 @@ function ProfileGroup({
{visibleServerLocation && <small>{visibleServerLocation}</small>} {visibleServerLocation && <small>{visibleServerLocation}</small>}
</button>} </button>}
{visited && <div {!expired && visited && <div
id={`client-profile-${profile.id}`} id={`client-profile-${profile.id}`}
className={`client-profile-body${expanded ? ' is-open' : ''}${closing ? ' is-closing' : ''}`} className={`client-profile-body${expanded ? ' is-open' : ''}${closing ? ' is-closing' : ''}`}
aria-hidden={!expanded} aria-hidden={!expanded}
+1 -1
View File
@@ -651,7 +651,7 @@
} }
.client-profile-body.is-open .client-server-toolbar { .client-profile-body.is-open .client-server-toolbar {
animation: client-profile-ping-in 300ms 620ms cubic-bezier(0.16, 1, 0.3, 1) both; animation: client-profile-ping-in 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
} }
.client-profile-body.is-closing .client-server-toolbar { .client-profile-body.is-closing .client-server-toolbar {
+13 -1
View File
@@ -105,6 +105,14 @@
transform: rotate(90deg); transform: rotate(90deg);
} }
.client-profile-disclosure.is-static {
cursor: default;
}
.client-profile-disclosure.is-static .client-profile-title {
grid-column: 2;
}
.client-profile-title { .client-profile-title {
min-width: 0; min-width: 0;
display: grid; display: grid;
@@ -139,6 +147,10 @@
text-transform: var(--type-label-transform); text-transform: var(--type-label-transform);
} }
.client-profile-group.is-expired .client-profile-title small {
color: oklch(0.68 0.14 72);
}
.client-profile-title em { .client-profile-title em {
padding: 0; padding: 0;
color: var(--client-accent); color: var(--client-accent);
@@ -276,7 +288,7 @@
.client-profile-body-inner { .client-profile-body-inner {
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
padding-bottom: 4px; padding: 0 0 4px 24px;
} }
.client-profile-body .client-servers { .client-profile-body .client-servers {
+1 -1
View File
@@ -142,7 +142,7 @@ test('server picker starts simple and reveals advanced controls on demand', () =
assert.match(styles, /\.client-profile-body \.client-server-check-label \{[\s\S]*?display: inline;/); assert.match(styles, /\.client-profile-body \.client-server-check-label \{[\s\S]*?display: inline;/);
assert.match(picker, /'--server-index': Math\.min\(index, 7\)[\s\S]*'--server-exit-delay'/); assert.match(picker, /'--server-index': Math\.min\(index, 7\)[\s\S]*'--server-exit-delay'/);
assert.match(styles, /\.client-profile-body\.is-open \.client-server-row \{[\s\S]*client-profile-server-in[\s\S]*var\(--server-index\)/); assert.match(styles, /\.client-profile-body\.is-open \.client-server-row \{[\s\S]*client-profile-server-in[\s\S]*var\(--server-index\)/);
assert.match(styles, /\.client-profile-body\.is-open \.client-server-toolbar \{[\s\S]*client-profile-ping-in 300ms 620ms/); assert.match(styles, /\.client-profile-body\.is-open \.client-server-toolbar \{[\s\S]*client-profile-ping-in 420ms cubic-bezier\(0\.16, 1, 0\.3, 1\) both/);
assert.match(styles, /\.client-profile-body\.is-closing \.client-server-toolbar \{[\s\S]*client-profile-ping-out[\s\S]*\.client-profile-body\.is-closing \.client-server-row \{[\s\S]*var\(--server-exit-delay\)/); assert.match(styles, /\.client-profile-body\.is-closing \.client-server-toolbar \{[\s\S]*client-profile-ping-out[\s\S]*\.client-profile-body\.is-closing \.client-server-row \{[\s\S]*var\(--server-exit-delay\)/);
assert.doesNotMatch(styles, /\.client-profile-body \.client-server-check::after/); assert.doesNotMatch(styles, /\.client-profile-body \.client-server-check::after/);
}); });
+11 -11
View File
@@ -39,24 +39,24 @@ const acceptedLedger = {
counts: { counts: {
cascadeEdges: 822, cascadeEdges: 822,
customProperties: 103, customProperties: 103,
declarations: 3299, declarations: 3302,
important: 0, important: 0,
keyframes: 56, keyframes: 56,
media: 13, media: 13,
rules: 949, rules: 952,
variableReferences: 808, variableReferences: 808,
}, },
hashes: { hashes: {
cascadeEdges: '03bede7036ed12ad1ccb5ee41188834934f8d9acd2d02296c5945dfc195bac71', cascadeEdges: '183435ac9241f0507dd2d82eda73e71eaaee8f8dcbdc0747b83cf8e17e2ce9f7',
customProperties: 'ef70fb1d9b61b177f1939f811babe1116bee631de26f5eac0aa0d0009ca5a1fb', customProperties: 'ef70fb1d9b61b177f1939f811babe1116bee631de26f5eac0aa0d0009ca5a1fb',
declarations: 'fc35daf13ffc5a0754f4e6f4982a5ff0c09c8664dfd86fd144e12c02fb006794', declarations: '07ab8e4c442a88368e6727f0665b29a2d27a882d64c38647250a0f4f0c069f6e',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: '0fa66e370695261a2a5c6a189752c07d518d80d68d3e79a3ba4bb34407893c0e', duplicateSelectors: '0fa66e370695261a2a5c6a189752c07d518d80d68d3e79a3ba4bb34407893c0e',
keyframes: 'd4378751f36eccc87923c12540315462055032a5d34978a0f45ecada0a5cc1ce', keyframes: 'd4378751f36eccc87923c12540315462055032a5d34978a0f45ecada0a5cc1ce',
ruleDeclarationSequences: 'af072317748091c47aaf33de5d124249367ca37e5037dd58eab869ca0fc26130', ruleDeclarationSequences: '864dd1f50b04e6e07e30be6e4aa3b72d02d5425dbecf74a28afa6280ce9dfd73',
selectors: '84116a78a2c71322a4e5ea45a7cdec788ca1d0d02c46767eb1c642b7e0755754', selectors: 'fa5bb2e06dcbb72802e49d67e2312504f27e87e855427498526ea24f143f84c8',
variableReferences: '664c093241c4076ca05a9dda06ae88d4e7024c926c4636109790201df8212df4', variableReferences: '664c093241c4076ca05a9dda06ae88d4e7024c926c4636109790201df8212df4',
witnesses: 'b31a875d6be7980f6bb8eb46dac2d9665f6fe9afaa7775a4f994a0156109734d', witnesses: 'ac771c94e7f2c409c3d317de610c2dae5722eb1d6f0b6626a4e1aa986c83fd94',
}, },
}; };
@@ -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, 805); assert.equal(witnesses.length, 811);
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-9maKdwRg.css']); assert.deepEqual(assets, ['index-DbgBuebh.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, 127604); assert.equal(built.byteLength, 127802);
assert.equal(sha256(built), '0ba84e80e387ee62c33379b3674b1849ba65857cf45dac1257f4d87605008d04'); assert.equal(sha256(built), '19995a47de1ae634f29b26fb4e484f6d9fb048519cc42ad0a186d11904e2bfb6');
}); });
@@ -107,11 +107,16 @@ test('feature keeps exact slots, truthy closes and subscription DOM order', () =
test('profiles render as flat accordion groups with scoped controls', () => { test('profiles render as flat accordion groups with scoped controls', () => {
assert.match(feature, /feature\.profiles\.map\(\(profile\) => <ProfileGroup/); assert.match(feature, /feature\.profiles\.map\(\(profile\) => <ProfileGroup/);
assert.match(feature, /aria-expanded=\{expanded\}/); assert.match(feature, /aria-expanded=\{expanded\}/);
assert.match(feature, /const expired = profile\.subscription\.errorCode === 'SUBSCRIPTION_EXPIRED'/);
assert.match(feature, /profileDetails = expired[\s\S]*'Срок действия подписки истёк'[\s\S]*`Серверов:/);
assert.match(feature, /expired \? <div className="client-profile-disclosure is-static">[\s\S]*: <button[\s\S]*client-profile-chevron/);
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(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, /\{!localStatus && renderServerPicker\(profile/);
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, /\{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\}/);
assert.match(feature, /anchorServerId: visibleServer\?\.id \|\| ''/); assert.match(feature, /anchorServerId: visibleServer\?\.id \|\| ''/);
assert.match(feature, /const visibleServerId = applied[\s\S]*feature\.selection\.appliedServerId[\s\S]*desired \? profile\.desiredServerId : ''/); assert.match(feature, /const visibleServerId = applied[\s\S]*feature\.selection\.appliedServerId[\s\S]*desired \? profile\.desiredServerId : ''/);
assert.match(feature, /selectedServerId: visibleServerId/); assert.match(feature, /selectedServerId: visibleServerId/);
@@ -119,11 +124,12 @@ test('profiles render as flat accordion groups with scoped controls', () => {
assert.doesNotMatch(page, /onActivateProfile/); 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.doesNotMatch(feature, /<strong>\{profile\.label\}<\/strong>|Переименовать|client-profile-menu/); assert.doesNotMatch(feature, /<strong>\{profile\.label\}<\/strong>|Переименовать|client-profile-menu/);
assert.match(styles, /\.client-profile-group/); assert.match(styles, /\.client-profile-group/);
assert.match(styles, /\.client-profile-body/); assert.match(styles, /\.client-profile-body/);
assert.match(styles, /\.client-profile-body \{[\s\S]*grid-template-rows: 0fr[\s\S]*\.client-profile-body\.is-open \{[\s\S]*grid-template-rows: 1fr/); assert.match(styles, /\.client-profile-body \{[\s\S]*grid-template-rows: 0fr[\s\S]*\.client-profile-body\.is-open \{[\s\S]*grid-template-rows: 1fr/);
assert.match(styles, /\.client-profile-body-inner \{[\s\S]*padding: 0 0 4px 24px/);
assert.match(styles, /\.client-profile-group\.is-expired \.client-profile-title small \{[\s\S]*color: oklch\(0\.68 0\.14 72\)/);
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/);