From 8eefb692d7debc92a911a9b1ff4a000eabce4f0f Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Sun, 11 Oct 2026 02:19:55 -0400 Subject: [PATCH 1/8] feat(deployments): filter and page the list, show staged releases and how each node installed The list filters by component and status, pages by the server's total, and shows each record's id, source (package, payload hash, or the release an activation made live) and user. A staged record (Harper 5.3+) is terminal and reads as waiting to be activated, so the list no longer polls it every 2s. The detail view shows the origin's install source, the activation's source release, and each peer's install comparison (5.3.1+), and drops the Restorable and Rollback-of fields no Harper version sets. The editor's Application menu links to the component's deployments. Refs #1786 Co-Authored-By: Claude Opus 5.5 --- .../components/ContentActions.tsx | 29 +++- .../hooks/useSupportsDeploymentSSE.ts | 10 ++ .../components/DeploymentDetail.tsx | 57 ++++++-- .../components/deploymentStatusBadge.tsx | 6 +- .../deployments/constants/tableDefinition.tsx | 48 +++++-- .../deployments/deploymentsSearch.test.ts | 28 ++++ .../config/deployments/deploymentsSearch.ts | 39 +++++ .../functions/deploymentDescriptions.test.ts | 52 +++++++ .../deployments/functions/deploymentSource.ts | 25 ++++ .../functions/describePeerInstall.ts | 26 ++++ .../config/deployments/index.test.tsx | 88 +++++++++++- .../instance/config/deployments/index.tsx | 133 ++++++++++++++++-- src/features/instance/config/routes.ts | 3 + .../deployments/deploymentPolling.test.ts | 3 + .../api/instance/deployments/types.test.ts | 1 + .../api/instance/deployments/types.ts | 27 +++- 16 files changed, 525 insertions(+), 50 deletions(-) create mode 100644 src/features/instance/config/deployments/deploymentsSearch.test.ts create mode 100644 src/features/instance/config/deployments/deploymentsSearch.ts create mode 100644 src/features/instance/config/deployments/functions/deploymentDescriptions.test.ts create mode 100644 src/features/instance/config/deployments/functions/deploymentSource.ts create mode 100644 src/features/instance/config/deployments/functions/describePeerInstall.ts diff --git a/src/features/instance/applications/components/ContentActions.tsx b/src/features/instance/applications/components/ContentActions.tsx index 6638d13dd..f63f7ecad 100644 --- a/src/features/instance/applications/components/ContentActions.tsx +++ b/src/features/instance/applications/components/ContentActions.tsx @@ -16,10 +16,13 @@ import { useEditorFileContent } from '@/features/instance/applications/context/e import { isDirectory } from '@/features/instance/applications/context/isDirectory'; import { useEditorView } from '@/features/instance/applications/hooks/useEditorView'; import { useEntryActions } from '@/features/instance/applications/hooks/useEntryActions'; -import { useInstanceBrowseManagePermission } from '@/hooks/usePermissions'; +import { useDeploymentsAvailable } from '@/features/instance/applications/hooks/useSupportsDeploymentSSE'; +import { useInstanceBrowseManagePermission, useInstanceManagePermission } from '@/hooks/usePermissions'; import { useRestartInstanceClick } from '@/hooks/useRestartInstanceClick'; import { emitToListeners, useEmitToListeners } from '@/lib/events/listener'; import { useSetWatchedValue, useWatchedValue } from '@/lib/events/watcher'; +import { buildAbsoluteLinkToPage } from '@/lib/urls/buildAbsoluteLinkToPage'; +import { useNavigate, useParams } from '@tanstack/react-router'; import { ArrowLeftIcon, ArrowRightIcon, @@ -28,6 +31,7 @@ import { DownloadIcon, FilePlusIcon, FolderPlusIcon, + HistoryIcon, LockIcon, PackageIcon, PanelRightCloseIcon, @@ -73,6 +77,14 @@ export function ContentActions({ const { openedEntryContents, openedEntry, isSavingFile, restrictPackageModification } = useEditorView(); const { content: updatedFileContent } = useEditorFileContent(openedEntry?.path); const canManageBrowseInstance = useInstanceBrowseManagePermission(); + // The Deployments page itself needs super-user and Harper 5.1. + const canManageInstance = useInstanceManagePermission(); + const deploymentsAvailable = useDeploymentsAvailable(); + const canViewDeployments = canManageInstance && deploymentsAvailable; + const navigate = useNavigate(); + const routeParams: { clusterId?: string; instanceId?: string; organizationId?: string } = useParams({ + strict: false, + }); const { onRestartClick, isRestartPending } = useRestartInstanceClick({ operation: 'restart_service', instanceClient: instanceParams.instanceClient, @@ -133,7 +145,8 @@ export function ContentActions({ useEntryActions(openedEntry); const showFileMenu = canEditFile || canRename || canAddEntries || canAddTable || canDeleteEntry; - const showApplicationMenu = canDownload || canManageBrowseInstance || canRedeploy; + const deploymentsProject = canViewDeployments && openedEntry?.project ? openedEntry.project : undefined; + const showApplicationMenu = canDownload || canManageBrowseInstance || canRedeploy || !!deploymentsProject; // The "File" menu acts on whatever is selected, so name it for that thing. const fileMenuLabel = openedEntry && isDirectory(openedEntry) ? 'Directory' : 'File'; @@ -389,6 +402,18 @@ export function ContentActions({ Redeploy Package )} + {deploymentsProject && ( + + void navigate({ + to: buildAbsoluteLinkToPage(routeParams, 'config/deployments'), + search: { project: deploymentsProject }, + })} + > + + View Deployments + + )} )} diff --git a/src/features/instance/applications/hooks/useSupportsDeploymentSSE.ts b/src/features/instance/applications/hooks/useSupportsDeploymentSSE.ts index deedf2c6b..d34e7ccdd 100644 --- a/src/features/instance/applications/hooks/useSupportsDeploymentSSE.ts +++ b/src/features/instance/applications/hooks/useSupportsDeploymentSSE.ts @@ -15,6 +15,16 @@ export function supportsDeployments(harperVersion: string | undefined): boolean return !!harperVersion && wasAReleasedBeforeB(MIN_DEPLOYMENT_SSE_VERSION, harperVersion); } +/** + * Staging (`activate: false`), activation by `deployment_id`, and the `staged` status ship in 5.3.0 (harper + * `v5.3.0:components/operations.js:638`). A 5.3.0 prerelease is refused, as `supportsPutOperation` refuses one. + */ +export const MIN_STAGED_DEPLOYMENT_VERSION = '5.3.0'; + +export function supportsStagedDeployments(harperVersion: string | undefined): boolean { + return !!harperVersion && wasAReleasedBeforeB(MIN_STAGED_DEPLOYMENT_VERSION, harperVersion); +} + /** * True when the instance/cluster is Harper >= 5.1.0, so the deployment history/detail * feature (`list_deployments` / `get_deployment`) is available. This gates feature diff --git a/src/features/instance/config/deployments/components/DeploymentDetail.tsx b/src/features/instance/config/deployments/components/DeploymentDetail.tsx index 2f68691b5..02afaf76e 100644 --- a/src/features/instance/config/deployments/components/DeploymentDetail.tsx +++ b/src/features/instance/config/deployments/components/DeploymentDetail.tsx @@ -3,14 +3,18 @@ import { Separator } from '@/components/ui/separator'; import { useInstanceClientIdParams } from '@/config/useInstanceClient'; import { DeployProgress } from '@/features/instance/applications/components/DeployProgress/DeployProgress'; import { useDeploymentStream } from '@/features/instance/applications/components/DeployProgress/useDeploymentStream'; +import { deploymentRecordPath } from '@/features/instance/applications/hooks/useDeploymentRecordAction'; import { useSupportsDeploymentSSE } from '@/features/instance/applications/hooks/useSupportsDeploymentSSE'; import { getDeploymentQueryOptions, getDeploymentStream } from '@/integrations/api/instance/deployments/getDeployment'; import { Deployment, isTerminalDeploymentStatus } from '@/integrations/api/instance/deployments/types'; +import { cn } from '@/lib/cn'; import { errorText } from '@/lib/errorText'; import { humanFileSize } from '@/lib/humanFileSize'; import { translateSecondsToAgo } from '@/lib/translateSecondsToAgo'; import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Link, useParams } from '@tanstack/react-router'; import { useEffect } from 'react'; +import { describePeerInstall } from '../functions/describePeerInstall'; import { DeploymentStatusBadge } from './deploymentStatusBadge'; function Field({ label, value }: { label: string; value: React.ReactNode }) { @@ -31,6 +35,7 @@ function timestamp(ms?: number) { export function DeploymentDetail({ deploymentId }: { deploymentId: string }) { const instanceParams = useInstanceClientIdParams(); + const params: { clusterId?: string; instanceId?: string; organizationId?: string } = useParams({ strict: false }); const sseSupported = useSupportsDeploymentSSE(); const queryClient = useQueryClient(); const stream = useDeploymentStream(); @@ -113,17 +118,32 @@ export function DeploymentDetail({ deploymentId }: { deploymentId: string }) { : undefined} /> {deployment.install_fingerprint.source} + : undefined} /> {deployment.rollback_of} + label="Activation of" + value={deployment.activated_from + ? ( + + {deployment.activated_from} + + ) : undefined} /> + {deployment.status === 'staged' && ( +
+ Staged: built and installed, but not live. Activating it makes it live with nothing to install. +
+ )} + {showLiveProgress && ( <> @@ -152,17 +172,30 @@ export function DeploymentDetail({ deploymentId }: { deploymentId: string }) { <>
-
Replication
-
    +
    Other nodes
    +
      {deployment.peer_results.map((peer, i) => { const peerError = errorText(peer.error); + const install = describePeerInstall(peer); return ( -
    • - {peer.node} - - {peer.status ?? 'ok'} - {peerError ? `: ${peerError}` : ''} +
    • + + {peer.node} + + {peer.status ?? 'ok'} + {peerError ? `: ${peerError}` : ''} + + {install && ( + + {install.label} + + )}
    • ); })} diff --git a/src/features/instance/config/deployments/components/deploymentStatusBadge.tsx b/src/features/instance/config/deployments/components/deploymentStatusBadge.tsx index eb4f0ff4b..7033913c3 100644 --- a/src/features/instance/config/deployments/components/deploymentStatusBadge.tsx +++ b/src/features/instance/config/deployments/components/deploymentStatusBadge.tsx @@ -13,9 +13,10 @@ const STATUS_VARIANT: Record = { success: 'success', failed: 'destructive', rolled_back: 'destructive', + staged: 'secondary', }; -const STATUS_LABEL: Record = { +export const DEPLOYMENT_STATUS_LABEL: Record = { pending: 'Pending', extracting: 'Extracting', installing: 'Installing', @@ -25,8 +26,9 @@ const STATUS_LABEL: Record = { success: 'Success', failed: 'Failed', rolled_back: 'Rolled back', + staged: 'Staged', }; export function DeploymentStatusBadge({ status }: { status: DeploymentStatus }) { - return {STATUS_LABEL[status] ?? status}; + return {DEPLOYMENT_STATUS_LABEL[status] ?? status}; } diff --git a/src/features/instance/config/deployments/constants/tableDefinition.tsx b/src/features/instance/config/deployments/constants/tableDefinition.tsx index 81a03dfb7..83cfc3446 100644 --- a/src/features/instance/config/deployments/constants/tableDefinition.tsx +++ b/src/features/instance/config/deployments/constants/tableDefinition.tsx @@ -2,6 +2,7 @@ import { Deployment } from '@/integrations/api/instance/deployments/types'; import { ColumnDef, createColumnHelper } from '@/lib/table'; import { translateSecondsToAgo } from '@/lib/translateSecondsToAgo'; import { DeploymentStatusBadge } from '../components/deploymentStatusBadge'; +import { deploymentSource, shortDeploymentId } from '../functions/deploymentSource'; const columnHelper = createColumnHelper(); @@ -10,8 +11,18 @@ function ago(ms?: number) { } export const deploymentColumns: Array> = [ + columnHelper.display({ + header: 'Deployment', + id: 'deployment_id', + enableSorting: false, + cell: (props) => ( + + {shortDeploymentId(props.row.original.deployment_id)} + + ), + }), { - header: 'Project', + header: 'Component', accessorKey: 'project', enableSorting: false, }, @@ -19,28 +30,35 @@ export const deploymentColumns: Array> = [ header: 'Status', id: 'status', enableSorting: false, - cell: (props) => , - }), - columnHelper.display({ - header: 'Started', - id: 'started_at', - enableSorting: false, - cell: (props) => ago(props.row.original.started_at), + cell: (props) => ( + + + {props.row.original.status === 'staged' && ( + waiting to be activated + )} + + ), }), columnHelper.display({ - header: 'Completed', - id: 'completed_at', + header: 'Source', + id: 'source', enableSorting: false, - cell: (props) => ago(props.row.original.completed_at), + cell: (props) => { + const source = deploymentSource(props.row.original); + return source + ? {source.label} + : '—'; + }, }), { header: 'User', accessorKey: 'user', enableSorting: false, }, - { - header: 'Origin', - accessorKey: 'origin_node', + columnHelper.display({ + header: 'Started', + id: 'started_at', enableSorting: false, - }, + cell: (props) => ago(props.row.original.started_at), + }), ]; diff --git a/src/features/instance/config/deployments/deploymentsSearch.test.ts b/src/features/instance/config/deployments/deploymentsSearch.test.ts new file mode 100644 index 000000000..347e1e639 --- /dev/null +++ b/src/features/instance/config/deployments/deploymentsSearch.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { filterableStatuses, validateDeploymentsSearch } from './deploymentsSearch'; + +describe('validateDeploymentsSearch', () => { + it('keeps a component, a known status and a later page', () => { + expect(validateDeploymentsSearch({ project: ' web ', status: 'staged', page: '3' })) + .toEqual({ project: 'web', status: 'staged', page: 3 }); + }); + + it.each([ + ['an empty component', { project: ' ' }], + ['a status Harper does not filter on', { status: 'bogus' }], + ['the first page, which is the default', { page: 1 }], + ['a page that is not a whole number', { page: '2.5' }], + ['a negative page', { page: -2 }], + ['a component that is not text', { project: ['web'] }], + ])('drops %s', (_label, search) => { + expect(validateDeploymentsSearch(search)).toEqual({ project: undefined, status: undefined, page: undefined }); + }); +}); + +describe('filterableStatuses', () => { + it('offers staged only where staging exists (Harper 5.3.0+)', () => { + expect(filterableStatuses(true)).toContain('staged'); + expect(filterableStatuses(false)).not.toContain('staged'); + expect(filterableStatuses(false)).toContain('success'); + }); +}); diff --git a/src/features/instance/config/deployments/deploymentsSearch.ts b/src/features/instance/config/deployments/deploymentsSearch.ts new file mode 100644 index 000000000..bf555e0e3 --- /dev/null +++ b/src/features/instance/config/deployments/deploymentsSearch.ts @@ -0,0 +1,39 @@ +import { DeploymentStatus } from '@/integrations/api/instance/deployments/types'; + +/** The Deployments page's filters and page, kept in the URL so a link can open it filtered. */ +export interface DeploymentsSearch { + project?: string; + status?: DeploymentStatus; + /** 1-based. */ + page?: number; +} + +/** What `list_deployments` can filter on. `staged` only exists from Harper 5.3.0. */ +export const FILTERABLE_STATUSES: readonly DeploymentStatus[] = [ + 'pending', + 'extracting', + 'installing', + 'loading', + 'replicating', + 'restarting', + 'success', + 'failed', + 'staged', +]; + +export function filterableStatuses(stagingSupported: boolean): readonly DeploymentStatus[] { + return stagingSupported ? FILTERABLE_STATUSES : FILTERABLE_STATUSES.filter((status) => status !== 'staged'); +} + +export const DEPLOYMENTS_PAGE_SIZE = 25; + +export function validateDeploymentsSearch(search: Record): DeploymentsSearch { + const project = typeof search.project === 'string' ? search.project.trim() : ''; + const status = FILTERABLE_STATUSES.find((candidate) => candidate === search.status); + const page = Number(search.page); + return { + project: project || undefined, + status, + page: Number.isInteger(page) && page > 1 ? page : undefined, + }; +} diff --git a/src/features/instance/config/deployments/functions/deploymentDescriptions.test.ts b/src/features/instance/config/deployments/functions/deploymentDescriptions.test.ts new file mode 100644 index 000000000..f702becd9 --- /dev/null +++ b/src/features/instance/config/deployments/functions/deploymentDescriptions.test.ts @@ -0,0 +1,52 @@ +import { Deployment } from '@/integrations/api/instance/deployments/types'; +import { describe, expect, it } from 'vitest'; +import { deploymentSource } from './deploymentSource'; +import { describePeerInstall } from './describePeerInstall'; + +const base = { deployment_id: 'd', project: 'web', status: 'success' } as Deployment; + +describe('deploymentSource', () => { + it('names the package a deploy came from', () => { + expect(deploymentSource({ ...base, package_identifier: 'github:acme/web#v2' })) + .toEqual({ label: 'github:acme/web#v2', title: 'github:acme/web#v2' }); + }); + + it("names a payload deploy by its tarball's hash", () => { + expect(deploymentSource({ ...base, package_identifier: null, payload_hash: 'abcdef0123456789abcdef' })) + .toEqual({ label: 'Payload abcdef012345', title: 'abcdef0123456789abcdef' }); + }); + + it('names the release an activation made live, which built nothing', () => { + expect(deploymentSource({ ...base, activated_from: 'a1b2c3d4-0000-4000-8000-000000000001' })) + .toEqual({ label: 'Activation of a1b2c3d4', title: 'a1b2c3d4-0000-4000-8000-000000000001' }); + }); + + it('names nothing for a record that says nothing', () => { + expect(deploymentSource(base)).toBeUndefined(); + }); +}); + +// The comparison fields Harper records per peer: harper `components/deploymentRecorder.ts` installComparisonFields. +describe('describePeerInstall', () => { + it('says nothing for a record from before installs were compared (pre-5.3.1)', () => { + expect(describePeerInstall({ node: 'node-b', status: 'success' })).toBeUndefined(); + }); + + it('names what a peer installed differently', () => { + expect(describePeerInstall({ + node: 'node-b', + install: { source: 'npm:web@1.5.0', lockfiles: {} }, + install_matches: false, + install_differs: ['source', 'package-lock.json'], + })).toEqual({ label: 'Installed differently: source npm:web@1.5.0, package-lock.json.', matches: false }); + }); + + it('says a matching install matched', () => { + expect(describePeerInstall({ node: 'node-b', install_matches: true, install_differs: [] })?.matches).toBe(true); + }); + + it('does not read missing evidence as a match', () => { + expect(describePeerInstall({ node: 'node-b', install: null, install_matches: null, install_differs: [] })) + .toEqual({ label: "Couldn't compare its install: a fingerprint is missing or unidentified.", matches: null }); + }); +}); diff --git a/src/features/instance/config/deployments/functions/deploymentSource.ts b/src/features/instance/config/deployments/functions/deploymentSource.ts new file mode 100644 index 000000000..b7641e005 --- /dev/null +++ b/src/features/instance/config/deployments/functions/deploymentSource.ts @@ -0,0 +1,25 @@ +import { Deployment } from '@/integrations/api/instance/deployments/types'; + +export function shortDeploymentId(id: string): string { + return id.length > 8 ? id.slice(0, 8) : id; +} + +/** + * Where a release came from. A package deploy records its reference and a payload deploy its tarball's hash; an + * activation (5.3.0+) builds nothing, so it names the deployment whose release it made live. + */ +export function deploymentSource(deployment: Deployment): { label: string; title: string } | undefined { + if (deployment.activated_from) { + return { + label: `Activation of ${shortDeploymentId(deployment.activated_from)}`, + title: deployment.activated_from, + }; + } + if (deployment.package_identifier) { + return { label: deployment.package_identifier, title: deployment.package_identifier }; + } + if (deployment.payload_hash) { + return { label: `Payload ${deployment.payload_hash.slice(0, 12)}`, title: deployment.payload_hash }; + } + return undefined; +} diff --git a/src/features/instance/config/deployments/functions/describePeerInstall.ts b/src/features/instance/config/deployments/functions/describePeerInstall.ts new file mode 100644 index 000000000..c66ef2378 --- /dev/null +++ b/src/features/instance/config/deployments/functions/describePeerInstall.ts @@ -0,0 +1,26 @@ +import { DeploymentPeerResult } from '@/integrations/api/instance/deployments/types'; + +/** + * Whether a node installed what the node that received the deploy did (5.3.1+). Harper adds the comparison only + * on a deploy or a stage that compared fingerprints, so a record without it says nothing either way. + */ +export function describePeerInstall( + peer: DeploymentPeerResult, +): { label: string; matches: boolean | null } | undefined { + if (!('install_matches' in peer)) { + return undefined; + } + if (peer.install_matches === true) { + return { label: 'Installed the same as the node that received the deploy.', matches: true }; + } + if (peer.install_matches === false) { + const differs = (peer.install_differs ?? []).map((field) => + field === 'source' && peer.install?.source ? `source ${peer.install.source}` : field + ); + return { + label: `Installed differently: ${differs.length ? differs.join(', ') : 'see the deploy log'}.`, + matches: false, + }; + } + return { label: "Couldn't compare its install: a fingerprint is missing or unidentified.", matches: null }; +} diff --git a/src/features/instance/config/deployments/index.test.tsx b/src/features/instance/config/deployments/index.test.tsx index 4a621a354..9e7ea9ecf 100644 --- a/src/features/instance/config/deployments/index.test.tsx +++ b/src/features/instance/config/deployments/index.test.tsx @@ -1,6 +1,6 @@ /** @vitest-environment jsdom */ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ConfigDeploymentsIndex } from './index'; @@ -8,8 +8,16 @@ const state = vi.hoisted(() => ({ version: undefined as string | undefined, registrationFails: false, listEnabled: [] as (boolean | undefined)[], + listParams: [] as Record[], + listData: undefined as { deployments: object[]; total: number } | undefined, + search: {} as Record, + navigate: vi.fn(), +})); +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => state.navigate, + useParams: () => ({}), + useSearch: () => state.search, })); -vi.mock('@tanstack/react-router', () => ({ useNavigate: () => vi.fn(), useParams: () => ({}) })); vi.mock('@/hooks/usePermissions', () => ({ useInstanceManagePermission: () => true })); vi.mock('@/config/useInstanceClient', () => ({ useInstanceClientIdParams: () => ({ entityId: 'ins-a' }) })); vi.mock('@/integrations/api/instance/status/getRegistrationInfo', () => ({ @@ -21,9 +29,15 @@ vi.mock('@/integrations/api/instance/status/getRegistrationInfo', () => ({ }), })); vi.mock('@/integrations/api/instance/deployments/listDeployments', () => ({ - getDeploymentsQueryOptions: ({ enabled }: { enabled?: boolean }) => { - state.listEnabled.push(enabled); - return { queryKey: ['list_deployments'], queryFn: () => ({ deployments: [], total: 0 }), enabled: false }; + getDeploymentsQueryOptions: (params: { enabled?: boolean }) => { + state.listEnabled.push(params.enabled); + state.listParams.push(params); + return { + queryKey: ['list_deployments'], + queryFn: () => ({ deployments: [], total: 0 }), + enabled: false, + ...(state.listData ? { initialData: state.listData } : {}), + }; }, })); vi.mock('./modals/DeploymentDetailModal', () => ({ DeploymentDetailModal: () => null })); @@ -33,6 +47,10 @@ afterEach(() => { state.version = undefined; state.registrationFails = false; state.listEnabled = []; + state.listParams = []; + state.listData = undefined; + state.search = {}; + state.navigate.mockClear(); }); function renderPage(version: string | undefined) { @@ -71,3 +89,63 @@ describe('ConfigDeploymentsIndex version gate', () => { expect(screen.queryByText('No deployments yet.')).toBeNull(); }); }); + +const row = (id: string, extra: object = {}) => ({ + deployment_id: id, + project: 'web', + status: 'success', + started_at: 1, + ...extra, +}); + +describe('ConfigDeploymentsIndex filters and paging', () => { + it('asks list_deployments for the filtered page the URL names', () => { + state.search = { project: 'web', status: 'staged', page: 3 }; + renderPage('5.3.0'); + expect(state.listParams.at(-1)).toMatchObject({ project: 'web', status: 'staged', limit: 25, offset: 50 }); + }); + + it('pages by the total the server reports, not by the rows it sent', () => { + state.search = { page: 2 }; + state.listData = { deployments: Array.from({ length: 25 }, (_, i) => row(`d-${i}`)), total: 60 }; + renderPage('5.3.0'); + expect(screen.getByText('26–50 of 60')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Next page' })); + const update = state.navigate.mock.calls.at(-1)?.[0] as { search: (prev: object) => object }; + expect(update.search({ page: 2, project: 'web' })).toEqual({ page: 3, project: 'web' }); + fireEvent.click(screen.getByRole('button', { name: 'Previous page' })); + const back = state.navigate.mock.calls.at(-1)?.[0] as { search: (prev: object) => object }; + expect(back.search({ page: 2 })).toEqual({ page: undefined }); + }); + + it('filters by component on Enter, and starts again from the first page', () => { + state.search = { page: 4 }; + renderPage('5.3.0'); + const input = screen.getByRole('textbox', { name: 'Component' }); + fireEvent.change(input, { target: { value: ' web ' } }); + fireEvent.submit(input.closest('form')!); + const update = state.navigate.mock.calls.at(-1)?.[0] as { search: (prev: object) => object }; + expect(update.search({ page: 4 })).toEqual({ page: undefined, project: 'web' }); + }); + + it('says the filters matched nothing rather than that there are no deployments', () => { + state.search = { project: 'nope' }; + state.listData = { deployments: [], total: 0 }; + renderPage('5.3.0'); + expect(screen.getByText('No deployments match these filters.')).toBeTruthy(); + }); + + it('shows a staged release as waiting, and an activation by the release it made live', () => { + state.listData = { + deployments: [ + row('a1b2c3d4-0000-4000-8000-000000000001', { status: 'staged', package_identifier: 'npm:web@2.0.0' }), + row('e5f6a7b8-0000-4000-8000-000000000002', { activated_from: 'a1b2c3d4-0000-4000-8000-000000000001' }), + ], + total: 2, + }; + renderPage('5.3.0'); + expect(screen.getByText('waiting to be activated')).toBeTruthy(); + expect(screen.getByText('npm:web@2.0.0')).toBeTruthy(); + expect(screen.getByText('Activation of a1b2c3d4')).toBeTruthy(); + }); +}); diff --git a/src/features/instance/config/deployments/index.tsx b/src/features/instance/config/deployments/index.tsx index ec984b3ab..17fc0dc46 100644 --- a/src/features/instance/config/deployments/index.tsx +++ b/src/features/instance/config/deployments/index.tsx @@ -1,24 +1,34 @@ import { SimpleBrowseDataTable } from '@/components/SimpleBrowseDataTable'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { useInstanceClientIdParams } from '@/config/useInstanceClient'; -import { supportsDeployments } from '@/features/instance/applications/hooks/useSupportsDeploymentSSE'; +import { + supportsDeployments, + supportsStagedDeployments, +} from '@/features/instance/applications/hooks/useSupportsDeploymentSSE'; import { useInstanceManagePermission } from '@/hooks/usePermissions'; import { useRefreshClick } from '@/hooks/useRefreshClick'; import { getDeploymentsQueryOptions } from '@/integrations/api/instance/deployments/listDeployments'; -import { Deployment } from '@/integrations/api/instance/deployments/types'; +import { Deployment, DeploymentStatus } from '@/integrations/api/instance/deployments/types'; import { getRegistrationInfoQueryOptions } from '@/integrations/api/instance/status/getRegistrationInfo'; import { Row } from '@/lib/table'; -import { useQuery } from '@tanstack/react-query'; -import { useNavigate, useParams } from '@tanstack/react-router'; -import { RefreshCwIcon } from 'lucide-react'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; +import { ChevronLeftIcon, ChevronRightIcon, RefreshCwIcon } from 'lucide-react'; import { useCallback, useMemo } from 'react'; +import { DEPLOYMENT_STATUS_LABEL } from './components/deploymentStatusBadge'; import { deploymentColumns } from './constants/tableDefinition'; +import { DEPLOYMENTS_PAGE_SIZE, DeploymentsSearch, filterableStatuses } from './deploymentsSearch'; import { DeploymentDetailModal } from './modals/DeploymentDetailModal'; +const ALL_STATUSES = 'all'; + export function ConfigDeploymentsIndex() { const navigate = useNavigate(); const canManage = useInstanceManagePermission(); const { deploymentId }: { deploymentId?: string } = useParams({ strict: false }); + const { project, status, page = 1 }: DeploymentsSearch = useSearch({ strict: false }); const instanceParams = useInstanceClientIdParams(); // The nav hides this page below Harper 5.1, but a bookmark or back-navigation still lands here. const { data: registrationInfo, isError: registrationFailed } = useQuery( @@ -27,21 +37,47 @@ export function ConfigDeploymentsIndex() { const versionKnown = registrationInfo !== undefined; const versionUnavailable = !versionKnown && registrationFailed; const deploymentsAvailable = supportsDeployments(registrationInfo?.version); + const statuses = filterableStatuses(supportsStagedDeployments(registrationInfo?.version)); - const { data, isFetching, isRefetching, refetch, error } = useQuery( - getDeploymentsQueryOptions({ + const { data, isFetching, isRefetching, refetch, error } = useQuery({ + ...getDeploymentsQueryOptions({ ...instanceParams, - limit: 100, + project, + status, + limit: DEPLOYMENTS_PAGE_SIZE, + offset: (page - 1) * DEPLOYMENTS_PAGE_SIZE, enabled: canManage && deploymentsAvailable, pollWhileOpen: true, }), - ); + // A filter or page change keeps the rows on screen until the next ones arrive. + placeholderData: keepPreviousData, + }); const deployments = useMemo(() => data?.deployments ?? [], [data]); + const total = data?.total ?? 0; + const firstShown = total === 0 ? 0 : (page - 1) * DEPLOYMENTS_PAGE_SIZE + 1; + const lastShown = Math.min(total, (page - 1) * DEPLOYMENTS_PAGE_SIZE + deployments.length); + const filtered = !!project || !!status; + + const setSearch = useCallback( + (patch: DeploymentsSearch) => { + void navigate({ to: '.', search: (previous: DeploymentsSearch) => ({ ...previous, ...patch }) }); + }, + [navigate], + ); + const applyProject = useCallback( + (value: string) => { + const next = value.trim() || undefined; + if (next !== project) { + setSearch({ project: next, page: undefined }); + } + }, + [project, setSearch], + ); const onSelect = useCallback( (next: string | undefined) => { const parts = [deploymentId ? '..' : '', next].filter(Boolean); - void navigate({ to: parts.join('/') || '.' }); + void navigate({ to: parts.join('/') || '.', search: true }); }, [deploymentId, navigate], ); @@ -73,6 +109,39 @@ export function ConfigDeploymentsIndex() { columns={deploymentColumns} onRowClick={onRowClick} > +
      { + event.preventDefault(); + applyProject(String(new FormData(event.currentTarget).get('project') ?? '')); + }} + > + applyProject(event.currentTarget.value)} + className="w-48" + /> + +
      + + ) + : filtered + ? 'No deployments match these filters.' + : 'No deployments yet.'} +
+ )} + {deployments.length > 0 && ( +
+ + {firstShown}–{lastShown} of {total} + + + +
)} {!!deploymentId && ( diff --git a/src/features/instance/config/routes.ts b/src/features/instance/config/routes.ts index ee2b40bef..55713d9a2 100644 --- a/src/features/instance/config/routes.ts +++ b/src/features/instance/config/routes.ts @@ -1,5 +1,6 @@ import { ConfigCertificatesIndex } from '@/features/instance/config/certificates'; import { ConfigDeploymentsIndex } from '@/features/instance/config/deployments'; +import { validateDeploymentsSearch } from '@/features/instance/config/deployments/deploymentsSearch'; import { ConfigDomainsIndex } from '@/features/instance/config/domains'; import { ConfigIndex } from '@/features/instance/config/index'; import { ConfigOverviewIndex } from '@/features/instance/config/overview'; @@ -76,12 +77,14 @@ export function createConfigRouteTree(instanceLayoutRoute: ReturnType instanceConfigRoute, path: 'deployments', + validateSearch: validateDeploymentsSearch, head: () => ({ meta: [{ title: 'Deployments — Harper Fabric' }] }), component: ConfigDeploymentsIndex, }); const instanceConfigDeploymentRoute = createRoute({ getParentRoute: () => instanceConfigRoute, path: 'deployments/$deploymentId', + validateSearch: validateDeploymentsSearch, head: () => ({ meta: [{ title: 'Deployments — Harper Fabric' }] }), component: ConfigDeploymentsIndex, }); diff --git a/src/integrations/api/instance/deployments/deploymentPolling.test.ts b/src/integrations/api/instance/deployments/deploymentPolling.test.ts index 3ba1e9f83..a1ad1a822 100644 --- a/src/integrations/api/instance/deployments/deploymentPolling.test.ts +++ b/src/integrations/api/instance/deployments/deploymentPolling.test.ts @@ -36,6 +36,8 @@ describe('get_deployment polling', () => { const poll = detailInterval(); expect(poll({ state: { error: null, data: deployment('installing') } })).toBe(2_000); expect(poll({ state: { error: null, data: deployment('success') } })).toBe(false); + // A staged record never changes again; polling it every 2s would run for as long as the view is open. + expect(poll({ state: { error: null, data: deployment('staged') } })).toBe(false); }); it('does not poll a detail view that never asked to', () => { @@ -62,6 +64,7 @@ describe('list_deployments polling', () => { const poll = listInterval() as IntervalFn; expect(poll({ state: { error: null, data: list('success', 'installing') } })).toBe(2_000); expect(poll({ state: { error: null, data: list('success', 'failed') } })).toBe(10_000); + expect(poll({ state: { error: null, data: list('success', 'staged') } })).toBe(10_000); expect(poll({ state: { error: null } })).toBe(10_000); }); diff --git a/src/integrations/api/instance/deployments/types.test.ts b/src/integrations/api/instance/deployments/types.test.ts index 3c2dd27ef..3c21e25ca 100644 --- a/src/integrations/api/instance/deployments/types.test.ts +++ b/src/integrations/api/instance/deployments/types.test.ts @@ -12,6 +12,7 @@ describe('isTerminalDeploymentStatus', () => { ['success', true], ['failed', true], ['rolled_back', true], + ['staged', true], ])('%s -> terminal=%s', (status, expected) => { expect(isTerminalDeploymentStatus(status)).toBe(expected); }); diff --git a/src/integrations/api/instance/deployments/types.ts b/src/integrations/api/instance/deployments/types.ts index f86693480..9c9254c8b 100644 --- a/src/integrations/api/instance/deployments/types.ts +++ b/src/integrations/api/instance/deployments/types.ts @@ -8,12 +8,16 @@ export type DeploymentStatus = | 'restarting' | 'success' | 'failed' - | 'rolled_back'; + | 'rolled_back' + /** Built and installed on every node, not live (5.3.0+). Activating it writes a record of its own. */ + | 'staged'; +// Harper's own set (harper `components/deploymentOperations.ts:19`): a staged record never changes again. export const TERMINAL_DEPLOYMENT_STATUSES: ReadonlySet = new Set([ 'success', 'failed', 'rolled_back', + 'staged', ]); export function isTerminalDeploymentStatus(status: DeploymentStatus | undefined): boolean { @@ -27,6 +31,15 @@ export interface DeploymentEventLogEntry { data: unknown; } +/** + * What an install came from (5.3.1+): `npm:@`, `git:`, `integrity:` or `unidentified`, + * absent for a payload; and the sha256 of each root lockfile, or why it could not be read. + */ +export interface InstallFingerprint { + source?: string; + lockfiles?: Record; +} + export interface DeploymentPeerResult { node: string; status?: string; @@ -34,6 +47,12 @@ export interface DeploymentPeerResult { error?: string | DeploymentPeerError | null; started_at?: number; completed_at?: number; + /** Present once the origin compared this peer's install with its own (5.3.1+). */ + install?: InstallFingerprint | null; + /** `false` when anything differs, `null` when the evidence is missing or unidentified, `true` when it all matches. */ + install_matches?: boolean | null; + /** `source` and/or the lockfile names that differ. */ + install_differs?: string[]; } export interface DeploymentPeerError { @@ -68,8 +87,10 @@ export interface Deployment { started_at?: number; completed_at?: number; user?: string; - restorable?: boolean; - rollback_of?: string | null; + /** On an activation (5.3.0+), the deployment whose staged or kept release it made live. */ + activated_from?: string | null; + /** The origin's own install, on a deploy or a stage (5.3.1+); an activation installs nothing. */ + install_fingerprint?: InstallFingerprint | null; error?: DeploymentError | null; } From 806599e11f90f793fc64fa3ca6ea3c03498d5660 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Sun, 11 Oct 2026 02:44:55 -0400 Subject: [PATCH 2/8] fix(deployments): no stale rows across a page change, and no staged filter where staging is absent The list no longer keeps the previous page's rows on screen, clickable, while the next page loads. A saved ?status=staged link opened against an instance older than 5.3 no longer asks for a status it cannot have. The component filter applies on Enter only, so leaving the field to click Next does not reset the page under the click, and it follows an outside change without remounting the field. Refs #1786 Co-Authored-By: Claude Opus 5.5 --- .../config/deployments/deploymentsSearch.ts | 3 +- .../config/deployments/index.test.tsx | 15 +++++++++ .../instance/config/deployments/index.tsx | 31 +++++++++++-------- .../deployments/deploymentPolling.test.ts | 1 - 4 files changed, 34 insertions(+), 16 deletions(-) diff --git a/src/features/instance/config/deployments/deploymentsSearch.ts b/src/features/instance/config/deployments/deploymentsSearch.ts index bf555e0e3..084369a24 100644 --- a/src/features/instance/config/deployments/deploymentsSearch.ts +++ b/src/features/instance/config/deployments/deploymentsSearch.ts @@ -1,6 +1,5 @@ import { DeploymentStatus } from '@/integrations/api/instance/deployments/types'; -/** The Deployments page's filters and page, kept in the URL so a link can open it filtered. */ export interface DeploymentsSearch { project?: string; status?: DeploymentStatus; @@ -8,7 +7,7 @@ export interface DeploymentsSearch { page?: number; } -/** What `list_deployments` can filter on. `staged` only exists from Harper 5.3.0. */ +// `rolled_back` is left out: no Harper version writes it, so the filter could only ever come back empty. export const FILTERABLE_STATUSES: readonly DeploymentStatus[] = [ 'pending', 'extracting', diff --git a/src/features/instance/config/deployments/index.test.tsx b/src/features/instance/config/deployments/index.test.tsx index 9e7ea9ecf..b3bf1efbe 100644 --- a/src/features/instance/config/deployments/index.test.tsx +++ b/src/features/instance/config/deployments/index.test.tsx @@ -105,6 +105,21 @@ describe('ConfigDeploymentsIndex filters and paging', () => { expect(state.listParams.at(-1)).toMatchObject({ project: 'web', status: 'staged', limit: 25, offset: 50 }); }); + it('does not ask a pre-5.3 instance for staged records a saved link names', () => { + state.search = { status: 'staged' }; + renderPage('5.2.15'); + expect(state.listParams.at(-1)).toMatchObject({ status: undefined }); + }); + + it('applies the component filter on Enter only, so leaving the field does not move the page', () => { + state.search = { page: 2 }; + renderPage('5.3.0'); + const input = screen.getByRole('textbox', { name: 'Component' }); + fireEvent.change(input, { target: { value: 'web' } }); + fireEvent.blur(input); + expect(state.navigate).not.toHaveBeenCalled(); + }); + it('pages by the total the server reports, not by the rows it sent', () => { state.search = { page: 2 }; state.listData = { deployments: Array.from({ length: 25 }, (_, i) => row(`d-${i}`)), total: 60 }; diff --git a/src/features/instance/config/deployments/index.tsx b/src/features/instance/config/deployments/index.tsx index 17fc0dc46..c5c97bd1f 100644 --- a/src/features/instance/config/deployments/index.tsx +++ b/src/features/instance/config/deployments/index.tsx @@ -13,10 +13,10 @@ import { getDeploymentsQueryOptions } from '@/integrations/api/instance/deployme import { Deployment, DeploymentStatus } from '@/integrations/api/instance/deployments/types'; import { getRegistrationInfoQueryOptions } from '@/integrations/api/instance/status/getRegistrationInfo'; import { Row } from '@/lib/table'; -import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; import { ChevronLeftIcon, ChevronRightIcon, RefreshCwIcon } from 'lucide-react'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { DEPLOYMENT_STATUS_LABEL } from './components/deploymentStatusBadge'; import { deploymentColumns } from './constants/tableDefinition'; import { DEPLOYMENTS_PAGE_SIZE, DeploymentsSearch, filterableStatuses } from './deploymentsSearch'; @@ -28,7 +28,7 @@ export function ConfigDeploymentsIndex() { const navigate = useNavigate(); const canManage = useInstanceManagePermission(); const { deploymentId }: { deploymentId?: string } = useParams({ strict: false }); - const { project, status, page = 1 }: DeploymentsSearch = useSearch({ strict: false }); + const { project, status: requestedStatus, page = 1 }: DeploymentsSearch = useSearch({ strict: false }); const instanceParams = useInstanceClientIdParams(); // The nav hides this page below Harper 5.1, but a bookmark or back-navigation still lands here. const { data: registrationInfo, isError: registrationFailed } = useQuery( @@ -38,9 +38,11 @@ export function ConfigDeploymentsIndex() { const versionUnavailable = !versionKnown && registrationFailed; const deploymentsAvailable = supportsDeployments(registrationInfo?.version); const statuses = filterableStatuses(supportsStagedDeployments(registrationInfo?.version)); + // A saved `?status=staged` opened against an instance without staging asks for nothing it can answer. + const status = requestedStatus && statuses.includes(requestedStatus) ? requestedStatus : undefined; - const { data, isFetching, isRefetching, refetch, error } = useQuery({ - ...getDeploymentsQueryOptions({ + const { data, isFetching, isRefetching, refetch, error } = useQuery( + getDeploymentsQueryOptions({ ...instanceParams, project, status, @@ -49,9 +51,7 @@ export function ConfigDeploymentsIndex() { enabled: canManage && deploymentsAvailable, pollWhileOpen: true, }), - // A filter or page change keeps the rows on screen until the next ones arrive. - placeholderData: keepPreviousData, - }); + ); const deployments = useMemo(() => data?.deployments ?? [], [data]); const total = data?.total ?? 0; const firstShown = total === 0 ? 0 : (page - 1) * DEPLOYMENTS_PAGE_SIZE + 1; @@ -84,6 +84,13 @@ export function ConfigDeploymentsIndex() { const onRowClick = useCallback((row: Row) => onSelect(row.original.deployment_id), [onSelect]); const closeModal = useCallback(() => onSelect(undefined), [onSelect]); const onRefreshClick = useRefreshClick(refetch); + const projectInput = useRef(null); + useEffect(() => { + // Follows a filter set from outside the field, such as the editor's link, without remounting it. + if (projectInput.current && projectInput.current.value.trim() !== (project ?? '')) { + projectInput.current.value = project ?? ''; + } + }, [project]); if (!canManage) { return ( @@ -117,14 +124,12 @@ export function ConfigDeploymentsIndex() { }} > applyProject(event.currentTarget.value)} - className="w-48" + className="w-56" />