fix(workers): refresh Workers view FE-4323 (#49887)

## Problem

The Workers view can remain stale after a worker is deployed through the
CLI, because the dashboard has no deployment mutation to invalidate its
list query.

## Fix

Add a manual Refresh action to the Workers header and force the Workers
list query to refetch whenever the browser regains focus.

## How to test

- Open a project’s Workers view and select Refresh.
- Expected result: the list requests current worker data and renders it.
- Deploy a worker through the CLI, then return focus to the Workers
view.
- Expected result: the Workers list refreshes even when its cached data
is fresh.

Closes FE-4323.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
  - Added Refresh buttons to the Workers page and worker list.
- Refreshing displays the latest worker information and shows a loading
state while data is retrieved.
- Worker data now automatically refreshes when the browser window
regains focus.
- Added a Refresh action to unexpected-error messages, allowing failed
requests to be retried without leaving the page.
- **Bug Fixes**
- Improved recovery from failed worker data requests through in-page
retry support.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Jordi Enric authored and GitHub committed 2026-09-02 13:42:57 +02:00
1 parent 47fbf26e4b
commit abb7f3ede2
5 files changed
+88 -11

No files matched your search

@@ -18,8 +18,16 @@ const worker = (name: string, overrides: Partial<Worker> = {}): Worker => ({
...overrides,
})
const renderList = (workers: Worker[]) =>
customRender(<WorkersList projectRef="default" workers={workers} onDeploy={vi.fn()} />)
const renderList = (workers: Worker[], onRefresh = vi.fn()) =>
customRender(
<WorkersList
projectRef="default"
workers={workers}
onDeploy={vi.fn()}
onRefresh={onRefresh}
isRefreshing={false}
/>
)
const rowNames = () =>
screen
@@ -48,12 +56,19 @@ describe('WorkersList', () => {
await userEvent.type(screen.getByPlaceholderText('Search by name'), 'resize')
expect(rowNames()).toEqual(['resize-images'])
expect(screen.getByText('1 worker')).toBeVisible()
await userEvent.type(screen.getByPlaceholderText('Search by name'), '-nope')
expect(screen.getByText('No workers match your filters')).toBeVisible()
})
it('refreshes the workers list on request', async () => {
const onRefresh = vi.fn()
renderList([worker('embed')], onRefresh)
await userEvent.click(screen.getByRole('button', { name: 'Refresh' }))
expect(onRefresh).toHaveBeenCalledOnce()
})
it('pages through the workers ten at a time', async () => {
const workers = Array.from({ length: 12 }, (_, index) => worker(`worker-${index}`))
renderList(workers)
@@ -1,4 +1,4 @@
import { ChevronLeft, ChevronRight, Terminal } from 'lucide-react'
import { ChevronLeft, ChevronRight, RefreshCw, Terminal } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
@@ -32,6 +32,8 @@ interface WorkersListProps {
projectRef: string
workers: Worker[]
onDeploy: () => void
onRefresh: () => void
isRefreshing: boolean
}
const STATE_FILTERS: { value: WorkerBuildState | 'all'; label: string }[] = [
@@ -55,7 +57,13 @@ const parseStateFilter = (value: string): WorkerBuildState | 'all' =>
const parseAccessFilter = (value: string): WorkerAccess | 'all' =>
ACCESS_FILTERS.find((option) => option.value === value)?.value ?? 'all'
export const WorkersList = ({ projectRef, workers, onDeploy }: WorkersListProps) => {
export const WorkersList = ({
projectRef,
workers,
onDeploy,
onRefresh,
isRefreshing,
}: WorkersListProps) => {
const router = useRouter()
const [search, setSearch] = useState('')
const [stateFilter, setStateFilter] = useState<WorkerBuildState | 'all'>('all')
@@ -124,9 +132,9 @@ export const WorkersList = ({ projectRef, workers, onDeploy }: WorkersListProps)
</Select>
<div className="flex items-center gap-3 md:ml-auto">
<span className="text-sm text-foreground-lighter">
{filtered.length} worker{filtered.length === 1 ? '' : 's'}
</span>
<Button variant="default" icon={<RefreshCw />} loading={isRefreshing} onClick={onRefresh}>
Refresh
</Button>
<Button variant="primary" icon={<Terminal />} onClick={onDeploy}>
Deploy a worker
</Button>
@@ -28,4 +28,5 @@ export const workersQueryOptions = ({ projectRef }: WorkersVariables) =>
queryKey: workersKeys.list(projectRef),
queryFn: ({ signal }) => getWorkers({ projectRef }, signal),
enabled: IS_PLATFORM && typeof projectRef !== 'undefined',
refetchOnWindowFocus: 'always',
})
@@ -1,6 +1,8 @@
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'common'
import { RefreshCw } from 'lucide-react'
import { useState } from 'react'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
@@ -38,6 +40,8 @@ const WorkersPage: NextPageWithLayout = () => {
isPending,
isError,
isSuccess,
isFetching,
refetch,
} = useQuery(workersQueryOptions({ projectRef: ref }))
const isNotEnrolled = isError && isWorkersUnavailable(error)
@@ -74,7 +78,22 @@ const WorkersPage: NextPageWithLayout = () => {
/>
)}
{isMissingPermission && <NoPermission resourceText="view this project's workers" />}
{isUnexpectedError && <AlertError error={error} subject="Failed to retrieve workers" />}
{isUnexpectedError && (
<AlertError
error={error}
subject="Failed to retrieve workers"
additionalActions={
<Button
variant="default"
icon={<RefreshCw />}
loading={isFetching}
onClick={() => refetch()}
>
Refresh
</Button>
}
/>
)}
{isSuccess && workers.length === 0 && (
<WorkersEmptyState onDeploy={() => setIsDeployInstructionsOpen(true)} />
)}
@@ -83,6 +102,8 @@ const WorkersPage: NextPageWithLayout = () => {
projectRef={ref}
workers={workers}
onDeploy={() => setIsDeployInstructionsOpen(true)}
onRefresh={() => refetch()}
isRefreshing={isFetching}
/>
)}
</PageSectionContent>
@@ -1,5 +1,5 @@
import { QueryClient } from '@tanstack/react-query'
import { screen } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/react'
import type { components } from 'api-types'
import { HttpResponse } from 'msw'
import { beforeEach, describe, expect, it } from 'vitest'
@@ -90,6 +90,17 @@ describe('/project/[ref]/workers', () => {
expect(screen.queryByRole('table')).not.toBeInTheDocument()
})
it('refreshes the workers list on request', async () => {
mockWorkersList([workerDatum('existing')])
await renderWorkersPage()
mockWorkersList([workerDatum('embed')])
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
expect(await screen.findByRole('link', { name: 'embed' })).toBeVisible()
})
it('explains that a project outside the alpha is not enrolled', async () => {
mockWorkersListFailure(404)
@@ -109,4 +120,25 @@ describe('/project/[ref]/workers', () => {
).toBeVisible()
expect(screen.queryByRole('table')).not.toBeInTheDocument()
})
it('allows retrying an unexpected error', async () => {
let requestCount = 0
addAPIMock({
method: 'get',
path: '/v2/projects/:ref/workers',
response: (): HttpResponse<APIErrorBody> | HttpResponse<ListWorkersResponse> => {
if (requestCount++ === 0) {
return HttpResponse.json<APIErrorBody>({ message: 'Unavailable' }, { status: 500 })
}
return HttpResponse.json<ListWorkersResponse>({ data: [workerDatum('embed')] })
},
})
await renderWorkersPage()
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
expect(await screen.findByRole('link', { name: 'embed' })).toBeVisible()
})
})