- Every section puts the hub's server selector in the chrome slot, the same place and gap as the Dashboard. - Data regions load with a Skeleton and fail inline with Retry; KPIs show the no-data dash instead of 0 when a server did not answer. Settings keeps its header and selector while loading or failing. - Cache, Apps and Logs headers group secondary or hub-refused actions in menus; app descriptions clamp to two lines; Unknown cells say why. - DNS Client: Protocol label, Resolve and Import together. DHCP and Query Logs empty states with actions. View Logs list capped with scroll. - Blocking: Lists KPI tiles like the Overview, app list table fits its panel with one-line URLs. Status re-reads on tab return at most once a minute. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
165 lines
6.8 KiB
TypeScript
165 lines
6.8 KiB
TypeScript
/*
|
|
Stale data: the list is still there and you can see it is no longer current.
|
|
|
|
It is a decision of the Zones design, and the console's design rule: *old data
|
|
never looks like new data*.
|
|
|
|
What was there already kept the zones when a refresh failed, and that is right:
|
|
throwing them away would leave the user with nothing over a network error. What
|
|
was missing was SAYING SO. Without the mark, a ten-minute-old list reads like a
|
|
one-second-old one, and in a DNS console that means believing a state that no
|
|
longer exists.
|
|
*/
|
|
import { render, screen, waitFor, within } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import * as client from '../../api/client'
|
|
import { Zones } from './Zones'
|
|
|
|
afterEach(() => vi.restoreAllMocks())
|
|
|
|
const ZONE = {
|
|
name: 'casa.test', type: 'Primary', lastModified: '2026-08-26T10:00:00Z',
|
|
disabled: false, soaSerial: 7, catalog: null, dnssecStatus: 'Unsigned',
|
|
hasDnssecPrivateKeys: false, notifyFailed: false, notifyFailedFor: [],
|
|
}
|
|
const LIST = { zones: [ZONE], pageNumber: 1, totalPages: 1, totalZones: 1 }
|
|
|
|
/** Answers well the first time and badly from the second on. */
|
|
function goodThenBroken() {
|
|
let n = 0
|
|
return vi.spyOn(client, 'apiRequest').mockImplementation(async (route) => {
|
|
const base = route.split('?')[0]
|
|
if (base !== 'zones/list') return { kind: 'ok', data: { status: 'ok', response: {} } } as never
|
|
n += 1
|
|
return n === 1
|
|
? ({ kind: 'ok', data: { status: 'ok', response: LIST } } as never)
|
|
: ({ kind: 'error', message: 'boom' } as never)
|
|
})
|
|
}
|
|
|
|
const clickGo = async (user: ReturnType<typeof userEvent.setup>) =>
|
|
user.click(screen.getByRole('button', { name: 'Go' }))
|
|
|
|
describe('stale data', () => {
|
|
it('while the refresh goes well, there is no mark', async () => {
|
|
vi.spyOn(client, 'apiRequest').mockResolvedValue({
|
|
kind: 'ok', data: { status: 'ok', response: LIST },
|
|
} as never)
|
|
render(<Zones token="t" canModify canDelete />)
|
|
|
|
expect(await screen.findByRole('button', { name: 'casa.test' })).toBeInTheDocument()
|
|
expect(screen.queryByText(/Could not refresh/)).not.toBeInTheDocument()
|
|
})
|
|
|
|
/* The one that matters: the list STAYS, and it is also said to be stale. */
|
|
it('when the refresh fails, the list stays and it is reported', async () => {
|
|
const user = userEvent.setup()
|
|
goodThenBroken()
|
|
render(<Zones token="t" canModify canDelete />)
|
|
await screen.findByRole('button', { name: 'casa.test' })
|
|
|
|
await clickGo(user)
|
|
|
|
await screen.findByText(/Could not refresh/)
|
|
/* The previous one is not thrown away: that would leave the user with nothing over a network error. */
|
|
expect(screen.getByRole('button', { name: 'casa.test' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('the strip carries the time of the last good data and a Retry', async () => {
|
|
const user = userEvent.setup()
|
|
goodThenBroken()
|
|
render(<Zones token="t" canModify canDelete />)
|
|
await screen.findByRole('button', { name: 'casa.test' })
|
|
|
|
await clickGo(user)
|
|
|
|
const banner = (await screen.findByText(/Could not refresh/)).closest('[role=alert]')!
|
|
expect(banner).toHaveTextContent(/Last good data:/)
|
|
expect(within(banner as HTMLElement).getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
|
})
|
|
|
|
/* No selection: acting in bulk on a list that no longer reflects the server is
|
|
how the wrong zone gets deleted. */
|
|
it('with stale data there is no selecting', async () => {
|
|
const user = userEvent.setup()
|
|
goodThenBroken()
|
|
render(<Zones token="t" canModify canDelete />)
|
|
await screen.findByRole('button', { name: 'casa.test' })
|
|
expect(screen.getByLabelText('Select casa.test')).toBeEnabled()
|
|
|
|
await clickGo(user)
|
|
|
|
await screen.findByText(/Could not refresh/)
|
|
expect(screen.getByLabelText('Select casa.test')).toBeDisabled()
|
|
expect(screen.getByLabelText('Select all zones')).toBeDisabled()
|
|
})
|
|
|
|
/* And with no previous data nothing goes stale: that is a plain error, and the
|
|
notice reports it. Marking a list that never existed promises data there is none of. */
|
|
it('when the FIRST load fails, it does not say anything is stale', async () => {
|
|
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'boom' } as never)
|
|
render(<Zones token="t" canModify canDelete />)
|
|
|
|
await waitFor(() => expect(screen.queryByRole('button', { name: 'casa.test' })).not.toBeInTheDocument())
|
|
expect(screen.queryByText(/Could not refresh/)).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('and when it goes well again, the mark goes away', async () => {
|
|
const user = userEvent.setup()
|
|
let n = 0
|
|
vi.spyOn(client, 'apiRequest').mockImplementation(async (route) => {
|
|
const base = route.split('?')[0]
|
|
if (base !== 'zones/list') return { kind: 'ok', data: { status: 'ok', response: {} } } as never
|
|
n += 1
|
|
return n === 2
|
|
? ({ kind: 'error', message: 'boom' } as never)
|
|
: ({ kind: 'ok', data: { status: 'ok', response: LIST } } as never)
|
|
})
|
|
render(<Zones token="t" canModify canDelete />)
|
|
await screen.findByRole('button', { name: 'casa.test' })
|
|
await clickGo(user)
|
|
await screen.findByText(/Could not refresh/)
|
|
|
|
await clickGo(user)
|
|
|
|
await waitFor(() => expect(screen.queryByText(/Could not refresh/)).not.toBeInTheDocument())
|
|
expect(screen.getByLabelText('Select casa.test')).toBeEnabled()
|
|
})
|
|
})
|
|
|
|
/*
|
|
A failure is reported ONCE.
|
|
|
|
With previous data the strip reports it (and it also says since when and offers a
|
|
retry), and the notice at the top keeps quiet. If both fired, the same failure
|
|
would appear twice and you would have to decide which to read. With no previous
|
|
data it is the other way round: there is nothing to go stale, so the notice
|
|
speaks, carrying the server's message.
|
|
*/
|
|
describe('who reports the failure', () => {
|
|
it('with previous data the strip speaks, and only the strip', async () => {
|
|
const user = userEvent.setup()
|
|
goodThenBroken()
|
|
render(<Zones token="t" canModify canDelete />)
|
|
await screen.findByRole('button', { name: 'casa.test' })
|
|
|
|
await clickGo(user)
|
|
|
|
await screen.findByText(/Could not refresh/)
|
|
/* A single `role=alert` on the screen: the strip. The server notice does not
|
|
duplicate the same event. */
|
|
expect(screen.getAllByRole('alert')).toHaveLength(1)
|
|
expect(screen.queryByText(/boom/)).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('with no previous data the notice speaks, with the server message', async () => {
|
|
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'boom' } as never)
|
|
render(<Zones token="t" canModify canDelete />)
|
|
|
|
/* The notice and the region both carry it: the region stays when the notice is dismissed. */
|
|
expect((await screen.findAllByText(/boom/)).length).toBeGreaterThan(0)
|
|
expect(screen.queryByText(/Could not refresh/)).not.toBeInTheDocument()
|
|
})
|
|
})
|