diff --git a/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.spec.tsx b/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.spec.tsx
index 41f983080c7..2569c6b2b79 100644
--- a/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.spec.tsx
+++ b/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.spec.tsx
@@ -157,6 +157,100 @@ describe('EnvironmentSection', () => {
expect(screen.queryByText(/0 seconds ago/i)).not.toBeInTheDocument()
})
+ it('sorts environments on click and restores the default order after the third click', async () => {
+ const environments = [
+ { ...overview, id: 'env-b', name: 'Beta' },
+ { ...overview, id: 'env-a', name: 'Alpha' },
+ ] as EnvironmentOverviewResponse[]
+
+ const { userEvent } = renderWithProviders(
+
+ )
+
+ const getEnvironmentNames = () =>
+ screen
+ .getAllByRole('checkbox', { name: /^Select (Alpha|Beta)$/i })
+ .map((checkbox) => checkbox.getAttribute('aria-label')?.replace('Select ', ''))
+
+ expect(getEnvironmentNames()).toEqual(['Alpha', 'Beta'])
+
+ await userEvent.click(screen.getByRole('button', { name: /sort by environment/i }))
+
+ expect(getEnvironmentNames()).toEqual(['Alpha', 'Beta'])
+
+ await userEvent.click(screen.getByRole('button', { name: /sort by environment/i }))
+
+ expect(getEnvironmentNames()).toEqual(['Beta', 'Alpha'])
+
+ await userEvent.click(screen.getByRole('button', { name: /sort by environment/i }))
+
+ expect(getEnvironmentNames()).toEqual(['Alpha', 'Beta'])
+ })
+
+ it('sorts a missing last update as just now, as displayed in its row', async () => {
+ jest.setSystemTime(new Date('2026-03-18T12:00:00.000Z'))
+ const environments = [
+ { ...overview, id: 'env-a', name: 'Alpha' },
+ { ...overview, id: 'env-b', name: 'Beta', updated_at: undefined },
+ ] as EnvironmentOverviewResponse[]
+
+ const { userEvent } = renderWithProviders(
+
+ )
+
+ await userEvent.click(screen.getByRole('button', { name: /sort by last update/i }))
+ const getEnvironmentNames = () =>
+ screen
+ .getAllByRole('checkbox', { name: /^Select (Alpha|Beta)$/i })
+ .map((checkbox) => checkbox.getAttribute('aria-label')?.replace('Select ', ''))
+
+ expect(getEnvironmentNames()).toEqual(['Alpha', 'Beta'])
+
+ await userEvent.click(screen.getByRole('button', { name: /sort by last update/i }))
+ expect(getEnvironmentNames()).toEqual(['Beta', 'Alpha'])
+ })
+
+ it('should reflect the current sort state through aria-sort on the column headers', async () => {
+ const { userEvent } = renderWithProviders(
+
+ )
+
+ const environmentHeader = screen.getByRole('columnheader', { name: 'Environment' })
+ const clusterHeader = screen.getByRole('columnheader', { name: 'Cluster' })
+
+ expect(environmentHeader).toHaveAttribute('aria-sort', 'ascending')
+ expect(clusterHeader).toHaveAttribute('aria-sort', 'none')
+ expect(environmentHeader.querySelector('.fa-arrow-down')).not.toBeInTheDocument()
+
+ await userEvent.click(screen.getByRole('button', { name: /sort by environment/i }))
+ expect(environmentHeader).toHaveAttribute('aria-sort', 'ascending')
+ expect(environmentHeader.querySelector('.fa-arrow-down')).toBeInTheDocument()
+
+ await userEvent.click(screen.getByRole('button', { name: /sort by environment/i }))
+ expect(environmentHeader).toHaveAttribute('aria-sort', 'descending')
+ expect(environmentHeader.querySelector('.fa-arrow-up')).toBeInTheDocument()
+
+ await userEvent.click(screen.getByRole('button', { name: /sort by cluster/i }))
+ expect(clusterHeader).toHaveAttribute('aria-sort', 'ascending')
+ expect(environmentHeader).toHaveAttribute('aria-sort', 'none')
+ expect(clusterHeader.querySelector('.fa-arrow-down')).toBeInTheDocument()
+ expect(environmentHeader.querySelector('.fa-arrow-up')).not.toBeInTheDocument()
+
+ await userEvent.click(screen.getByRole('button', { name: /sort by cluster/i }))
+ await userEvent.click(screen.getByRole('button', { name: /sort by cluster/i }))
+ expect(clusterHeader).toHaveAttribute('aria-sort', 'none')
+ expect(environmentHeader).toHaveAttribute('aria-sort', 'ascending')
+ expect(clusterHeader.querySelector('.fa-arrow-down, .fa-arrow-up')).not.toBeInTheDocument()
+ })
+
+ it('keeps the ephemeral default sort without showing an arrow initially', () => {
+ renderWithProviders()
+
+ const lastOperationHeader = screen.getByRole('columnheader', { name: 'Last operation' })
+ expect(lastOperationHeader).toHaveAttribute('aria-sort', 'descending')
+ expect(lastOperationHeader.querySelector('.fa-arrow-up')).not.toBeInTheDocument()
+ })
+
it('should disable the deploy button when the environment is managed by ArgoCD', async () => {
const { userEvent } = renderWithProviders(
(environmentA.name ?? '').localeCompare(environmentB.name ?? ''))
+ .with('cluster', () => (environmentA.cluster?.name ?? '').localeCompare(environmentB.cluster?.name ?? ''))
+ .with('last-operation', () => lastOperationTimestamp(environmentA) - lastOperationTimestamp(environmentB))
+ .with(
+ 'last-update',
+ () => lastUpdateTimestamp(environmentA, fallbackTimestamp) - lastUpdateTimestamp(environmentB, fallbackTimestamp)
+ )
+ .exhaustive()
+}
+
+function SortableColumnHeader({
+ label,
+ column,
+ sort,
+ effectiveSort,
+ onSort,
+ className,
+ buttonClassName,
+}: {
+ label: string
+ column: EnvironmentSortColumn
+ sort: EnvironmentSort | null
+ effectiveSort: EnvironmentSort
+ onSort: (column: EnvironmentSortColumn) => void
+ className?: string
+ buttonClassName?: string
+}) {
+ const isSorted = sort?.column === column
+
+ return (
+
+
+
+ )
+}
+
export function EnvironmentSection({
type,
items,
@@ -214,15 +293,32 @@ export function EnvironmentSection({
.with('PREVIEW', () => 'Ephemeral')
.exhaustive()
+ // Ephemeral environments default to the most recent operation first, every other
+ // section keeps the historical alphabetical order. Clicking a header overrides this.
+ const [sort, setSort] = useState(null)
+ const effectiveSort = sort ?? defaultEnvironmentSort(type)
+
+ const handleSort = useCallback((column: EnvironmentSortColumn) => {
+ setSort((currentSort) => {
+ if (currentSort?.column !== column) return { column, direction: 'asc' }
+ return currentSort.direction === 'asc' ? { column, direction: 'desc' } : null
+ })
+ }, [])
+
const sortedItems = useMemo(() => {
- if (type !== EnvironmentModeEnum.PREVIEW) {
- return items
- }
+ const activeSort = sort ?? defaultEnvironmentSort(type)
+ const directionFactor = activeSort.direction === 'asc' ? 1 : -1
+ const fallbackTimestamp = Date.now()
- return [...items].sort(
- (environmentA, environmentB) => lastOperationTimestamp(environmentB) - lastOperationTimestamp(environmentA)
- )
- }, [items, type])
+ return [...items].sort((environmentA, environmentB) => {
+ const comparison = compareEnvironments(environmentA, environmentB, activeSort.column, fallbackTimestamp)
+ if (comparison !== 0) {
+ return directionFactor * comparison
+ }
+ // Stable, predictable fallback so rows with equal values keep an alphabetical order
+ return (environmentA.name ?? '').localeCompare(environmentB.name ?? '')
+ })
+ }, [items, sort, type])
const EmptyState = () =>
match(type)
@@ -291,18 +387,41 @@ export function EnvironmentSection({
/>
-
- Environment
-
-
- Last operation
-
-
- Cluster
-
-
- Last update
-
+
+
+
+
Actions
diff --git a/libs/domains/services/feature/src/lib/hooks/use-services/use-services.ts b/libs/domains/services/feature/src/lib/hooks/use-services/use-services.ts
index e19efe5eef5..56208352a22 100644
--- a/libs/domains/services/feature/src/lib/hooks/use-services/use-services.ts
+++ b/libs/domains/services/feature/src/lib/hooks/use-services/use-services.ts
@@ -65,6 +65,7 @@ export function useServices({ environmentId, suspense = false }: UseServicesProp
JSON.stringify([
...runningStatusResults.map(({ data }) => data?.state),
...deploymentStatusResults.map(({ data }) => data?.state),
+ ...deploymentStatusResults.map(({ data }) => data?.last_deployment_date),
]),
])
diff --git a/libs/domains/services/feature/src/lib/service-list/__snapshots__/service-list.spec.tsx.snap b/libs/domains/services/feature/src/lib/service-list/__snapshots__/service-list.spec.tsx.snap
index b7c5ca58d0e..f41756ddccc 100644
--- a/libs/domains/services/feature/src/lib/service-list/__snapshots__/service-list.spec.tsx.snap
+++ b/libs/domains/services/feature/src/lib/service-list/__snapshots__/service-list.spec.tsx.snap
@@ -48,6 +48,7 @@ exports[`ServiceList should match snapshot 1`] = `
|
|
- Last operation
+
|
{
expect(rows).toHaveLength(5)
})
+ it('sorts services by last operation and clears the sort on the third click', async () => {
+ const { userEvent } = renderWithProviders()
+ const lastOperationHeader = screen.getByRole('columnheader', { name: 'Last operation' })
+ const getServiceNames = () =>
+ screen
+ .getAllByRole('row')
+ .slice(1)
+ .map((row) => row.textContent?.match(/FRONT-END|back-end-A|CRONJOB|seed_script/)?.[0])
+
+ expect(lastOperationHeader.querySelector('.fa-arrow-down, .fa-arrow-up')).not.toBeInTheDocument()
+ expect(lastOperationHeader).toHaveAttribute('aria-sort', 'none')
+ expect(getServiceNames()).toEqual(['FRONT-END', 'back-end-A', 'CRONJOB', 'seed_script'])
+
+ await userEvent.click(screen.getByRole('button', { name: 'Last operation' }))
+ expect(getServiceNames()).toEqual(['FRONT-END', 'CRONJOB', 'back-end-A', 'seed_script'])
+ expect(lastOperationHeader.querySelector('.fa-arrow-down')).toBeInTheDocument()
+ expect(lastOperationHeader).toHaveAttribute('aria-sort', 'ascending')
+
+ await userEvent.click(screen.getByRole('button', { name: 'Last operation' }))
+ expect(getServiceNames()).toEqual(['seed_script', 'back-end-A', 'CRONJOB', 'FRONT-END'])
+ expect(lastOperationHeader.querySelector('.fa-arrow-up')).toBeInTheDocument()
+ expect(lastOperationHeader).toHaveAttribute('aria-sort', 'descending')
+
+ await userEvent.click(screen.getByRole('button', { name: 'Last operation' }))
+ expect(getServiceNames()).toEqual(['FRONT-END', 'back-end-A', 'CRONJOB', 'seed_script'])
+ expect(lastOperationHeader.querySelector('.fa-arrow-down, .fa-arrow-up')).not.toBeInTheDocument()
+ expect(lastOperationHeader).toHaveAttribute('aria-sort', 'none')
+ })
+
it('should not display ArgoCD services', () => {
renderWithProviders()
expect(screen.queryByText('ARGOCD APP')).not.toBeInTheDocument()
diff --git a/libs/domains/services/feature/src/lib/service-list/service-list.tsx b/libs/domains/services/feature/src/lib/service-list/service-list.tsx
index 47961bf25d5..f299e68fb38 100644
--- a/libs/domains/services/feature/src/lib/service-list/service-list.tsx
+++ b/libs/domains/services/feature/src/lib/service-list/service-list.tsx
@@ -240,19 +240,27 @@ export function ServiceList({ className, containerClassName, environment, ...pro
return
},
}),
- columnHelper.display({
- id: 'last_deployment',
- header: 'Last operation',
- enableColumnFilter: false,
- enableSorting: false,
- cell: (info) => (
-
- ),
- }),
+ columnHelper.accessor(
+ (service) => {
+ const lastDeploymentDate = service.isSkipped ? undefined : service.deploymentStatus?.last_deployment_date
+ return lastDeploymentDate ? new Date(lastDeploymentDate).getTime() : 0
+ },
+ {
+ id: 'last_deployment',
+ header: 'Last operation',
+ enableColumnFilter: false,
+ enableSorting: true,
+ sortingFn: 'basic',
+ sortDescFirst: false,
+ cell: (info) => (
+
+ ),
+ }
+ ),
columnHelper.accessor('version', {
header: 'Target version',
enableColumnFilter: false,
@@ -390,6 +398,15 @@ export function ServiceList({ className, containerClassName, environment, ...pro
{headerGroup.headers.map((header, i) => (
'ascending' as const)
+ .with('desc', () => 'descending' as const)
+ .with(false, () => 'none' as const)
+ .exhaustive()
+ : undefined
+ }
className={twMerge(
'relative flex h-full items-center border-r border-neutral text-neutral-subtle last:border-r-0',
i === 1 || i === 0 ? 'border-none p-0' : ''
|