From fda0db3083dc17d0a52e712612726c49d83e99e4 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Fri, 14 Aug 2026 15:27:57 +0200 Subject: [PATCH 01/27] Fix:Add per-image fallback guard so the original can fall back to the thumbnail once --- .../frontend/js_src/lib/components/Attachments/Viewer.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx index 1f366ced5ea..ec6ee49eac0 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx @@ -286,11 +286,15 @@ function ImageTransformContent({ readonly thumbnail: string | undefined; }): JSX.Element { const { resetTransform } = useControls(); + const thumbnailFallbackAttempted = React.useRef(false); 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; } }, From 45f3cf92e2fb1f684fbb375cf127de139b379563 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Fri, 14 Aug 2026 15:52:05 +0200 Subject: [PATCH 02/27] Add attachment server runtime state --- .../lib/components/Attachments/attachments.ts | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts index 8b30bfff2b8..064c6bcb0e1 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,77 @@ 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 setAttachmentServerStatus = (newStatus: AttachmentServerStatus): void => { + if (serverStatus === newStatus) return; + serverStatus = newStatus; + serverStatusListeners.forEach((listener) => listener()); +}; + export const attachmentSettingsPromise = load>( '/context/attachment_settings.json', 'application/json' ).then((data) => { - if (Object.keys(data).length > 0) settings = data as AttachmentSettings; + if (Object.keys(data).length > 0) { + settings = data as AttachmentSettings; + setAttachmentServerStatus('available'); + } else setAttachmentServerStatus('unavailable'); return attachmentsAvailable(); }); export const attachmentsAvailable = (): boolean => typeof settings === 'object'; + +export const reportAttachmentServerFailure = (): void => { + if (settings !== undefined) setAttachmentServerStatus('unavailable'); +}; + +const checkAttachmentServer = async (): Promise => { + if (settings === undefined) return; + const { status } = await ajax(settings.read, { + cache: 'no-store', + errorMode: 'silent', + expectedErrors: Object.values(Http), + headers: { Accept: 'application/octet-stream' }, + }); + setAttachmentServerStatus( + status !== Http.MISDIRECTED && status < Http.SERVER_ERROR + ? 'available' + : 'unavailable' + ); +}; + +const startAttachmentServerHealthPolling = (): (() => void) => { + if (healthCheckTimer !== undefined) return () => undefined; + healthCheckTimer = setInterval(() => { + checkAttachmentServer().catch(() => + setAttachmentServerStatus('unavailable') + ); + }, 30_000); + return () => { + if (serverStatusListeners.size === 0 && healthCheckTimer !== undefined) { + clearInterval(healthCheckTimer); + healthCheckTimer = undefined; + } + }; +}; + +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); +}; const uploadTimeoutMilliseconds = 30 * 60 * 1000; /* From 6909f7944d4a2d0d1ea9820686c10c235ef3bfe0 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Fri, 14 Aug 2026 15:52:11 +0200 Subject: [PATCH 03/27] Handle unavailable attachment thumbnails --- .../lib/components/Attachments/Preview.tsx | 7 ++- .../lib/components/Attachments/Viewer.tsx | 44 +++++++++++++++---- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx index ff4ac242654..bd3f754121c 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx @@ -4,7 +4,7 @@ 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 } from './attachments'; export function AttachmentPreview({ attachment, @@ -56,6 +56,11 @@ export function Thumbnail({ width: `${thumbnail.width}px`, height: `${thumbnail.height}px`, }} + onError={ + thumbnail.src.startsWith('http') + ? 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 ec6ee49eac0..4e2308970f5 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,6 +53,7 @@ export function AttachmentViewer({ | ((table: SpecifyTable, recordId: number) => void) | undefined; }): JSX.Element { + const attachmentServerStatus = useAttachmentServerStatus(); const serialized = React.useMemo( () => serializeResource(attachment), [attachment] @@ -136,7 +142,9 @@ export function AttachmentViewer({ return ( <>
- {displayOriginal === 'full' && !isTiffImage ? ( + {attachmentServerStatus === 'unavailable' ? ( + + ) : displayOriginal === 'full' && !isTiffImage ? ( originalUrl === undefined ? ( loadingGif ) : type === 'image' ? ( @@ -192,6 +200,7 @@ export function AttachmentViewer({ alt={title} className="h-full w-full object-scale-down" src={thumbnail?.src} + onError={reportAttachmentServerFailure} /> ) @@ -287,6 +296,7 @@ function ImageTransformContent({ }): JSX.Element { const { resetTransform } = useControls(); const thumbnailFallbackAttempted = React.useRef(false); + const [imageFailed, setImageFailed] = React.useState(false); const handleError = React.useCallback( (event: React.SyntheticEvent) => { if ( @@ -296,6 +306,9 @@ function ImageTransformContent({ thumbnailFallbackAttempted.current = true; const image = event.currentTarget; image.src = thumbnail; + } else { + setImageFailed(true); + reportAttachmentServerFailure(); } }, [thumbnail] @@ -314,13 +327,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, From cd9c7b8cbc99ede7e4d31cc7f0909c46bf814a3c Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Fri, 14 Aug 2026 15:52:18 +0200 Subject: [PATCH 04/27] Show attachment gallery outage warning --- .../lib/components/Attachments/index.tsx | 53 ++++++++++++------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx index 32d32acdd1d..fc607de870e 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'; @@ -65,6 +66,7 @@ function Attachments({ readonly onClick?: (attachment: SerializedResource) => void; }): JSX.Element { useMenuItem('attachments'); + const attachmentServerStatus = useAttachmentServerStatus(); const isInDialog = React.useContext(DialogContext); @@ -241,25 +243,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()}

+
+ ); +} From 5842bdda4a41fe2c3089f7e6d17a1fc9e1899ab6 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 17 Aug 2026 12:48:03 +0200 Subject: [PATCH 05/27] Feat: Disable attachment menu item when no server connexion --- .../js_src/lib/components/Header/index.tsx | 38 ++++++++++++++----- .../components/Header/menuItemDefinitions.ts | 14 +++---- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Header/index.tsx b/specifyweb/frontend/js_src/lib/components/Header/index.tsx index d5d9971865f..531d2c0cf5a 100644 --- a/specifyweb/frontend/js_src/lib/components/Header/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Header/index.tsx @@ -11,6 +11,7 @@ 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'; @@ -169,17 +170,23 @@ 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 === 'unavailable'; + return ( + + ); + })} ); } @@ -190,6 +197,7 @@ export function MenuButton({ isActive = false, isCollapsed, preventOverflow = false, + disabled = false, onClick: handleClick, props: extraProps, }: { @@ -198,6 +206,7 @@ export function MenuButton({ readonly isCollapsed: boolean; readonly isActive?: boolean; readonly preventOverflow?: boolean; + readonly disabled?: boolean; readonly onClick: string | (() => void); readonly props?: Omit & TagProps<'button'>, 'aria-label'>; }): JSX.Element | null { @@ -224,6 +233,7 @@ export function MenuButton({ [titlePosition]: position === 'left' ? 'right' : position === 'right' ? 'left' : undefined, 'aria-current': isActive ? 'page' : undefined, + 'aria-disabled': disabled ? true : undefined, title: isCollapsed ? title : undefined, } as const; @@ -242,6 +252,16 @@ export function MenuButton({ ); + 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', From 39245516255d0fdcb0955104ffd56fffa88d3115 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 17 Aug 2026 13:23:54 +0200 Subject: [PATCH 06/27] Feat: Record transitions between available and unavailable with timestamps --- .../js_src/lib/components/Attachments/attachments.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts index 064c6bcb0e1..5f0b2e2e97f 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts +++ b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts @@ -51,7 +51,17 @@ let healthCheckTimer: ReturnType | undefined; 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()); }; From b48feadfe0791fbc36eb868e5a1647e4422376d5 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 17 Aug 2026 13:29:46 +0200 Subject: [PATCH 07/27] Feat: Confirm server health before changing global availability --- .../lib/components/Attachments/attachments.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts index 5f0b2e2e97f..d25e97aa362 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts +++ b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts @@ -78,10 +78,6 @@ export const attachmentSettingsPromise = load>( export const attachmentsAvailable = (): boolean => typeof settings === 'object'; -export const reportAttachmentServerFailure = (): void => { - if (settings !== undefined) setAttachmentServerStatus('unavailable'); -}; - const checkAttachmentServer = async (): Promise => { if (settings === undefined) return; const { status } = await ajax(settings.read, { @@ -97,6 +93,15 @@ const checkAttachmentServer = async (): Promise => { ); }; +/* + * 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 => { + if (settings === undefined) return; + checkAttachmentServer().catch(() => setAttachmentServerStatus('unavailable')); +}; + const startAttachmentServerHealthPolling = (): (() => void) => { if (healthCheckTimer !== undefined) return () => undefined; healthCheckTimer = setInterval(() => { From bdd4ff8110035ccbaf16f236e90270277f4bec64 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 17 Aug 2026 13:38:39 +0200 Subject: [PATCH 08/27] Fix: Disable attachment actions when the server is unavailable --- .../lib/components/Attachments/Viewer.tsx | 42 ++++++++++--------- .../lib/components/Attachments/index.tsx | 6 +++ 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx index 4e2308970f5..c130955a05d 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx @@ -238,25 +238,29 @@ export function AttachmentViewer({ {typeof originalUrl === 'string' && (
- - {notificationsText.download()} - - - {commonText.openInNewTab()} - + {attachmentServerStatus !== 'unavailable' && ( + <> + + {notificationsText.download()} + + + {commonText.openInNewTab()} + + + )} {typeof table === 'object' && typeof handleViewRecord === 'function' ? ( navigate('/specify/overlay/attachments/import/')} > {commonText.import()} From ea1b85e0838c5b20067a87e7e7049adaaa9740dc Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 17 Aug 2026 13:39:45 +0200 Subject: [PATCH 09/27] Fix: Return the cleanup function to every subscriber --- .../lib/components/Attachments/attachments.ts | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts index d25e97aa362..4785efddb10 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts +++ b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts @@ -102,19 +102,21 @@ export const reportAttachmentServerFailure = (): void => { checkAttachmentServer().catch(() => setAttachmentServerStatus('unavailable')); }; +const stopAttachmentServerHealthPolling = (): void => { + if (serverStatusListeners.size === 0 && healthCheckTimer !== undefined) { + clearInterval(healthCheckTimer); + healthCheckTimer = undefined; + } +}; + const startAttachmentServerHealthPolling = (): (() => void) => { - if (healthCheckTimer !== undefined) return () => undefined; - healthCheckTimer = setInterval(() => { - checkAttachmentServer().catch(() => - setAttachmentServerStatus('unavailable') - ); - }, 30_000); - return () => { - if (serverStatusListeners.size === 0 && healthCheckTimer !== undefined) { - clearInterval(healthCheckTimer); - healthCheckTimer = undefined; - } - }; + if (healthCheckTimer === undefined) + healthCheckTimer = setInterval(() => { + checkAttachmentServer().catch(() => + setAttachmentServerStatus('unavailable') + ); + }, 30_000); + return stopAttachmentServerHealthPolling; }; export const useAttachmentServerStatus = (): AttachmentServerStatus => { From c37c2fc6f0c75179e476e9640430d6e1790a52ff Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 17 Aug 2026 14:01:09 +0200 Subject: [PATCH 10/27] Fix: Add tooltip for side bar attachement disabled menu item --- .../js_src/lib/components/Header/index.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/Header/index.tsx b/specifyweb/frontend/js_src/lib/components/Header/index.tsx index 531d2c0cf5a..c384e2dd419 100644 --- a/specifyweb/frontend/js_src/lib/components/Header/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Header/index.tsx @@ -7,6 +7,7 @@ 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'; @@ -180,6 +181,11 @@ function HeaderItems({ void); readonly props?: Omit & TagProps<'button'>, 'aria-label'>; }): JSX.Element | null { @@ -214,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] ${ @@ -234,7 +243,9 @@ export function MenuButton({ position === 'left' ? 'right' : position === 'right' ? 'left' : undefined, 'aria-current': isActive ? 'page' : undefined, 'aria-disabled': disabled ? true : undefined, - title: isCollapsed ? title : undefined, + 'aria-describedby': + disabled && typeof disabledTitle === 'string' ? descriptionId : undefined, + title: disabled ? disabledTitle : isCollapsed ? title : undefined, } as const; const children = ( @@ -249,6 +260,11 @@ export function MenuButton({ ) : ( {title} )} + {disabled && typeof disabledTitle === 'string' ? ( + + {disabledTitle} + + ) : undefined} ); From ff900134c8434bd00af94880f64b918466f2db86 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 17 Aug 2026 15:24:13 +0200 Subject: [PATCH 11/27] Test: Add frontend unit tests for server status --- .../__tests__/attachmentServerStatus.test.ts | 120 ++++++++++++++++++ .../lib/components/Attachments/attachments.ts | 9 ++ 2 files changed, 129 insertions(+) create mode 100644 specifyweb/frontend/js_src/lib/components/Attachments/__tests__/attachmentServerStatus.test.ts 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..32b6b45847e --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/attachmentServerStatus.test.ts @@ -0,0 +1,120 @@ +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 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(mockReadUrl, '', { responseCode: Http.OK }); + + test('a single caller error does not mark the server unavailable', async () => { + const { result, unmount } = renderHook(() => useAttachmentServerStatus()); + expect(result.current).toBe('available'); + + act(() => reportAttachmentServerFailure()); + + await waitFor(() => expect(result.current).toBe('available')); + unmount(); + }); + }); + + describe('when a health check confirms the server is unreachable', () => { + overrideAjax(mockReadUrl, '', { 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('does nothing when no settings are configured', () => { + overrideAttachmentSettings(undefined); + expect(() => reportAttachmentServerFailure()).not.toThrow(); + }); +}); + +describe('connection loss/restoration logging', () => { + overrideAjax(mockReadUrl, '', { 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', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + test('keeps the shared interval running until the last subscriber unmounts', () => { + const first = renderHook(() => useAttachmentServerStatus()); + const second = renderHook(() => useAttachmentServerStatus()); + + 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/attachments.ts b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts index 4785efddb10..8ad19e46ef4 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts +++ b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts @@ -131,6 +131,15 @@ export const useAttachmentServerStatus = (): AttachmentServerStatus => { 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; /* From 8e3853b0646da0292c1bf4cd509df62f70b2e525 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 17 Aug 2026 16:54:38 +0200 Subject: [PATCH 12/27] Test: Add frontend unit tests for unavailable gallery --- .../__tests__/AttachmentsView.test.tsx | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 specifyweb/frontend/js_src/lib/components/Attachments/__tests__/AttachmentsView.test.tsx 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..23c99544052 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/AttachmentsView.test.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import * as Router from 'react-router-dom'; + +import { requireContext } from '../../../tests/helpers'; +import { mount } from '../../../tests/reactUtils'; +import { commonText } from '../../../localization/common'; +import { attachmentsText } from '../../../localization/attachments'; +import { SetMenuContext } from '../../Header/MenuContext'; +import { + attachmentSettingsPromise, + overrideAttachmentServerStatus, +} 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(); + +function TestAttachmentsView(): JSX.Element { + return ( + + + + + + ); +} + +describe('AttachmentsView', () => { + beforeEach(async () => { + await attachmentSettingsPromise; + }); + + test('replaces the gallery with a single unavailable message and disables Import', async () => { + overrideAttachmentServerStatus('unavailable'); + + const { findByRole } = mount(); + + await findByRole('heading', { + name: attachmentsText.attachmentServerUnavailable(), + }); + + const importButton = await findByRole('button', { + name: commonText.import(), + }); + expect(importButton).toBeDisabled(); + expect(importButton).toHaveAttribute( + 'title', + attachmentsText.attachmentServerUnavailable() + ); + }); + + test('shows the gallery and an enabled Import button when available', async () => { + overrideAttachmentServerStatus('available'); + + const { findByRole, queryByRole } = mount(); + + const importButton = await findByRole('button', { + name: commonText.import(), + }); + expect(importButton).toBeEnabled(); + expect( + queryByRole('heading', { + name: attachmentsText.attachmentServerUnavailable(), + }) + ).not.toBeInTheDocument(); + }); +}); From d9e3a3175e726275d22459ee858100ed3b41f024 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 18 Aug 2026 09:03:52 +0200 Subject: [PATCH 13/27] Fix: Run the first health check immediately --- .../lib/components/Attachments/attachments.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts index 8ad19e46ef4..593bb21b754 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts +++ b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts @@ -110,12 +110,18 @@ const stopAttachmentServerHealthPolling = (): void => { }; const startAttachmentServerHealthPolling = (): (() => void) => { - if (healthCheckTimer === undefined) + const poll = (): void => { + checkAttachmentServer().catch(() => + setAttachmentServerStatus('unavailable') + ); + }; + if (healthCheckTimer === undefined) { + poll(); + healthCheckTimer = setInterval(() => { - checkAttachmentServer().catch(() => - setAttachmentServerStatus('unavailable') - ); + poll(); }, 30_000); + } return stopAttachmentServerHealthPolling; }; From b6d974e728ddb3903763aa327d8f8319424a4481 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 18 Aug 2026 10:46:09 +0200 Subject: [PATCH 14/27] Tests: fix attachment view tests and add header tests --- .../__tests__/AttachmentsView.test.tsx | 94 +++++++++---- .../__tests__/attachmentServerStatus.test.ts | 9 +- .../Header/__tests__/Header.test.tsx | 130 ++++++++++++++++++ .../js_src/lib/components/Header/index.tsx | 2 +- 4 files changed, 209 insertions(+), 26 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/Header/__tests__/Header.test.tsx 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 index 23c99544052..fe86b4042df 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/AttachmentsView.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/AttachmentsView.test.tsx @@ -1,14 +1,18 @@ +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 '..'; @@ -40,6 +44,19 @@ jest.mock('../../../hooks/useAsyncState', () => { 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 ( { + 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); }); - test('replaces the gallery with a single unavailable message and disables Import', async () => { - overrideAttachmentServerStatus('unavailable'); + afterEach(() => { + overrideAttachmentSettings(undefined); + consoleError.mockRestore(); + consoleWarn.mockRestore(); + }); - const { findByRole } = mount(); + describe('when the health check reports the server is unreachable', () => { + overrideAjax(mockReadUrl, '', { responseCode: Http.SERVER_ERROR }); - await findByRole('heading', { - name: attachmentsText.attachmentServerUnavailable(), - }); + test('replaces the gallery with a single unavailable message and disables Import', async () => { + const { findByRole, unmount } = mount(); - const importButton = await findByRole('button', { - name: commonText.import(), + await findByRole('heading', { + name: attachmentsText.attachmentServerUnavailable(), + }); + + const importButton = await findByRole('button', { + name: commonText.import(), + }); + expect(importButton).toBeDisabled(); + expect(importButton).toHaveAttribute( + 'title', + attachmentsText.attachmentServerUnavailable() + ); + + unmount(); }); - expect(importButton).toBeDisabled(); - expect(importButton).toHaveAttribute( - 'title', - attachmentsText.attachmentServerUnavailable() - ); }); - test('shows the gallery and an enabled Import button when available', async () => { - overrideAttachmentServerStatus('available'); + describe('when the health check reports the server is reachable', () => { + overrideAjax(mockReadUrl, '', { responseCode: Http.OK }); + + test('shows the gallery and an enabled Import button when available', async () => { + const { findByRole, queryByRole, unmount } = mount( + + ); - const { findByRole, queryByRole } = mount(); + const importButton = await findByRole('button', { + name: commonText.import(), + }); + await waitFor(() => expect(importButton).toBeEnabled()); + expect( + queryByRole('heading', { + name: attachmentsText.attachmentServerUnavailable(), + }) + ).not.toBeInTheDocument(); - const importButton = await findByRole('button', { - name: commonText.import(), + await act(() => { + unmount(); + }); }); - expect(importButton).toBeEnabled(); - expect( - queryByRole('heading', { - name: attachmentsText.attachmentServerUnavailable(), - }) - ).not.toBeInTheDocument(); }); }); 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 index 32b6b45847e..078858a8700 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/attachmentServerStatus.test.ts +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/attachmentServerStatus.test.ts @@ -102,13 +102,20 @@ describe('connection loss/restoration logging', () => { }); describe('useAttachmentServerStatus polling', () => { + overrideAjax(mockReadUrl, '', { responseCode: Http.OK }); + beforeEach(() => jest.useFakeTimers()); afterEach(() => jest.useRealTimers()); - test('keeps the shared interval running until the last subscriber unmounts', () => { + 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(); 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..78892d3a08a --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Header/__tests__/Header.test.tsx @@ -0,0 +1,130 @@ +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 }); + +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); + 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 c384e2dd419..a6192f5df13 100644 --- a/specifyweb/frontend/js_src/lib/components/Header/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Header/index.tsx @@ -162,7 +162,7 @@ export function Header({ ); } -function HeaderItems({ +export function HeaderItems({ menuItems, isCollapsed, activeMenuItem, From bf9929f2a8e5b5cc28b394dc8e44ef458aed5fbf Mon Sep 17 00:00:00 2001 From: "Caroline D." <108160931+CarolineDenis@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:19:13 +0200 Subject: [PATCH 15/27] Update specifyweb/frontend/js_src/lib/components/Attachments/__tests__/attachmentServerStatus.test.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../Attachments/__tests__/attachmentServerStatus.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 078858a8700..d29a3d27631 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/attachmentServerStatus.test.ts +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/attachmentServerStatus.test.ts @@ -51,8 +51,9 @@ describe('reportAttachmentServerFailure', () => { overrideAjax(mockReadUrl, '', { responseCode: Http.OK }); test('a single caller error does not mark the server unavailable', async () => { + overrideAttachmentServerStatus('unknown'); const { result, unmount } = renderHook(() => useAttachmentServerStatus()); - expect(result.current).toBe('available'); + expect(result.current).toBe('unknown'); act(() => reportAttachmentServerFailure()); From 5eaea4ac28b706b17ffecc14e0d9dff9326c2dbf Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 18 Aug 2026 15:39:56 +0200 Subject: [PATCH 16/27] Fix: Add health check to avoid manual reload and cache clearance --- specifyweb/backend/attachment_gw/urls.py | 1 + specifyweb/backend/attachment_gw/views.py | 14 ++++++++ .../lib/components/Attachments/Cell.tsx | 11 ++++-- .../lib/components/Attachments/Preview.tsx | 15 ++++++-- .../lib/components/Attachments/Viewer.tsx | 16 +++++++-- .../AttachmentCell.test.tsx.snap | 34 +------------------ .../lib/components/Attachments/attachments.ts | 13 ++++--- 7 files changed, 61 insertions(+), 43 deletions(-) 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..943ad29dcc5 100644 --- a/specifyweb/backend/attachment_gw/views.py +++ b/specifyweb/backend/attachment_gw/views.py @@ -261,6 +261,20 @@ 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: + return HttpResponse(status=503) + + try: + test_key() + except (AttachmentError, requests.RequestException): + 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 bd3f754121c..41611ee1b83 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, reportAttachmentServerFailure } 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 ); diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx index c130955a05d..dd8072fe841 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx @@ -59,7 +59,13 @@ export function AttachmentViewer({ [attachment] ); const [originalUrl] = useAsyncState( - React.useCallback(async () => fetchOriginalUrl(serialized), [serialized]), + React.useCallback( + async () => + attachmentServerStatus !== 'unavailable' + ? fetchOriginalUrl(serialized) + : undefined, + [attachmentServerStatus, serialized] + ), false ); @@ -110,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 ); 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/attachments.ts b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts index 593bb21b754..e9d0be67c45 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts +++ b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts @@ -71,8 +71,13 @@ export const attachmentSettingsPromise = load>( ).then((data) => { if (Object.keys(data).length > 0) { settings = data as AttachmentSettings; - setAttachmentServerStatus('available'); - } else setAttachmentServerStatus('unavailable'); + checkAttachmentServer().catch(() => { + setAttachmentServerStatus('unavailable'); + }); + } else { + settings = undefined; + setAttachmentServerStatus('unavailable'); + } return attachmentsAvailable(); }); @@ -80,11 +85,11 @@ export const attachmentsAvailable = (): boolean => typeof settings === 'object'; const checkAttachmentServer = async (): Promise => { if (settings === undefined) return; - const { status } = await ajax(settings.read, { + const { status } = await ajax('/attachment_gw/health/', { cache: 'no-store', errorMode: 'silent', expectedErrors: Object.values(Http), - headers: { Accept: 'application/octet-stream' }, + headers: { Accept: 'text/plain' }, }); setAttachmentServerStatus( status !== Http.MISDIRECTED && status < Http.SERVER_ERROR From 61d154a4c1b38a75ad8dc2bbadf7c38742f1e17d Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 18 Aug 2026 16:01:26 +0200 Subject: [PATCH 17/27] Fix: Reset attachment status in tests --- .../js_src/lib/components/Header/__tests__/Header.test.tsx | 1 + 1 file changed, 1 insertion(+) 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 index 78892d3a08a..8f07bea9157 100644 --- a/specifyweb/frontend/js_src/lib/components/Header/__tests__/Header.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Header/__tests__/Header.test.tsx @@ -73,6 +73,7 @@ describe('HeaderItems', () => { afterEach(() => { overrideAttachmentSettings(undefined); + overrideAttachmentServerStatus('unknown'); jest.restoreAllMocks(); }); From 391e0207490d1ef374900e073a322f7b40fcf9cb Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 18 Aug 2026 16:08:30 +0200 Subject: [PATCH 18/27] Fix: Log health check error --- specifyweb/backend/attachment_gw/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/specifyweb/backend/attachment_gw/views.py b/specifyweb/backend/attachment_gw/views.py index 943ad29dcc5..c8ff1e5bb62 100644 --- a/specifyweb/backend/attachment_gw/views.py +++ b/specifyweb/backend/attachment_gw/views.py @@ -270,7 +270,8 @@ def health(request): try: test_key() - except (AttachmentError, requests.RequestException): + except (AttachmentError, requests.RequestException) as error: + logger.error('Health check failed: %s', str(error)) return HttpResponse(status=503) return HttpResponse(status=204) From f4ceaaca768fbb0c895592c8d48efbf1fd95eab7 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 18 Aug 2026 16:14:53 +0200 Subject: [PATCH 19/27] Fix: Bound the asset-server health probe and startup check --- specifyweb/backend/attachment_gw/views.py | 45 +++++++++++++---------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/specifyweb/backend/attachment_gw/views.py b/specifyweb/backend/attachment_gw/views.py index c8ff1e5bb62..0f4a8c41a95 100644 --- a/specifyweb/backend/attachment_gw/views.py +++ b/specifyweb/backend/attachment_gw/views.py @@ -226,33 +226,38 @@ 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 - - server_urls = {url.attrib['type']: url.text - for url in urls_xml.findall('url')} - - try: - test_key() - except AttachmentError as error: - logger.error('%s', str(error)) + 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: + logger.error('Failed parsing the response') + return + + server_urls = {url.attrib['type']: url.text + for url in urls_xml.findall('url')} + + try: + test_key() + except (AttachmentError, requests.RequestException) as error: + logger.error('%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 def test_key(): random = str(uuid4()) token = generate_token(get_timestamp(), random) r = requests.get(server_urls["testkey"], - params={'random': random, 'token': token}) + params={'random': random, 'token': token}, + timeout=settings.WEB_ATTACHMENT_TIMEOUT) if r.status_code == 200: return From 31bd67949e87a690cbc06b152dfae4aec0118b44 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 18 Aug 2026 16:29:54 +0200 Subject: [PATCH 20/27] Fix: Reset image failure state --- .../frontend/js_src/lib/components/Attachments/Viewer.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx index dd8072fe841..e3ca273134c 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx @@ -293,7 +293,7 @@ export function AttachmentViewer({ ); } -function ImageTransformContent({ +export function ImageTransformContent({ alt, canToggleSidebar, isSidebarExpanded, @@ -313,6 +313,12 @@ function ImageTransformContent({ const { resetTransform } = useControls(); const thumbnailFallbackAttempted = React.useRef(false); const [imageFailed, setImageFailed] = React.useState(false); + + React.useEffect(() => { + thumbnailFallbackAttempted.current = false; + setImageFailed(false); + }, [src]); + const handleError = React.useCallback( (event: React.SyntheticEvent) => { if ( From 26b904c0f35ad4c25be91df666832dd29cf946b5 Mon Sep 17 00:00:00 2001 From: "Caroline D." <108160931+CarolineDenis@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:38:18 +0200 Subject: [PATCH 21/27] Potential fix for pull request finding 'CodeQL / Except block handles 'BaseException'' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- specifyweb/backend/attachment_gw/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/backend/attachment_gw/views.py b/specifyweb/backend/attachment_gw/views.py index 0f4a8c41a95..61a0a9094d9 100644 --- a/specifyweb/backend/attachment_gw/views.py +++ b/specifyweb/backend/attachment_gw/views.py @@ -236,7 +236,7 @@ def init(): try: urls_xml = ElementTree.fromstring(r.text) - except: + except ElementTree.ParseError: logger.error('Failed parsing the response') return From e17faf18ffb1b3f896acaf5cbef18d788c12b30c Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Wed, 19 Aug 2026 10:20:25 +0200 Subject: [PATCH 22/27] Fix: Add WEB_ATTACHMENT_TIMEOUT to settings --- specifyweb/settings/specify_settings.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/specifyweb/settings/specify_settings.py b/specifyweb/settings/specify_settings.py index a9c4d02f37f..9f22f4444c0 100644 --- a/specifyweb/settings/specify_settings.py +++ b/specifyweb/settings/specify_settings.py @@ -83,6 +83,9 @@ # Set to true if the asset server requires auth token to get files. WEB_ATTACHMENT_REQUIRES_KEY_FOR_GET = False +# Timeout in seconds for requests made to the asset server. +WEB_ATTACHMENT_TIMEOUT = float(os.getenv('WEB_ATTACHMENT_TIMEOUT', 5)) + # Report runner service REPORT_RUNNER_HOST = '' REPORT_RUNNER_PORT = '' From f3723bcfdb962fb141dcdd06ff4e13ef0f6b817f Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Wed, 19 Aug 2026 10:31:04 +0200 Subject: [PATCH 23/27] Fix: Test, add new health check --- .../Attachments/__tests__/attachmentServerStatus.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 index d29a3d27631..158900ebb0f 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/attachmentServerStatus.test.ts +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/attachmentServerStatus.test.ts @@ -14,6 +14,7 @@ import { requireContext(); const mockReadUrl = '/mockAssetServer/fileget'; +const healthCheckUrl = '/attachment_gw/health/'; const testSettings = { collection: 'Test Collection', @@ -48,7 +49,7 @@ afterEach(() => { describe('reportAttachmentServerFailure', () => { describe('when a health check confirms the server is reachable', () => { - overrideAjax(mockReadUrl, '', { responseCode: Http.OK }); + overrideAjax(healthCheckUrl, '', { responseCode: Http.OK }); test('a single caller error does not mark the server unavailable', async () => { overrideAttachmentServerStatus('unknown'); @@ -63,7 +64,7 @@ describe('reportAttachmentServerFailure', () => { }); describe('when a health check confirms the server is unreachable', () => { - overrideAjax(mockReadUrl, '', { responseCode: Http.SERVER_ERROR }); + overrideAjax(healthCheckUrl, '', { responseCode: Http.SERVER_ERROR }); test('marks the server unavailable', async () => { const { result, unmount } = renderHook(() => useAttachmentServerStatus()); @@ -83,7 +84,7 @@ describe('reportAttachmentServerFailure', () => { }); describe('connection loss/restoration logging', () => { - overrideAjax(mockReadUrl, '', { responseCode: Http.SERVER_ERROR }); + 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()); @@ -103,7 +104,7 @@ describe('connection loss/restoration logging', () => { }); describe('useAttachmentServerStatus polling', () => { - overrideAjax(mockReadUrl, '', { responseCode: Http.OK }); + overrideAjax(healthCheckUrl, '', { responseCode: Http.OK }); beforeEach(() => jest.useFakeTimers()); afterEach(() => jest.useRealTimers()); From 95c9d66b4c178d2228f3305ed6fb30f8529dfb6b Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Wed, 19 Aug 2026 10:40:44 +0200 Subject: [PATCH 24/27] Test: Add server connexion health check in wb attachment preview --- .../__tests__/WbAttachmentsPreview.test.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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..6cffb99f466 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,13 @@ overrideAjax( secondDataSetAttachmentRequest ); -beforeEach(() => { +// The attachment server status hook polls this endpoint on mount +overrideAjax('/attachment_gw/health/', '', { responseCode: Http.OK }); + +beforeEach(async () => { + await attachmentSettingsPromise; + // Prevent the background attachment server health check from racing with the test + overrideAttachmentServerStatus('available'); jest.clearAllMocks(); clearIdStore(); }); From 22ae5970a39765992ab773e1a93513e6ca11bebe Mon Sep 17 00:00:00 2001 From: "Caroline D." <108160931+CarolineDenis@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:51:02 +0200 Subject: [PATCH 25/27] Update specifyweb/frontend/js_src/lib/components/Header/index.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- specifyweb/frontend/js_src/lib/components/Header/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/Header/index.tsx b/specifyweb/frontend/js_src/lib/components/Header/index.tsx index a6192f5df13..8b0695a7a4e 100644 --- a/specifyweb/frontend/js_src/lib/components/Header/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Header/index.tsx @@ -176,7 +176,7 @@ export function HeaderItems({ <> {menuItems.map(({ url, name, ...menuItem }) => { const isAttachmentsUnavailable = - name === 'attachments' && attachmentServerStatus === 'unavailable'; + name === 'attachments' && attachmentServerStatus !== 'available'; return ( Date: Wed, 19 Aug 2026 15:04:17 +0200 Subject: [PATCH 26/27] Fix: Treat only the health endpoint success status as available --- .../frontend/js_src/lib/components/Attachments/attachments.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts index e9d0be67c45..4e6e49eb8f5 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts +++ b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts @@ -92,7 +92,7 @@ const checkAttachmentServer = async (): Promise => { headers: { Accept: 'text/plain' }, }); setAttachmentServerStatus( - status !== Http.MISDIRECTED && status < Http.SERVER_ERROR + status === Http.NO_CONTENT ? 'available' : 'unavailable' ); From 207ec5967c398a1d58e72d0df2a4d5dde1c764f0 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Wed, 19 Aug 2026 15:18:53 +0200 Subject: [PATCH 27/27] Test: mock asset server connexion status --- .../components/Attachments/__tests__/AttachmentsView.test.tsx | 2 ++ 1 file changed, 2 insertions(+) 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 index fe86b4042df..fac762016bb 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/AttachmentsView.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/AttachmentsView.test.tsx @@ -95,6 +95,7 @@ describe('AttachmentsView', () => { }); 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 () => { @@ -118,6 +119,7 @@ describe('AttachmentsView', () => { }); 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 () => {