diff --git a/specifyweb/backend/attachment_gw/urls.py b/specifyweb/backend/attachment_gw/urls.py index 08857760fc6..d7f007afad9 100644 --- a/specifyweb/backend/attachment_gw/urls.py +++ b/specifyweb/backend/attachment_gw/urls.py @@ -6,6 +6,7 @@ path('get_settings/', views.get_settings), path('get_upload_params/', views.get_upload_params), path('get_token/', views.get_token), + path('health/', views.health), path('proxy/', views.proxy), path('download_all/', views.download_all), path('dataset/', views.datasets), diff --git a/specifyweb/backend/attachment_gw/views.py b/specifyweb/backend/attachment_gw/views.py index fec4bed676a..cc3d5bba945 100644 --- a/specifyweb/backend/attachment_gw/views.py +++ b/specifyweb/backend/attachment_gw/views.py @@ -3,6 +3,7 @@ import json import logging import time +from threading import Lock from tempfile import mkdtemp from os.path import splitext from uuid import uuid4 @@ -33,6 +34,10 @@ server_urls = None server_time_delta = None +initialization_lock = Lock() +next_initialization_attempt = 0 +initialization_failures = 0 +MAX_INITIALIZATION_RETRY_DELAY = 60 from .models import Spattachmentdataset @@ -226,33 +231,76 @@ def init(): logger.info('Asset server is not configured') return - r = requests.get(settings.WEB_ATTACHMENT_URL) - if r.status_code != 200: - logger.error('Failed fetching asset server configuration') - return - - update_time_delta(r) - try: - urls_xml = ElementTree.fromstring(r.text) - except: - logger.error('Failed parsing the response') - return + r = requests.get(settings.WEB_ATTACHMENT_URL, timeout=settings.WEB_ATTACHMENT_TIMEOUT) + if r.status_code != 200: + logger.error('Failed fetching asset server configuration') + return + + update_time_delta(r) + + try: + urls_xml = ElementTree.fromstring(r.text) + except ElementTree.ParseError: + logger.error('Failed parsing the response') + return + + try: + urls = {url.attrib['type']: url.text + for url in urls_xml.findall('url')} + required_url_types = ( + 'delete', + 'getmetadata', + 'read', + 'testkey', + 'write', + ) + if any(not urls.get(url_type) for url_type in required_url_types): + raise AttachmentError('Incomplete asset server configuration.') + test_key(urls) + server_urls = urls + except (AttachmentError, requests.RequestException, KeyError) as error: + logger.error('Invalid asset server configuration: %s', str(error)) + server_urls = None + except requests.RequestException as error: + logger.error('Failed to connect to asset server: %s', str(error)) + server_urls = None - server_urls = {url.attrib['type']: url.text - for url in urls_xml.findall('url')} +def retry_initialization(): + """Attempt a bounded, backoff-limited asset server initialization.""" + global initialization_failures, next_initialization_attempt - try: - test_key() - except AttachmentError as error: - logger.error('%s', str(error)) - server_urls = None + now = time.monotonic() + if now < next_initialization_attempt or not initialization_lock.acquire(blocking=False): + return False -def test_key(): + try: + if server_urls is not None: + return True + if now < next_initialization_attempt: + return False + + init() + if server_urls is not None: + initialization_failures = 0 + next_initialization_attempt = 0 + return True + + initialization_failures += 1 + next_initialization_attempt = time.monotonic() + min( + 2 ** initialization_failures, + MAX_INITIALIZATION_RETRY_DELAY, + ) + return False + finally: + initialization_lock.release() + +def test_key(urls=None): random = str(uuid4()) token = generate_token(get_timestamp(), random) - r = requests.get(server_urls["testkey"], - params={'random': random, 'token': token}) + r = requests.get((urls or server_urls)["testkey"], + params={'random': random, 'token': token}, + timeout=settings.WEB_ATTACHMENT_TIMEOUT) if r.status_code == 200: return @@ -261,6 +309,24 @@ def test_key(): else: raise AttachmentError("Attachment key test failed.") +@login_maybe_required +@require_http_methods(['GET', 'HEAD']) +@never_cache +def health(request): + if server_urls is None: + if settings.WEB_ATTACHMENT_URL in (None, ''): + return HttpResponse(status=404) + if not retry_initialization(): + return HttpResponse(status=503) + + try: + test_key() + except (AttachmentError, requests.RequestException) as error: + logger.error('Health check failed: %s', str(error)) + return HttpResponse(status=503) + + return HttpResponse(status=204) + @openapi(schema={ "get": { "parameters": [ diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx index e89cedf46fa..028a0476fc5 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx @@ -22,7 +22,7 @@ import { softFail } from '../Errors/Crash'; import { Dialog } from '../Molecules/Dialog'; import { TableIcon } from '../Molecules/TableIcon'; import { hasTablePermission } from '../Permissions/helpers'; -import { fetchOriginalUrl } from './attachments'; +import { fetchOriginalUrl, useAttachmentServerStatus } from './attachments'; import { AttachmentPreview } from './Preview'; import { getAttachmentRelationship, tablesWithAttachments } from './utils'; @@ -39,10 +39,17 @@ export function AttachmentCell({ | ((table: SpecifyTable, recordId: number) => void) | undefined; }): JSX.Element { + const attachmentServerStatus = useAttachmentServerStatus(); const table = f.maybe(attachment.tableID ?? undefined, getAttachmentTable); const [originalUrl] = useAsyncState( - React.useCallback(async () => fetchOriginalUrl(attachment), [attachment]), + React.useCallback( + async () => + attachmentServerStatus !== 'unavailable' + ? fetchOriginalUrl(attachment) + : undefined, + [attachment, attachmentServerStatus] + ), false ); diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx index ff4ac242654..8773e09702d 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx @@ -4,7 +4,11 @@ import { useAsyncState } from '../../hooks/useAsyncState'; import type { SerializedResource } from '../DataModel/helperTypes'; import type { Attachment } from '../DataModel/types'; import type { AttachmentThumbnail } from './attachments'; -import { fetchThumbnail } from './attachments'; +import { + fetchThumbnail, + reportAttachmentServerFailure, + useAttachmentServerStatus, +} from './attachments'; export function AttachmentPreview({ attachment, @@ -13,8 +17,15 @@ export function AttachmentPreview({ readonly attachment: SerializedResource; readonly onOpen: () => void; }): JSX.Element { + const attachmentServerStatus = useAttachmentServerStatus(); const [thumbnail] = useAsyncState( - React.useCallback(async () => fetchThumbnail(attachment), [attachment]), + React.useCallback( + async () => + attachmentServerStatus !== 'unavailable' + ? fetchThumbnail(attachment) + : undefined, + [attachment, attachmentServerStatus] + ), false ); @@ -56,6 +67,11 @@ export function Thumbnail({ width: `${thumbnail.width}px`, height: `${thumbnail.height}px`, }} + onError={ + thumbnail.isServerBacked === true + ? reportAttachmentServerFailure + : undefined + } /> ); } diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx index 1f366ced5ea..e3ca273134c 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx @@ -29,7 +29,12 @@ import { } from '../Forms/useViewDefinition'; import { loadingGif } from '../Molecules'; import { userPreferences } from '../Preferences/userPreferences'; -import { fetchOriginalUrl, fetchThumbnail } from './attachments'; +import { + fetchOriginalUrl, + fetchThumbnail, + reportAttachmentServerFailure, + useAttachmentServerStatus, +} from './attachments'; import { AttachmentRecordLink, getAttachmentTable } from './Cell'; import { Thumbnail } from './Preview'; @@ -48,12 +53,19 @@ export function AttachmentViewer({ | ((table: SpecifyTable, recordId: number) => void) | undefined; }): JSX.Element { + const attachmentServerStatus = useAttachmentServerStatus(); const serialized = React.useMemo( () => serializeResource(attachment), [attachment] ); const [originalUrl] = useAsyncState( - React.useCallback(async () => fetchOriginalUrl(serialized), [serialized]), + React.useCallback( + async () => + attachmentServerStatus !== 'unavailable' + ? fetchOriginalUrl(serialized) + : undefined, + [attachmentServerStatus, serialized] + ), false ); @@ -104,7 +116,13 @@ export function AttachmentViewer({ const type = mimeType?.split('/')[0]; const [thumbnail] = useAsyncState( - React.useCallback(async () => fetchThumbnail(serialized), [serialized]), + React.useCallback( + async () => + attachmentServerStatus !== 'unavailable' + ? fetchThumbnail(serialized) + : undefined, + [attachmentServerStatus, serialized] + ), false ); @@ -136,7 +154,9 @@ export function AttachmentViewer({ return ( <>
- {displayOriginal === 'full' && !isTiffImage ? ( + {attachmentServerStatus === 'unavailable' ? ( + + ) : displayOriginal === 'full' && !isTiffImage ? ( originalUrl === undefined ? ( loadingGif ) : type === 'image' ? ( @@ -192,6 +212,7 @@ export function AttachmentViewer({ alt={title} className="h-full w-full object-scale-down" src={thumbnail?.src} + onError={reportAttachmentServerFailure} /> ) @@ -229,25 +250,29 @@ export function AttachmentViewer({ {typeof originalUrl === 'string' && (
- - {notificationsText.download()} - - - {commonText.openInNewTab()} - + {attachmentServerStatus !== 'unavailable' && ( + <> + + {notificationsText.download()} + + + {commonText.openInNewTab()} + + + )} {typeof table === 'object' && typeof handleViewRecord === 'function' ? ( { + thumbnailFallbackAttempted.current = false; + setImageFailed(false); + }, [src]); + const handleError = React.useCallback( (event: React.SyntheticEvent) => { - if (typeof thumbnail === 'string') { + if ( + !thumbnailFallbackAttempted.current && + typeof thumbnail === 'string' + ) { + thumbnailFallbackAttempted.current = true; const image = event.currentTarget; - image.onerror = null; image.src = thumbnail; + } else { + setImageFailed(true); + reportAttachmentServerFailure(); } }, [thumbnail] @@ -310,13 +349,17 @@ function ImageTransformContent({ wrapperClass="flex h-full w-full items-center justify-center" wrapperStyle={{ height: '100%', width: '100%' }} > - {alt} + {imageFailed ? ( + + ) : ( + {alt} + )} {showControls ? (
+ {attachmentsText.attachmentServerUnavailable()} + {attachmentsText.attachmentServerUnavailableDescription()} +
+ ); +} + function ZoomControls({ canToggleSidebar, isSidebarExpanded, diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/AttachmentsView.test.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/AttachmentsView.test.tsx new file mode 100644 index 00000000000..fac762016bb --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/AttachmentsView.test.tsx @@ -0,0 +1,145 @@ +import { act, waitFor } from '@testing-library/react'; +import React from 'react'; +import * as Router from 'react-router-dom'; + +import { overrideAjax } from '../../../tests/ajax'; +import { requireContext } from '../../../tests/helpers'; +import { mount } from '../../../tests/reactUtils'; +import { commonText } from '../../../localization/common'; +import { attachmentsText } from '../../../localization/attachments'; +import { Http } from '../../../utils/ajax/definitions'; +import { SetMenuContext } from '../../Header/MenuContext'; +import { + attachmentSettingsPromise, + overrideAttachmentServerStatus, + overrideAttachmentSettings, +} from '../attachments'; +import { AttachmentsView } from '..'; + +/* + * Bypass the paginated attachment fetches (a pre-existing, unrelated bug in + * useAsyncState's real implementation crashes when exercised here); only the + * status-driven rendering is under test + */ +jest.mock('../../../hooks/useAsyncState', () => { + const ReactModule = require('react'); + return { + __esModule: true, + useAsyncState: () => ReactModule.useState(undefined), + usePromise: (promise: Promise) => { + const [state, setState] = ReactModule.useState(undefined); + ReactModule.useEffect(() => { + let ignore = false; + promise.then((value: unknown) => { + if (!ignore) setState(value); + }); + return () => { + ignore = true; + }; + }, [promise]); + return [state, setState]; + }, + }; +}); + +requireContext(); + +const mockReadUrl = '/mockAssetServer/fileget'; + +const testSettings = { + collection: 'Test Collection', + delete: '/mockAssetServer/filedelete', + getmetadata: '/mockAssetServer/getmetadata', + read: mockReadUrl, + testkey: '/mockAssetServer/testkey', + // eslint-disable-next-line @typescript-eslint/naming-convention + token_required_for_get: false, + write: '/mockAssetServer/fileupload', +}; + +function TestAttachmentsView(): JSX.Element { + return ( + + + + + + ); +} + +describe('AttachmentsView', () => { + let consoleError: jest.SpiedFunction; + let consoleWarn: jest.SpiedFunction; + + beforeEach(async () => { + await attachmentSettingsPromise; + overrideAttachmentSettings(testSettings); + overrideAttachmentServerStatus('available'); + consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + consoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + }); + + afterEach(() => { + overrideAttachmentSettings(undefined); + consoleError.mockRestore(); + consoleWarn.mockRestore(); + }); + + describe('when the health check reports the server is unreachable', () => { + overrideAttachmentServerStatus('unavailable'); + overrideAjax(mockReadUrl, '', { responseCode: Http.SERVER_ERROR }); + + test('replaces the gallery with a single unavailable message and disables Import', async () => { + const { findByRole, unmount } = mount(); + + await findByRole('heading', { + name: attachmentsText.attachmentServerUnavailable(), + }); + + const importButton = await findByRole('button', { + name: commonText.import(), + }); + expect(importButton).toBeDisabled(); + expect(importButton).toHaveAttribute( + 'title', + attachmentsText.attachmentServerUnavailable() + ); + + unmount(); + }); + }); + + describe('when the health check reports the server is reachable', () => { + overrideAttachmentServerStatus('available'); + overrideAjax(mockReadUrl, '', { responseCode: Http.OK }); + + test('shows the gallery and an enabled Import button when available', async () => { + const { findByRole, queryByRole, unmount } = mount( + + ); + + const importButton = await findByRole('button', { + name: commonText.import(), + }); + await waitFor(() => expect(importButton).toBeEnabled()); + expect( + queryByRole('heading', { + name: attachmentsText.attachmentServerUnavailable(), + }) + ).not.toBeInTheDocument(); + + await act(() => { + unmount(); + }); + }); + }); +}); diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/Preview.test.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/Preview.test.tsx new file mode 100644 index 00000000000..1b9508b7b1b --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/Preview.test.tsx @@ -0,0 +1,73 @@ +import { fireEvent, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { overrideAjax } from '../../../tests/ajax'; +import attachmentSettings from '../../../tests/ajax/static/context/attachment_settings.json'; +import { requireContext } from '../../../tests/helpers'; +import { mount } from '../../../tests/reactUtils'; +import { Http } from '../../../utils/ajax/definitions'; +import { serializeResource } from '../../DataModel/serializers'; +import { tables } from '../../DataModel/tables'; +import { + attachmentSettingsPromise, + fetchThumbnail, + overrideAttachmentServerStatus, + overrideAttachmentSettings, +} from '../attachments'; +import { Thumbnail } from '../Preview'; + +requireContext(); + +const healthCheckUrl = '/attachment_gw/health/'; +const rootRelativeReadUrl = '/mockAssetServer/fileget'; +const healthResponse = jest.fn(() => ''); +let consoleError: jest.SpiedFunction; + +overrideAjax(healthCheckUrl, healthResponse, { + responseCode: Http.SERVER_ERROR, +}); + +beforeEach(async () => { + await attachmentSettingsPromise; + healthResponse.mockClear(); + consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + overrideAttachmentServerStatus('available'); + overrideAttachmentSettings({ + ...attachmentSettings, + read: rootRelativeReadUrl, + }); +}); + +afterEach(() => { + overrideAttachmentSettings(undefined); + consoleError.mockRestore(); +}); + +describe('Thumbnail', () => { + test('reports failed root-relative asset server thumbnails', async () => { + const attachment = new tables.Attachment.Resource({ + attachmentlocation: 'testLocation', + mimetype: 'image/jpeg', + origfilename: 'testFile.jpg', + title: 'testFile.jpg', + isPublic: true, + }); + const thumbnail = await fetchThumbnail(serializeResource(attachment), 78); + + expect(thumbnail?.src.startsWith(rootRelativeReadUrl)).toBe(true); + + const { getByRole } = mount( + + ); + + fireEvent.error(getByRole('img')); + + await waitFor(() => expect(healthResponse).toHaveBeenCalledTimes(1)); + expect(consoleError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/AttachmentCell.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/AttachmentCell.test.tsx.snap index 61638ec3ada..c527650144e 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/AttachmentCell.test.tsx.snap +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/AttachmentCell.test.tsx.snap @@ -66,40 +66,8 @@ exports[`AttachmentCell simple render 1`] = ` > + />
- - -
`; diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/attachmentServerStatus.test.ts b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/attachmentServerStatus.test.ts new file mode 100644 index 00000000000..e89a5d668b5 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/attachmentServerStatus.test.ts @@ -0,0 +1,129 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; + +import { overrideAjax } from '../../../tests/ajax'; +import { requireContext } from '../../../tests/helpers'; +import { Http } from '../../../utils/ajax/definitions'; +import { + attachmentSettingsPromise, + overrideAttachmentServerStatus, + overrideAttachmentSettings, + reportAttachmentServerFailure, + useAttachmentServerStatus, +} from '../attachments'; + +requireContext(); + +const mockReadUrl = '/mockAssetServer/fileget'; +const healthCheckUrl = '/attachment_gw/health/'; + +const testSettings = { + collection: 'Test Collection', + delete: '/mockAssetServer/filedelete', + getmetadata: '/mockAssetServer/getmetadata', + read: mockReadUrl, + testkey: '/mockAssetServer/testkey', + // eslint-disable-next-line @typescript-eslint/naming-convention + token_required_for_get: false, + write: '/mockAssetServer/fileupload', +}; + +// Silences (and lets tests assert on) the connection-loss/restoration logging +let consoleError: jest.SpiedFunction; +let consoleWarn: jest.SpiedFunction; + +beforeEach(async () => { + await attachmentSettingsPromise; + overrideAttachmentSettings(testSettings); + overrideAttachmentServerStatus('available'); + consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); +}); + +afterEach(() => { + overrideAttachmentSettings(undefined); + consoleError.mockRestore(); + consoleWarn.mockRestore(); +}); + +describe('reportAttachmentServerFailure', () => { + describe('when a health check confirms the server is reachable', () => { + overrideAjax(healthCheckUrl, '', { responseCode: Http.NO_CONTENT }); + + test('a single caller error does not mark the server unavailable', async () => { + overrideAttachmentServerStatus('unknown'); + const { result, unmount } = renderHook(() => useAttachmentServerStatus()); + expect(result.current).toBe('unknown'); + + act(() => reportAttachmentServerFailure()); + + await waitFor(() => expect(result.current).toBe('available')); + unmount(); + }); + }); + + describe('when a health check confirms the server is unreachable', () => { + overrideAjax(healthCheckUrl, '', { responseCode: Http.SERVER_ERROR }); + + test('marks the server unavailable', async () => { + const { result, unmount } = renderHook(() => useAttachmentServerStatus()); + expect(result.current).toBe('available'); + + act(() => reportAttachmentServerFailure()); + + await waitFor(() => expect(result.current).toBe('unavailable')); + unmount(); + }); + }); + + test('checks health when settings are temporarily unavailable', () => { + overrideAttachmentSettings(undefined); + expect(() => reportAttachmentServerFailure()).not.toThrow(); + }); +}); + +describe('connection loss/restoration logging', () => { + overrideAjax(healthCheckUrl, '', { responseCode: Http.SERVER_ERROR }); + + test('logs connection loss exactly once, and only once further failures are reported', async () => { + const { result, unmount } = renderHook(() => useAttachmentServerStatus()); + expect(result.current).toBe('available'); + + act(() => reportAttachmentServerFailure()); + await waitFor(() => expect(result.current).toBe('unavailable')); + + expect(consoleError).toHaveBeenCalledTimes(1); + expect(consoleWarn).not.toHaveBeenCalled(); + + act(() => reportAttachmentServerFailure()); + await waitFor(() => expect(consoleError).toHaveBeenCalledTimes(1)); + + unmount(); + }); +}); + +describe('useAttachmentServerStatus polling', () => { + overrideAjax(healthCheckUrl, '', { responseCode: Http.NO_CONTENT }); + + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + test('keeps the shared interval running until the last subscriber unmounts', async () => { + const first = renderHook(() => useAttachmentServerStatus()); + const second = renderHook(() => useAttachmentServerStatus()); + + // Let the immediate on-mount health check settle before tearing down + await act(async () => { + await Promise.resolve(); + }); + + expect(jest.getTimerCount()).toBe(1); + + first.unmount(); + expect(jest.getTimerCount()).toBe(1); + + second.unmount(); + expect(jest.getTimerCount()).toBe(0); + }); +}); diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/fetchThumbnail.test.ts b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/fetchThumbnail.test.ts index e4cdac8adf5..60960c338df 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/fetchThumbnail.test.ts +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/fetchThumbnail.test.ts @@ -37,6 +37,7 @@ describe('fetchThumbnail', () => { alt: 'testLocation', width: 78, height: 78, + isServerBacked: true, }); }); diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts index 8b30bfff2b8..eca55affc7c 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts +++ b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts @@ -1,3 +1,5 @@ +import React from 'react'; + import { commonText } from '../../localization/common'; import { ajax } from '../../utils/ajax'; import { Http } from '../../utils/ajax/definitions'; @@ -41,15 +43,128 @@ type AttachmentSettings = { }; let settings: AttachmentSettings | undefined; +export type AttachmentServerStatus = 'unknown' | 'available' | 'unavailable'; + +let serverStatus: AttachmentServerStatus = 'unknown'; +const serverStatusListeners = new Set<() => void>(); +let healthCheckTimer: ReturnType | undefined; + +const attachmentSettingsPath = '/context/attachment_settings.json'; + +const setAttachmentServerStatus = (newStatus: AttachmentServerStatus): void => { + if (serverStatus === newStatus) return; + const previousStatus = serverStatus; + serverStatus = newStatus; + // Only log actual connection loss/restoration, not the initial unknown state + if (previousStatus === 'available' && newStatus === 'unavailable') + console.error( + `[${new Date().toISOString()}] Attachment server connection lost` + ); + else if (previousStatus === 'unavailable' && newStatus === 'available') + console.warn( + `[${new Date().toISOString()}] Attachment server connection restored` + ); + serverStatusListeners.forEach((listener) => listener()); +}; + export const attachmentSettingsPromise = load>( - '/context/attachment_settings.json', + attachmentSettingsPath, 'application/json' -).then((data) => { - if (Object.keys(data).length > 0) settings = data as AttachmentSettings; - return attachmentsAvailable(); +).then(async (data) => { + if (Object.keys(data).length > 0) { + settings = data as AttachmentSettings; + checkAttachmentServer().catch(() => { + setAttachmentServerStatus('unavailable'); + }); + return true; + } else { + settings = undefined; + const status = await checkAttachmentServer().catch(() => Http.SERVER_ERROR); + return status !== Http.NOT_FOUND; + } }); export const attachmentsAvailable = (): boolean => typeof settings === 'object'; + +const checkAttachmentServer = async (): Promise => { + const { status } = await ajax('/attachment_gw/health/', { + cache: 'no-store', + errorMode: 'silent', + expectedErrors: Object.values(Http), + headers: { Accept: 'text/plain' }, + }); + if (status === Http.NO_CONTENT && settings === undefined) { + const { data } = await ajax>( + attachmentSettingsPath, + { + cache: 'no-store', + errorMode: 'silent', + expectedErrors: Object.values(Http), + headers: { Accept: 'application/json' }, + } + ); + if (Object.keys(data).length > 0) settings = data as AttachmentSettings; + } + setAttachmentServerStatus( + status === Http.NO_CONTENT && settings !== undefined + ? 'available' + : 'unavailable' + ); + return status; +}; + +/* + * A single caller error (e.g. a missing or corrupt attachment) doesn't mean + * the server is down, so confirm with a health check before marking it unavailable + */ +export const reportAttachmentServerFailure = (): void => { + checkAttachmentServer().catch(() => setAttachmentServerStatus('unavailable')); +}; + +const stopAttachmentServerHealthPolling = (): void => { + if (serverStatusListeners.size === 0 && healthCheckTimer !== undefined) { + clearInterval(healthCheckTimer); + healthCheckTimer = undefined; + } +}; + +const startAttachmentServerHealthPolling = (): (() => void) => { + const poll = (): void => { + checkAttachmentServer().catch(() => + setAttachmentServerStatus('unavailable') + ); + }; + if (healthCheckTimer === undefined) { + poll(); + + healthCheckTimer = setInterval(() => { + poll(); + }, 30_000); + } + return stopAttachmentServerHealthPolling; +}; + +export const useAttachmentServerStatus = (): AttachmentServerStatus => { + const subscribe = React.useCallback((listener: () => void) => { + serverStatusListeners.add(listener); + const stopPolling = startAttachmentServerHealthPolling(); + return () => { + serverStatusListeners.delete(listener); + stopPolling(); + }; + }, []); + const getSnapshot = React.useCallback(() => serverStatus, []); + return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +}; + +/* + * This function is only used in automated tests. + */ +export const overrideAttachmentServerStatus = ( + newStatus: AttachmentServerStatus +): void => { + serverStatus = newStatus; +}; const uploadTimeoutMilliseconds = 30 * 60 * 1000; /* @@ -150,6 +265,7 @@ export type AttachmentThumbnail = { readonly alt: string | undefined; readonly width: number; readonly height: number; + readonly isServerBacked?: boolean; }; export async function fetchThumbnail( @@ -185,6 +301,7 @@ export async function fetchThumbnail( alt: attachment.attachmentLocation ?? undefined, width: scale, height: scale, + isServerBacked: true, }; } diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx index 32d32acdd1d..e3aa1e67270 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx @@ -27,6 +27,7 @@ import { Dialog } from '../Molecules/Dialog'; import { ProtectedTable } from '../Permissions/PermissionDenied'; import { OrderPicker } from '../Preferences/Renderers'; import { attachmentSettingsPromise } from './attachments'; +import { useAttachmentServerStatus } from './attachments'; import { AttachmentGallery } from './Gallery'; import { allTablesWithAttachments, tablesWithAttachments } from './utils'; @@ -44,18 +45,23 @@ export function AttachmentsView({ const navigate = useNavigate(); const [isConfigured] = usePromise(attachmentSettingsPromise, true); - return isConfigured === undefined ? null : isConfigured ? ( + if (isConfigured === undefined) return null; + + if (isConfigured === false) + return ( + navigate('/specify/')} + > + {attachmentsText.attachmentServerUnavailableDescription()} + + ); + + return ( - ) : ( - navigate('/specify/')} - > - {attachmentsText.attachmentServerUnavailableDescription()} - ); } @@ -65,6 +71,7 @@ function Attachments({ readonly onClick?: (attachment: SerializedResource) => void; }): JSX.Element { useMenuItem('attachments'); + const attachmentServerStatus = useAttachmentServerStatus(); const isInDialog = React.useContext(DialogContext); @@ -234,6 +241,12 @@ function Attachments({ /> navigate('/specify/overlay/attachments/import/')} > {commonText.import()} @@ -241,25 +254,38 @@ function Attachments({ )} - - collection === undefined - ? undefined - : setCollection({ - records: replaceItem(collection.records, index, attachment), - totalCount: collection.totalCount, - }) - } - onClick={onClick} - onFetchMore={collection === undefined ? undefined : fetchMore} - /> + {attachmentServerStatus === 'unavailable' ? ( + + ) : ( + + collection === undefined + ? undefined + : setCollection({ + records: replaceItem(collection.records, index, attachment), + totalCount: collection.totalCount, + }) + } + onClick={onClick} + onFetchMore={collection === undefined ? undefined : fetchMore} + /> + )} ); } + +function AttachmentServerUnavailable(): JSX.Element { + return ( +
+

{attachmentsText.attachmentServerUnavailable()}

+

{attachmentsText.attachmentServerUnavailableDescription()}

+
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/Header/__tests__/Header.test.tsx b/specifyweb/frontend/js_src/lib/components/Header/__tests__/Header.test.tsx new file mode 100644 index 00000000000..36954b6fbcb --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Header/__tests__/Header.test.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import * as Router from 'react-router-dom'; +import { waitFor } from '@testing-library/react'; + +import { commonText } from '../../../localization/common'; +import { overrideAjax } from '../../../tests/ajax'; +import { requireContext } from '../../../tests/helpers'; +import { mount } from '../../../tests/reactUtils'; +import { Http } from '../../../utils/ajax/definitions'; +import { SetMenuContext } from '../MenuContext'; +import { + overrideAttachmentServerStatus, + overrideAttachmentSettings, +} from '../../Attachments/attachments'; +import { HeaderItems } from '..'; + +requireContext(); + +const mockReadUrl = '/mockAssetServer/fileget'; + +const testSettings = { + collection: 'Test Collection', + delete: '/mockAssetServer/filedelete', + getmetadata: '/mockAssetServer/getmetadata', + read: mockReadUrl, + testkey: '/mockAssetServer/testkey', + // eslint-disable-next-line @typescript-eslint/naming-convention + token_required_for_get: false, + write: '/mockAssetServer/fileupload', +}; + +overrideAjax(mockReadUrl, '', { responseCode: Http.OK }); +overrideAjax('/attachment_gw/health/', '', { + responseCode: Http.NO_CONTENT, +}); + +function TestHeaderItems(): JSX.Element { + return ( + + + , + url: '/specify/attachments/', + }, + { + name: 'search', + title: commonText.search(), + icon: , + url: '/specify/overlay/express-search/', + }, + ]} + isCollapsed={false} + activeMenuItem={undefined} + /> + + + ); +} + +describe('HeaderItems', () => { + // The immediate on-mount health check legitimately logs a status transition + beforeEach(() => { + overrideAttachmentSettings(testSettings); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + overrideAttachmentSettings(undefined); + overrideAttachmentServerStatus('unknown'); + jest.restoreAllMocks(); + }); + + test('disables attachments item when server is unavailable', async () => { + overrideAttachmentServerStatus('unavailable'); + const { getByTestId } = mount(); + + await waitFor(() => { + const attachmentsItem = getByTestId('attachments-icon').closest( + 'span[aria-disabled]' + ); + expect(attachmentsItem).toHaveAttribute('aria-disabled', 'true'); + expect(attachmentsItem).toHaveClass('cursor-not-allowed'); + expect(attachmentsItem).toHaveClass('opacity-50'); + }); + }); + + test('does not disable non-attachments items', async () => { + overrideAttachmentServerStatus('unavailable'); + const { getByTestId } = mount(); + + await waitFor(() => { + const searchItem = getByTestId('search-icon').closest('a'); + expect(searchItem).toBeEnabled(); + expect(searchItem).toHaveAttribute( + 'href', + '/specify/overlay/express-search/' + ); + }); + }); + + test('renders disabled attachments item as non-interactive', async () => { + overrideAttachmentServerStatus('unavailable'); + const { getByTestId } = mount(); + + await waitFor(() => { + const attachmentsItem = getByTestId('attachments-icon').closest( + 'span[aria-disabled]' + ); + expect(attachmentsItem).toBeInTheDocument(); + expect(attachmentsItem).not.toHaveAttribute('href'); + }); + }); + + test('keeps attachments enabled when server is available', async () => { + overrideAttachmentServerStatus('available'); + const { getByTestId } = mount(); + + await waitFor(() => { + const attachmentsItem = getByTestId('attachments-icon').closest('a'); + expect(attachmentsItem).toBeEnabled(); + expect(attachmentsItem).toHaveAttribute('href', '/specify/attachments/'); + }); + }); +}); diff --git a/specifyweb/frontend/js_src/lib/components/Header/index.tsx b/specifyweb/frontend/js_src/lib/components/Header/index.tsx index d5d9971865f..8b0695a7a4e 100644 --- a/specifyweb/frontend/js_src/lib/components/Header/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Header/index.tsx @@ -7,10 +7,12 @@ import { useLocation } from 'react-router-dom'; import type { LocalizedString } from 'typesafe-i18n'; import { useCachedState } from '../../hooks/useCachedState'; +import { attachmentsText } from '../../localization/attachments'; import { commonText } from '../../localization/common'; import { listen } from '../../utils/events'; import type { RA } from '../../utils/types'; import { localized } from '../../utils/types'; +import { useAttachmentServerStatus } from '../Attachments/attachments'; import { Button } from '../Atoms/Button'; import { className } from '../Atoms/className'; import { icons } from '../Atoms/Icons'; @@ -160,7 +162,7 @@ export function Header({ ); } -function HeaderItems({ +export function HeaderItems({ menuItems, isCollapsed, activeMenuItem, @@ -169,17 +171,28 @@ function HeaderItems({ readonly isCollapsed: boolean; readonly activeMenuItem: MenuItemName | undefined; }): JSX.Element { + const attachmentServerStatus = useAttachmentServerStatus(); return ( <> - {menuItems.map(({ url, name, ...menuItem }) => ( - - ))} + {menuItems.map(({ url, name, ...menuItem }) => { + const isAttachmentsUnavailable = + name === 'attachments' && attachmentServerStatus !== 'available'; + return ( + + ); + })} ); } @@ -190,6 +203,8 @@ export function MenuButton({ isActive = false, isCollapsed, preventOverflow = false, + disabled = false, + disabledTitle, onClick: handleClick, props: extraProps, }: { @@ -198,6 +213,8 @@ export function MenuButton({ readonly isCollapsed: boolean; readonly isActive?: boolean; readonly preventOverflow?: boolean; + readonly disabled?: boolean; + readonly disabledTitle?: LocalizedString; readonly onClick: string | (() => void); readonly props?: Omit & TagProps<'button'>, 'aria-label'>; }): JSX.Element | null { @@ -205,6 +222,7 @@ export function MenuButton({ const [isSideBarLight] = userPreferences.use('general', 'ui', 'sidebarTheme'); const isDarkMode = useDarkMode(); const isSideBarDark = isDarkMode || isSideBarLight === 'dark'; + const descriptionId = React.useId(); const getClassName = (isActive: boolean): string => ` p-[1.4vh] ${ @@ -224,7 +242,10 @@ export function MenuButton({ [titlePosition]: position === 'left' ? 'right' : position === 'right' ? 'left' : undefined, 'aria-current': isActive ? 'page' : undefined, - title: isCollapsed ? title : undefined, + 'aria-disabled': disabled ? true : undefined, + 'aria-describedby': + disabled && typeof disabledTitle === 'string' ? descriptionId : undefined, + title: disabled ? disabledTitle : isCollapsed ? title : undefined, } as const; const children = ( @@ -239,9 +260,24 @@ export function MenuButton({ ) : ( {title} )} + {disabled && typeof disabledTitle === 'string' ? ( + + {disabledTitle} + + ) : undefined} ); + if (disabled) + return ( + + {children} + + ); + return typeof handleClick === 'string' ? ( >>()({ url: '/specify/attachments/', title: attachmentsText.attachments(), icon: icons.photos, - async enabled(): Promise { - if (!hasTablePermission('Attachment', 'read')) return false; - await attachmentSettingsPromise; - return attachmentsAvailable(); - }, + /* + * Asset server availability is checked at render time so the item can be + * disabled and re-enabled without a page reload. See useAttachmentServerStatus + */ + enabled: () => hasTablePermission('Attachment', 'read'), }, statistics: { url: '/specify/stats', diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/WbAttachmentsPreview.test.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/WbAttachmentsPreview.test.tsx index bf69465595b..29535935d26 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/WbAttachmentsPreview.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/WbAttachmentsPreview.test.tsx @@ -7,8 +7,13 @@ import { clearIdStore } from '../../../hooks/useId'; import { overrideAjax } from '../../../tests/ajax'; import { requireContext } from '../../../tests/helpers'; import { mount } from '../../../tests/reactUtils'; +import { Http } from '../../../utils/ajax/definitions'; import { f } from '../../../utils/functools'; import * as Attachments from '../../Attachments/attachments'; +import { + attachmentSettingsPromise, + overrideAttachmentServerStatus, +} from '../../Attachments/attachments'; import { testAttachment } from '../../Attachments/__tests__/utils'; import { LoadingContext } from '../../Core/Contexts'; import type { Dataset } from '../../WbPlanView/Wrapped'; @@ -64,7 +69,15 @@ overrideAjax( secondDataSetAttachmentRequest ); -beforeEach(() => { +// The attachment server status hook polls this endpoint on mount +overrideAjax('/attachment_gw/health/', '', { + responseCode: Http.NO_CONTENT, +}); + +beforeEach(async () => { + await attachmentSettingsPromise; + // Prevent the background attachment server health check from racing with the test + overrideAttachmentServerStatus('available'); jest.clearAllMocks(); clearIdStore(); }); @@ -75,6 +88,7 @@ afterEach(() => { // [WorkBench] Show the matching attachment for the selected row test('shows the selected row attachment', async () => { + overrideAttachmentServerStatus('available'); jest .spyOn(Attachments, 'fetchThumbnail') .mockImplementation(async (attachment) => ({ @@ -136,6 +150,8 @@ test('shows the selected row attachment', async () => { attachments: null, }; + overrideAttachmentServerStatus('available'); + const { findByRole, queryByRole } = mount(