From b485cbf17526949a637ad7affd0e3001465621fb Mon Sep 17 00:00:00 2001 From: Devin Holland <50112339+Devin-Holland@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:08:44 -0400 Subject: [PATCH 1/4] feat: scope cluster version selector to org-deployed versions Pass the current organizationId into the /HarperVersions query so the deploy version selector can include versions currently deployed on the org's clusters (the backend merges them in, tagged `deployed`, for enterprise orgs). - getHarperVersions / getHarperVersionsOptions take an optional organizationId, appended as a query param and folded into the query key so the cache is scoped per org. - UpsertCluster passes the route's organizationId through. Requires the companion central-manager change adding organizationId to GET /HarperVersions. Co-Authored-By: Claude Opus 4.8 --- .../queries/getHarperVersionsQuery.test.ts | 26 ++++++++++++++++++- .../queries/getHarperVersionsQuery.ts | 15 +++++++---- src/features/clusters/upsert/index.tsx | 2 +- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/features/clusters/queries/getHarperVersionsQuery.test.ts b/src/features/clusters/queries/getHarperVersionsQuery.test.ts index fb537ef39..c13e61653 100644 --- a/src/features/clusters/queries/getHarperVersionsQuery.test.ts +++ b/src/features/clusters/queries/getHarperVersionsQuery.test.ts @@ -90,11 +90,16 @@ describe('dedupeHarperVersionsByTag', () => { describe('getHarperVersionsOptions', () => { it('configures the query to hit the HarperVersions cache key without retrying', () => { const options = getHarperVersionsOptions(); - expect(options.queryKey).toEqual(['HarperVersions']); + expect(options.queryKey).toEqual(['HarperVersions', null]); expect(options.staleTime).toBe(60_000); expect(options.retry).toBe(false); }); + it('scopes the cache key to the organization when one is given', () => { + const options = getHarperVersionsOptions('org_123'); + expect(options.queryKey).toEqual(['HarperVersions', 'org_123']); + }); + it('fetches the versions and dedupes overlapping tags, keeping the rest of the response', async () => { mockedGet.mockResolvedValue({ data: { @@ -122,6 +127,25 @@ describe('getHarperVersionsOptions', () => { }); }); + it('passes the organizationId as a query param (url-encoded) when scoped to an org', async () => { + mockedGet.mockResolvedValue({ + data: { + name: 'Harper Versions', + description: 'Available Harper versions', + value: [ + { name: 'stable', version: '5.1.21' }, + { name: 'deployed', version: '5.0.8' }, + ], + } satisfies HarperVersionsResponse, + }); + + const options = getHarperVersionsOptions('org/1'); + const result = await (options.queryFn as () => Promise)(); + + expect(mockedGet).toHaveBeenCalledWith('/HarperVersions/?organizationId=org%2F1'); + expect(tags(result.value)).toEqual(['5.1.21 stable', '5.0.8 deployed']); + }); + it('surfaces (rather than swallows) a malformed response with no value array', async () => { // The endpoint's OpenAPI description is broken, so the `as HarperVersionsResponse` cast is not // schema-backed. If it ever returns a body without `value`, the queryFn throws — React Query's diff --git a/src/features/clusters/queries/getHarperVersionsQuery.ts b/src/features/clusters/queries/getHarperVersionsQuery.ts index 7140bbb80..9478fcceb 100644 --- a/src/features/clusters/queries/getHarperVersionsQuery.ts +++ b/src/features/clusters/queries/getHarperVersionsQuery.ts @@ -45,9 +45,14 @@ export function dedupeHarperVersionsByTag(versions: HarperVersion[]): HarperVers return [...bestByVersion.values()]; } -async function getHarperVersions() { +async function getHarperVersions(organizationId?: string) { // TODO: OpenAPI from CM is erroring, so this new endpoint isn't described. - const { data } = await apiClient.get(`/HarperVersions/` as any); + // When an organizationId is passed, enterprise orgs also get the versions currently + // deployed on their clusters (tagged `deployed`) merged into the list server-side. + const path = organizationId + ? `/HarperVersions/?organizationId=${encodeURIComponent(organizationId)}` + : `/HarperVersions/`; + const { data } = await apiClient.get(path as any); const response = data as HarperVersionsResponse; return { ...response, @@ -55,10 +60,10 @@ async function getHarperVersions() { } satisfies HarperVersionsResponse; } -export function getHarperVersionsOptions() { +export function getHarperVersionsOptions(organizationId?: string) { return queryOptions({ - queryKey: ['HarperVersions'], - queryFn: getHarperVersions, + queryKey: ['HarperVersions', organizationId ?? null], + queryFn: () => getHarperVersions(organizationId), staleTime: 60_000, retry: false, }); diff --git a/src/features/clusters/upsert/index.tsx b/src/features/clusters/upsert/index.tsx index f9b6ae651..30fa8a3a6 100644 --- a/src/features/clusters/upsert/index.tsx +++ b/src/features/clusters/upsert/index.tsx @@ -73,7 +73,7 @@ export function UpsertCluster() { })); const { data: regionLocationsDedicated } = useQuery(getRegionLocationsOptions({ organizationId })); - const { data: newHarperVersions } = useQuery(getHarperVersionsOptions()); + const { data: newHarperVersions } = useQuery(getHarperVersionsOptions(organizationId)); const harperVersions = useMemo(() => { if (cluster) { const clusterVersions = cluster.instances?.map(i => i.version).filter(excludeFalsy); From 122b76704bc250bd0baa90c921b839ef1af62917 Mon Sep 17 00:00:00 2001 From: Devin Holland <50112339+Devin-Holland@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:10:08 -0400 Subject: [PATCH 2/4] fix: exclude all of the edited cluster's versions from the picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The edit-flow version picker injects a synthetic "current" entry for the cluster being edited. Now that the backend also returns org-deployed versions, the current cluster's own version comes back as a "deployed on " entry — so exclude every version the edited cluster runs (not just the max) to avoid a duplicate row. Also copy the array before sort() (sort mutates) so the full instance-version set is intact for the exclusion. Co-Authored-By: Claude Opus 4.8 --- src/features/clusters/upsert/index.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/features/clusters/upsert/index.tsx b/src/features/clusters/upsert/index.tsx index 30fa8a3a6..ca30713a3 100644 --- a/src/features/clusters/upsert/index.tsx +++ b/src/features/clusters/upsert/index.tsx @@ -78,7 +78,9 @@ export function UpsertCluster() { if (cluster) { const clusterVersions = cluster.instances?.map(i => i.version).filter(excludeFalsy); if (newHarperVersions && clusterVersions) { - const latestClusterVersion = clusterVersions.sort(compareVersions).pop(); + // Copy before sort — sort mutates in place, and we reuse the full set below. + const latestClusterVersion = [...clusterVersions].sort(compareVersions).pop(); + const clusterVersionSet = new Set(clusterVersions); return { ...newHarperVersions, value: [ @@ -87,13 +89,12 @@ export function UpsertCluster() { version: latestClusterVersion, } as const, ...(newHarperVersions?.value || []).filter(v => { - // Is our version unique from the latest cluster version? - return latestClusterVersion !== v.version - // Do we have a cluster version? - && (!latestClusterVersion - // Or if we do, have we updated to a higher version already? - // This can prevent upgrading to, say, "next" v5, and then downgrading to the "latest" v4. - || wasAReleasedBeforeB(latestClusterVersion, v.version)); + // Drop any version this cluster already runs: the current version is shown once as + // "current" above, and the backend also returns it (and any co-tenant instance's + // version, mid-upgrade) as a "deployed on " entry we don't want to duplicate. + return !clusterVersionSet.has(v.version) + // Only offer newer releases — no downgrades (e.g. don't drop from "next" v5 to "stable" v4). + && (!latestClusterVersion || wasAReleasedBeforeB(latestClusterVersion, v.version)); }), ].filter(excludeFalsy), } satisfies HarperVersionsResponse; From d6cb4a811f45a8d80707434e13254b56630bfd9d Mon Sep 17 00:00:00 2001 From: Devin Holland <50112339+Devin-Holland@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:00:17 -0400 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20address=20review=20=E2=80=94=20?= =?UTF-8?q?org-scoped=20versions=20query=20is=20required=20+=20uses=20axio?= =?UTF-8?q?s=20params?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review (dawsontoth): - Make organizationId a required parameter of getHarperVersions/getHarperVersionsOptions — the picker never requests versions without an org, so require it (matches the sibling getPlanTypesOptions pattern) and let it splash outward to the route. - Pass organizationId via axios `params` instead of hand-building the query string (axios handles encoding). Co-Authored-By: Claude Opus 4.8 --- .../queries/getHarperVersionsQuery.test.ts | 25 ++++++++----------- .../queries/getHarperVersionsQuery.ts | 17 ++++++------- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/src/features/clusters/queries/getHarperVersionsQuery.test.ts b/src/features/clusters/queries/getHarperVersionsQuery.test.ts index c13e61653..2475e7317 100644 --- a/src/features/clusters/queries/getHarperVersionsQuery.test.ts +++ b/src/features/clusters/queries/getHarperVersionsQuery.test.ts @@ -88,16 +88,11 @@ describe('dedupeHarperVersionsByTag', () => { }); describe('getHarperVersionsOptions', () => { - it('configures the query to hit the HarperVersions cache key without retrying', () => { - const options = getHarperVersionsOptions(); - expect(options.queryKey).toEqual(['HarperVersions', null]); - expect(options.staleTime).toBe(60_000); - expect(options.retry).toBe(false); - }); - - it('scopes the cache key to the organization when one is given', () => { + it('scopes the cache key to the organization and does not retry', () => { const options = getHarperVersionsOptions('org_123'); expect(options.queryKey).toEqual(['HarperVersions', 'org_123']); + expect(options.staleTime).toBe(60_000); + expect(options.retry).toBe(false); }); it('fetches the versions and dedupes overlapping tags, keeping the rest of the response', async () => { @@ -113,10 +108,10 @@ describe('getHarperVersionsOptions', () => { } satisfies HarperVersionsResponse, }); - const options = getHarperVersionsOptions(); + const options = getHarperVersionsOptions('org_1'); const result = await (options.queryFn as () => Promise)(); - expect(mockedGet).toHaveBeenCalledWith('/HarperVersions/'); + expect(mockedGet).toHaveBeenCalledWith('/HarperVersions/', { params: { organizationId: 'org_1' } }); expect(result).toEqual({ name: 'Harper Versions', description: 'Available Harper versions', @@ -127,14 +122,14 @@ describe('getHarperVersionsOptions', () => { }); }); - it('passes the organizationId as a query param (url-encoded) when scoped to an org', async () => { + it('passes the organizationId through axios params (axios handles encoding)', async () => { mockedGet.mockResolvedValue({ data: { name: 'Harper Versions', description: 'Available Harper versions', value: [ { name: 'stable', version: '5.1.21' }, - { name: 'deployed', version: '5.0.8' }, + { name: 'deployed on prod-east', version: '5.0.8' }, ], } satisfies HarperVersionsResponse, }); @@ -142,8 +137,8 @@ describe('getHarperVersionsOptions', () => { const options = getHarperVersionsOptions('org/1'); const result = await (options.queryFn as () => Promise)(); - expect(mockedGet).toHaveBeenCalledWith('/HarperVersions/?organizationId=org%2F1'); - expect(tags(result.value)).toEqual(['5.1.21 stable', '5.0.8 deployed']); + expect(mockedGet).toHaveBeenCalledWith('/HarperVersions/', { params: { organizationId: 'org/1' } }); + expect(tags(result.value)).toEqual(['5.1.21 stable', '5.0.8 deployed on prod-east']); }); it('surfaces (rather than swallows) a malformed response with no value array', async () => { @@ -153,7 +148,7 @@ describe('getHarperVersionsOptions', () => { // fails safely rather than silently rendering an empty picker. mockedGet.mockResolvedValue({ data: { name: 'Harper Versions', description: '' } }); - const options = getHarperVersionsOptions(); + const options = getHarperVersionsOptions('org_1'); await expect((options.queryFn as () => Promise)()).rejects.toThrow(); }); diff --git a/src/features/clusters/queries/getHarperVersionsQuery.ts b/src/features/clusters/queries/getHarperVersionsQuery.ts index 9478fcceb..58454837e 100644 --- a/src/features/clusters/queries/getHarperVersionsQuery.ts +++ b/src/features/clusters/queries/getHarperVersionsQuery.ts @@ -45,14 +45,13 @@ export function dedupeHarperVersionsByTag(versions: HarperVersion[]): HarperVers return [...bestByVersion.values()]; } -async function getHarperVersions(organizationId?: string) { +async function getHarperVersions(organizationId: string) { // TODO: OpenAPI from CM is erroring, so this new endpoint isn't described. - // When an organizationId is passed, enterprise orgs also get the versions currently - // deployed on their clusters (tagged `deployed`) merged into the list server-side. - const path = organizationId - ? `/HarperVersions/?organizationId=${encodeURIComponent(organizationId)}` - : `/HarperVersions/`; - const { data } = await apiClient.get(path as any); + // The list is org-scoped: enterprise orgs also get the versions currently deployed on their + // clusters (labeled with the cluster) merged in server-side. + const { data } = await apiClient.get(`/HarperVersions/` as any, { + params: { organizationId }, + }); const response = data as HarperVersionsResponse; return { ...response, @@ -60,9 +59,9 @@ async function getHarperVersions(organizationId?: string) { } satisfies HarperVersionsResponse; } -export function getHarperVersionsOptions(organizationId?: string) { +export function getHarperVersionsOptions(organizationId: string) { return queryOptions({ - queryKey: ['HarperVersions', organizationId ?? null], + queryKey: ['HarperVersions', organizationId], queryFn: () => getHarperVersions(organizationId), staleTime: 60_000, retry: false, From 7b8675e623d7f65078d093b3a8e0be17b3d6763d Mon Sep 17 00:00:00 2001 From: Devin Holland <50112339+Devin-Holland@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:03:29 -0400 Subject: [PATCH 4/4] refactor: org-first HarperVersions cache key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review (DavidCockerill, dawsontoth): use an org-first query key `[organizationId, 'HarperVersions']` to match the sibling queries (getPlanTypesQuery, getRegionLocationsQuery, …). Beyond consistency, this makes the list participate in the repo's org-scoped cache busting (`invalidateQueries({ queryKey: [organizationId] })`, used after cluster ops) — with the old namespace-first key, prefix matching skipped it, so deployed versions could go stale after a cluster change. Co-Authored-By: Claude Opus 4.8 --- src/features/clusters/queries/getHarperVersionsQuery.test.ts | 5 +++-- src/features/clusters/queries/getHarperVersionsQuery.ts | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/features/clusters/queries/getHarperVersionsQuery.test.ts b/src/features/clusters/queries/getHarperVersionsQuery.test.ts index 2475e7317..585ba0105 100644 --- a/src/features/clusters/queries/getHarperVersionsQuery.test.ts +++ b/src/features/clusters/queries/getHarperVersionsQuery.test.ts @@ -88,9 +88,10 @@ describe('dedupeHarperVersionsByTag', () => { }); describe('getHarperVersionsOptions', () => { - it('scopes the cache key to the organization and does not retry', () => { + it('scopes the cache key to the organization (org-first) and does not retry', () => { const options = getHarperVersionsOptions('org_123'); - expect(options.queryKey).toEqual(['HarperVersions', 'org_123']); + // Org-first so it participates in org-scoped `invalidateQueries([organizationId])`. + expect(options.queryKey).toEqual(['org_123', 'HarperVersions']); expect(options.staleTime).toBe(60_000); expect(options.retry).toBe(false); }); diff --git a/src/features/clusters/queries/getHarperVersionsQuery.ts b/src/features/clusters/queries/getHarperVersionsQuery.ts index 58454837e..2bf6bbb26 100644 --- a/src/features/clusters/queries/getHarperVersionsQuery.ts +++ b/src/features/clusters/queries/getHarperVersionsQuery.ts @@ -61,7 +61,10 @@ async function getHarperVersions(organizationId: string) { export function getHarperVersionsOptions(organizationId: string) { return queryOptions({ - queryKey: ['HarperVersions', organizationId], + // Org-first, matching the sibling queries (getPlanTypesQuery, getRegionLocationsQuery, …) so + // org-scoped invalidations — `queryClient.invalidateQueries({ queryKey: [organizationId] })`, + // used after cluster ops that can change deployed versions — also refresh this list. + queryKey: [organizationId, 'HarperVersions'], queryFn: () => getHarperVersions(organizationId), staleTime: 60_000, retry: false,