Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
fda0db3
Fix:Add per-image fallback guard so the original can fall back to the…
CarolineDenis Aug 14, 2026
45f3cf9
Add attachment server runtime state
CarolineDenis Aug 14, 2026
6909f79
Handle unavailable attachment thumbnails
CarolineDenis Aug 14, 2026
cd9c7b8
Show attachment gallery outage warning
CarolineDenis Aug 14, 2026
5842bdd
Feat: Disable attachment menu item when no server connexion
CarolineDenis Aug 17, 2026
3924551
Feat: Record transitions between available and unavailable with times…
CarolineDenis Aug 17, 2026
b48fead
Feat: Confirm server health before changing global availability
CarolineDenis Aug 17, 2026
bdd4ff8
Fix: Disable attachment actions when the server is unavailable
CarolineDenis Aug 17, 2026
ea1b85e
Fix: Return the cleanup function to every subscriber
CarolineDenis Aug 17, 2026
c37c2fc
Fix: Add tooltip for side bar attachement disabled menu item
CarolineDenis Aug 17, 2026
ff90013
Test: Add frontend unit tests for server status
CarolineDenis Aug 17, 2026
8e3853b
Test: Add frontend unit tests for unavailable gallery
CarolineDenis Aug 17, 2026
d9e3a31
Fix: Run the first health check immediately
CarolineDenis Aug 18, 2026
b6d974e
Tests: fix attachment view tests and add header tests
CarolineDenis Aug 18, 2026
bf9929f
Update specifyweb/frontend/js_src/lib/components/Attachments/__tests_…
CarolineDenis Aug 18, 2026
b3b9d5c
Merge branch 'main' into issue-6851
CarolineDenis Aug 18, 2026
5eaea4a
Fix: Add health check to avoid manual reload and cache clearance
CarolineDenis Aug 18, 2026
61d154a
Fix: Reset attachment status in tests
CarolineDenis Aug 18, 2026
391e020
Fix: Log health check error
CarolineDenis Aug 18, 2026
f4ceaac
Fix: Bound the asset-server health probe and startup check
CarolineDenis Aug 18, 2026
31bd679
Fix: Reset image failure state
CarolineDenis Aug 18, 2026
26b904c
Potential fix for pull request finding 'CodeQL / Except block handles…
CarolineDenis Aug 18, 2026
e17faf1
Fix: Add WEB_ATTACHMENT_TIMEOUT to settings
CarolineDenis Aug 19, 2026
f3723bc
Fix: Test, add new health check
CarolineDenis Aug 19, 2026
95c9d66
Test: Add server connexion health check in wb attachment preview
CarolineDenis Aug 19, 2026
22ae597
Update specifyweb/frontend/js_src/lib/components/Header/index.tsx
CarolineDenis Aug 19, 2026
f3a3f3e
Fix: Treat only the health endpoint success status as available
CarolineDenis Aug 19, 2026
207ec59
Test: mock asset server connexion status
CarolineDenis Aug 19, 2026
e19d023
Feat: Restore attachment availability after an outage during backend …
CarolineDenis Aug 20, 2026
b19666c
Test: Fix mock health check
CarolineDenis Aug 20, 2026
024c1a8
Feat: Handle incomplete asset-server configuration
CarolineDenis Aug 21, 2026
10c88da
Fix: Start backoff after the failed request completes
CarolineDenis Aug 21, 2026
b1b4e19
Test: Add a failed-thumbnail test
CarolineDenis Aug 21, 2026
afe5e07
Fix: Validate all required asset-server URL types before setting serv…
CarolineDenis Aug 21, 2026
af11387
Fix: Publish server_urls only after key validation succeedss
CarolineDenis Aug 24, 2026
d2ac8b7
Merge branch 'main' into issue-6851
CarolineDenis Aug 24, 2026
5346007
Update specifyweb/frontend/js_src/lib/components/Attachments/__tests_…
CarolineDenis Aug 25, 2026
f921124
Update specifyweb/frontend/js_src/lib/components/Header/__tests__/Hea…
CarolineDenis Aug 25, 2026
1a336a9
Test: Fix overide
CarolineDenis Aug 25, 2026
2b4504f
Merge branch 'main' into issue-6851
CarolineDenis Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions specifyweb/backend/attachment_gw/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
108 changes: 87 additions & 21 deletions specifyweb/backend/attachment_gw/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Expand All @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return HttpResponse(status=204)

@openapi(schema={
"get": {
"parameters": [
Expand Down
11 changes: 9 additions & 2 deletions specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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
);

Expand Down
20 changes: 18 additions & 2 deletions specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -13,8 +17,15 @@ export function AttachmentPreview({
readonly attachment: SerializedResource<Attachment>;
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
);

Expand Down Expand Up @@ -56,6 +67,11 @@ export function Thumbnail({
width: `${thumbnail.width}px`,
height: `${thumbnail.height}px`,
}}
onError={
thumbnail.isServerBacked === true
? reportAttachmentServerFailure
: undefined
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/>
);
}
Loading
Loading