Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions ui/apps/pmm/src/api/updates.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AxiosResponse } from 'axios';
import { AxiosRequestConfig, AxiosResponse } from 'axios';
import {
GetChangeLogsResponse,
GetUpdateStatusBody,
Expand All @@ -11,9 +11,11 @@ import {
import { api } from './api';

export const checkForUpdates = async (
params: GetUpdatesParams = { force: false }
params: GetUpdatesParams = { force: false },
config?: AxiosRequestConfig
) => {
const res = await api.get<GetUpdatesResponse>('/server/updates', {
...config,
params,
});
return res.data;
Expand Down
21 changes: 21 additions & 0 deletions ui/apps/pmm/src/components/footer/Footer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,27 @@ describe('Footer', () => {
expect('Last checked: 2024/07/30');
});

it('hides the check date when no check has run', () => {
render(
wrapWithUpdatesProvider(<Footer />, {
versionInfo: {
lastCheck: null,
latest: null,
installed: {
version: '3.10.0',
fullVersion: '3.10.0',
timestamp: '2026-07-30T00:00:00Z',
},
latestNewsUrl: '',
updateAvailable: false,
},
})
);

expect(screen.getByText(Messages.version('3.10.0'))).toBeDefined();
expect(screen.queryByText(/Last checked/)).toBeNull();
});

it('shows in progress message', () => {
render(
wrapWithUpdatesProvider(<Footer />, {
Expand Down
19 changes: 14 additions & 5 deletions ui/apps/pmm/src/components/footer/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,25 @@ export const Footer: FC = () => {

if (!versionInfo) return null;

const { lastCheck } = versionInfo;
Comment thread
matejkubinec marked this conversation as resolved.
Outdated
let checkStatus: string | null = null;

if (inProgress) {
checkStatus = Messages.inProgress;
} else if (lastCheck) {
checkStatus = Messages.checkedOn(formatCheckDate(lastCheck));
}

return (
<Stack direction="row" gap={2} data-testid="pmm-footer">
<Typography variant="body2">
{Messages.version(versionInfo.installed.version)}
</Typography>
<Typography variant="body2" color="text.disabled">
{inProgress
? Messages.inProgress
: Messages.checkedOn(formatCheckDate(versionInfo.lastCheck || 'N/A'))}
</Typography>
{checkStatus && (
<Typography variant="body2" color="text.disabled">
{checkStatus}
</Typography>
)}
</Stack>
);
};
211 changes: 211 additions & 0 deletions ui/apps/pmm/src/contexts/updates/updates.provider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { render, waitFor } from '@testing-library/react';
import { AxiosError, type InternalAxiosRequestConfig } from 'axios';
import { UpdatesProvider } from './updates.provider';
import { SettingsContext, type SettingsContextProps } from 'contexts/settings';
import {
api,
addApiErrorInterceptor,
removeApiErrorInterceptor,
} from 'api/api';
import {
wrapWithQueryProvider,
wrapWithSettings,
wrapWithUserProvider,
} from 'utils/testUtils';

const { enqueueSnackbar } = vi.hoisted(() => ({
enqueueSnackbar: vi.fn(),
}));

vi.mock('notistack', () => ({
enqueueSnackbar,
}));

// captured verbatim from a live PMM 3.8.0 server with updates disabled
const DISABLED_ERROR_BODY = {
error: 'PMM updates are disabled',
code: 9,
message: 'PMM updates are disabled',
details: [],
};

const INSTALLED_ONLY_BODY = {
installed: {
version: '3.8.0',
full_version: '3.8.0',
timestamp: '2026-05-15T10:11:22Z',
},
latest: null,
update_available: false,
latest_news_url: '',
last_check: null,
};

let seenParams: Record<string, unknown>[] = [];

/** Stands in for pmm-managed: refuses a full check, answers the installed-only one. */
const updatesDisabledAdapter = async (config: InternalAxiosRequestConfig) => {
const params = (config.params ?? {}) as Record<string, unknown>;

if (!config.url?.includes('/server/updates')) {
return { data: {}, status: 200, statusText: 'OK', headers: {}, config };
}

seenParams.push(params);

if (params.only_installed_version) {
return {
data: INSTALLED_ONLY_BODY,
status: 200,
statusText: 'OK',
headers: {},
config,
};
}

throw new AxiosError('Request failed', '400', config, {}, {
data: DISABLED_ERROR_BODY,
status: 400,
statusText: 'Bad Request',
headers: {},
config,
} as AxiosError['response']);
};

const renderProvider = (updatesEnabled: boolean) =>
render(
wrapWithQueryProvider(
wrapWithUserProvider(
wrapWithSettings(
<UpdatesProvider>
<div>content</div>
</UpdatesProvider>,
{ settings: { updatesEnabled } }
)
)
)
);

const renderWithSettingsContext = (value: SettingsContextProps) =>
render(
wrapWithQueryProvider(
wrapWithUserProvider(
<SettingsContext.Provider value={value}>
<UpdatesProvider>
<div>content</div>
</UpdatesProvider>
</SettingsContext.Provider>
)
)
);

describe('UpdatesProvider (PMM-15274)', () => {
beforeEach(() => {
vi.clearAllMocks();
seenParams = [];
api.defaults.adapter = updatesDisabledAdapter;
addApiErrorInterceptor();
});

afterEach(() => {
removeApiErrorInterceptor();
});

it('raises no error toast when updates are disabled', async () => {
renderProvider(false);

await waitFor(() => expect(seenParams.length).toBeGreaterThan(0));

expect(enqueueSnackbar).not.toHaveBeenCalled();
});

it('never asks for a full check when updates are disabled', async () => {
renderProvider(false);

await waitFor(() => expect(seenParams.length).toBeGreaterThan(0));

expect(seenParams).toEqual([
{ force: false, only_installed_version: true },
]);
});

it('reports a real failure even though the fallback recovers', async () => {
// pmm-managed is up but cannot reach the version service. The fallback
// succeeds, so this is the only chance the user has to hear about it.
api.defaults.adapter = async (config: InternalAxiosRequestConfig) => {
if (!config.url?.includes('/server/updates')) {
return { data: {}, status: 200, statusText: 'OK', headers: {}, config };
}
const params = (config.params ?? {}) as Record<string, unknown>;
seenParams.push(params);

if (params.only_installed_version) {
return {
data: INSTALLED_ONLY_BODY,
status: 200,
statusText: 'OK',
headers: {},
config,
};
}

throw new AxiosError('Request failed', '503', config, {}, {
data: { message: 'failed to check for updates' },
status: 503,
statusText: 'Service Unavailable',
headers: {},
config,
} as AxiosError['response']);
};

renderProvider(true);

await waitFor(() => expect(enqueueSnackbar).toHaveBeenCalled());

expect(seenParams).toEqual([
{ force: true },
{ force: true, only_installed_version: true },
]);
expect(enqueueSnackbar).toHaveBeenCalledTimes(1);
expect(enqueueSnackbar).toHaveBeenCalledWith(
'failed to check for updates',
expect.objectContaining({ variant: 'error' })
);
});

it('stays quiet if the server reports updates are disabled', async () => {
// settings said enabled but the server disagrees, so the toast this ticket
// is about must not come back
renderProvider(true);

await waitFor(() => expect(seenParams.length).toBe(2));

expect(seenParams).toEqual([
{ force: true },
{ force: true, only_installed_version: true },
]);
expect(enqueueSnackbar).not.toHaveBeenCalled();
});

it('waits for settings before checking', async () => {
renderWithSettingsContext({ isLoading: true, settings: null });

await new Promise((resolve) => setTimeout(resolve, 50));

expect(seenParams).toEqual([]);
});

it('still checks for updates when settings fail to load', async () => {
// settings errored, and there is no retry. Blocking here would cost the
// footer its version for the rest of the session.
renderWithSettingsContext({ isLoading: false, settings: null });

await waitFor(() => expect(seenParams.length).toBe(2));

expect(seenParams).toEqual([
{ force: true },
{ force: true, only_installed_version: true },
]);
expect(enqueueSnackbar).not.toHaveBeenCalled();
});
});
12 changes: 10 additions & 2 deletions ui/apps/pmm/src/contexts/updates/updates.provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,19 @@ import { useSettings } from 'contexts/settings';
import { useUser } from 'contexts/user';

export const UpdatesProvider: FC<PropsWithChildren> = ({ children }) => {
const { settings } = useSettings();
const { settings, isLoading: isLoadingSettings } = useSettings();
const [status, setStatus] = useState(UpdateStatus.Pending);
const { user } = useUser();
const { isLoading, data, error, isRefetching, refetch } = useCheckUpdates({
enabled: !settings?.frontend?.anonymousEnabled && !!user?.isPMMAdmin,
// wait for settings to settle, otherwise a full check can fire before we
// know whether this deployment allows one. Gate on loading rather than on
// success: if settings fail there is no retry, and blocking on that would
// cost us the version in the footer for the rest of the session.
enabled:
!isLoadingSettings &&
!settings?.frontend?.anonymousEnabled &&
!!user?.isPMMAdmin,
onlyInstalledVersion: settings?.updatesEnabled === false,
});
const { data: clients } = useAgentVersions({
enabled: !settings?.frontend?.anonymousEnabled && !!user?.isPMMAdmin,
Expand Down
Loading
Loading