+ setUrlPrefixes(prev => ({ ...prev, [key]: value }))
+ }
+ value={urlPrefixes[key] ?? ''}
+ />
+ );
+ })
+ : null}
>
) : null}
@@ -294,7 +373,11 @@ export function useCreateViewFlow() {
0
+ }
loading={pending}
loadingText={creating ? 'Creating...' : 'Adding...'}
onClick={() => void runRequest()}
diff --git a/frontend/src/hooks/useViewSources.ts b/frontend/src/hooks/useViewSources.ts
index c6c740b51..8e7e324ef 100644
--- a/frontend/src/hooks/useViewSources.ts
+++ b/frontend/src/hooks/useViewSources.ts
@@ -1,4 +1,5 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useQueries } from '@tanstack/react-query';
import {
dataLinkSegment,
@@ -9,8 +10,14 @@ import { bucketDatasetUrl, datasetKey } from '@/utils/pathHandling';
import { isReadOnlyFsp } from '@/utils/cloudStorage';
import { makeMapKey } from '@/utils';
import useZoneAndFileSharePathMapQuery from '@/queries/zoneAndFileSharePathMapQuery';
+import {
+ externalBucketQueryKeys,
+ fetchExternalBucket,
+ transformBucketToDataUrl
+} from '@/queries/externalBucketQueries';
import type { NeuroglancerBridge } from '@/hooks/useNeuroglancerViewer';
import type { ViewLayer } from '@/queries/viewQueries';
+import type { ExternalBucket } from '@/queries/externalBucketQueries';
import type { FileSharePath } from '@/shared.types';
import type { NgLayer, ResolvedCheckoutDataset } from '@/utils/viewCheckout';
@@ -214,9 +221,49 @@ export function useViewSources(
};
}, [status, getState, subscribe]);
- // A saved bucket layer has no Data Link; its segment is the dataset's
- // public URL, built the same way checkout built the layer's source.
+ // A saved layer without a Data Link is in a public bucket or an externally
+ // mapped path; its segment is the dataset's public URL, built the same way
+ // checkout built the layer's source.
const fspMap = useZoneAndFileSharePathMapQuery().data;
+ const fspOf = useCallback(
+ (fspName: string) =>
+ fspMap?.[makeMapKey('fsp', fspName)] as FileSharePath | undefined,
+ [fspMap]
+ );
+ const externalFspNames = [
+ ...new Set(
+ viewLayers
+ .filter(
+ l =>
+ l.sharing_key === null &&
+ l.fsp_name !== null &&
+ !isReadOnlyFsp(fspOf(l.fsp_name))
+ )
+ .map(l => l.fsp_name!)
+ )
+ ].sort();
+ // A stable combine keeps the Map, and so the rows below, from changing on
+ // every render.
+ const externalKey = externalFspNames.join('\n');
+ const combineBuckets = useCallback(
+ (results: { data?: ExternalBucket | null }[]) =>
+ new Map(
+ (externalKey ? externalKey.split('\n') : []).map((name, i) => [
+ name,
+ results[i]?.data ?? null
+ ])
+ ),
+ [externalKey]
+ );
+ const externalBuckets = useQueries({
+ queries: externalFspNames.map(fspName => ({
+ queryKey: externalBucketQueryKeys.byFsp(fspName),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ fetchExternalBucket(fspName, signal),
+ staleTime: 5 * 60 * 1000
+ })),
+ combine: combineBuckets
+ });
const { known, unsupported } = useMemo(() => {
const saved: KnownDataset[] = [];
const unsupported: KnownDataset[] = [];
@@ -228,10 +275,14 @@ export function useViewSources(
if (l.sharing_key !== null && l.url_prefix !== null) {
segment = dataLinkSegment(l.sharing_key, l.url_prefix);
} else if (l.sharing_key === null && !l.broken) {
- const fsp = fspMap?.[makeMapKey('fsp', l.fsp_name)] as
- | FileSharePath
- | undefined;
- segment = isReadOnlyFsp(fsp) ? bucketDatasetUrl(fsp!, l.path) : null;
+ const fsp = fspOf(l.fsp_name);
+ segment = isReadOnlyFsp(fsp)
+ ? bucketDatasetUrl(fsp!, l.path)
+ : transformBucketToDataUrl(
+ externalBuckets.get(l.fsp_name) ?? null,
+ l.path,
+ l.fsp_name
+ );
}
if (segment === null) {
continue;
@@ -264,7 +315,14 @@ export function useViewSources(
known: [...saved, ...linked],
unsupported: [...unsupported, ...pending]
};
- }, [viewLayers, added, pendingSources, removedSources, fspMap]);
+ }, [
+ viewLayers,
+ added,
+ pendingSources,
+ removedSources,
+ fspOf,
+ externalBuckets
+ ]);
return useMemo(
() => groupLayersBySource(layers, known, unsupported),
diff --git a/frontend/src/omezarr-helper.ts b/frontend/src/omezarr-helper.ts
index ecb03d9e8..3293f816b 100644
--- a/frontend/src/omezarr-helper.ts
+++ b/frontend/src/omezarr-helper.ts
@@ -797,9 +797,11 @@ async function getZarrArray(
dataUrl: string,
zarrVersion: 2 | 3
): Promise> {
+ // 'same-origin', not 'include': public buckets answer CORS with
+ // Allow-Origin '*', which browsers reject for credentialed requests.
const store = new zarr.FetchStore(dataUrl, {
overrides: {
- credentials: 'include'
+ credentials: 'same-origin'
}
});
return await omezarr.getArray(store, '/', zarrVersion);
@@ -811,7 +813,7 @@ async function getZarrArray(
async function getOmeZarrMetadata(dataUrl: string): Promise {
const store = new zarr.FetchStore(dataUrl, {
overrides: {
- credentials: 'include'
+ credentials: 'same-origin'
}
});
const { arr, shapes, multiscale, omero, scales, zarr_version } =
@@ -946,7 +948,7 @@ async function getOmeZarrThumbnail(
): Promise {
const store = new zarr.FetchStore(dataUrl, {
overrides: {
- credentials: 'include',
+ credentials: 'same-origin',
signal
}
});
diff --git a/frontend/src/queries/externalBucketQueries.ts b/frontend/src/queries/externalBucketQueries.ts
index 10b1f5abe..2dc2e687d 100644
--- a/frontend/src/queries/externalBucketQueries.ts
+++ b/frontend/src/queries/externalBucketQueries.ts
@@ -21,7 +21,7 @@ export const externalBucketQueryKeys = {
* Fetches an external bucket by FSP name
* Returns null if no external bucket exists (404)
*/
-const fetchExternalBucket = async (
+export const fetchExternalBucket = async (
fspName: string,
signal?: AbortSignal
): Promise => {
diff --git a/frontend/src/queries/viewQueries.ts b/frontend/src/queries/viewQueries.ts
index 0589f9194..4c1f9a834 100644
--- a/frontend/src/queries/viewQueries.ts
+++ b/frontend/src/queries/viewQueries.ts
@@ -96,6 +96,9 @@ export const viewQueryKeys = {
forDataLink: (sharingKey: string) =>
['views', 'forDataLink', sharingKey] as const,
forDataLinkAll: () => ['views', 'forDataLink'] as const,
+ forDataset: (fspName: string, path: string) =>
+ ['views', 'forDataset', fspName, path] as const,
+ forDatasetAll: () => ['views', 'forDataset'] as const,
relinkable: (fspName: string, path: string) =>
['views', 'relinkable', fspName, path] as const,
state: (readKey: string) => ['views', 'state', readKey] as const
@@ -211,6 +214,32 @@ export function useViewsForDataLinkQuery(
});
}
+/**
+ * Query hook for the Views with a working layer on a dataset, matched by
+ * (fspName, path), so datasets without a Data Link are covered. Disabled
+ * until both are provided.
+ */
+export function useViewsForDatasetQuery(
+ fspName?: string,
+ path?: string
+): UseQueryResult {
+ return useQuery({
+ queryKey: viewQueryKeys.forDataset(fspName ?? '', path ?? ''),
+ queryFn: async ({ signal }) => {
+ const url = buildUrl('/api/neuroglancer/views/for-dataset', null, {
+ fsp_name: fspName!,
+ path: path!
+ });
+ const data = (await sendRequestAndThrowForNotOk(url, 'GET', undefined, {
+ signal
+ })) as ViewsResponse;
+ return data.views ?? [];
+ },
+ // path '' is the FSP root, so test for undefined, not falsiness
+ enabled: fspName !== undefined && path !== undefined
+ });
+}
+
export function useRelinkableViewsQuery(
fspName?: string,
path?: string
@@ -347,6 +376,9 @@ export function useUpdateViewMutation(): UseMutationResult<
queryClient.invalidateQueries({
queryKey: viewQueryKeys.forDataLinkAll()
});
+ queryClient.invalidateQueries({
+ queryKey: viewQueryKeys.forDatasetAll()
+ });
// A name-only rename must not touch the state query: the embedded
// viewer's active viewQueryKeys.state(readKey) query would refetch,
// changing the iframe src and reloading Neuroglancer (losing the
diff --git a/tests/test_database.py b/tests/test_database.py
index b7c5bd527..82aa2cfe0 100644
--- a/tests/test_database.py
+++ b/tests/test_database.py
@@ -1153,3 +1153,42 @@ def test_reconcile_keeps_bucket_layers_as_dataset_rows(db_session):
rows = sorted(v.layers, key=lambda l: l.layer_index)
assert [(r.fsp_name, r.path, r.channel, r.broken) for r in rows] == [
(None, None, None, False), ("idr", "zarr/img.zarr", "DAPI", False)]
+
+
+def test_reconcile_keeps_externally_mapped_layers_as_dataset_rows(db_session):
+ db_session.add(ExternalBucketDB(full_path="/nrs/lab/data_external", external_url="https://s3.example.org/lab/",
+ fsp_name="nrs_lab", relative_path="data_external"))
+ db_session.commit()
+ src = "https://s3.example.org/lab/a/img.zarr/|zarr3:"
+ state = {"layers": [{"name": "img", "source": src}]}
+ v = create_view(db_session, "u", "e", state,
+ [{"layer_index": 0, "channel": None, "opts": None,
+ "fsp_name": "nrs_lab", "path": "data_external/a/img.zarr"}], "read")
+
+ update_view(db_session, v, ng_state=state, proxy_url=PROXY)
+ assert [(r.fsp_name, r.path, r.broken) for r in v.layers] == [("nrs_lab", "data_external/a/img.zarr", False)]
+
+
+def test_get_views_for_dataset_matches_layers_without_data_links(db_session):
+ def layer(i, fsp_name, path, channel=None):
+ return {"layer_index": i, "channel": channel, "opts": None, "fsp_name": fsp_name, "path": path}
+
+ bucket = create_view(db_session, "u", "bucket", {"layers": []}, [layer(0, "idr", "zarr/img.zarr")], "read")
+ external = create_view(db_session, "u", "ext", {"layers": []},
+ [layer(0, "nrs_lab", "data_external/a/img.zarr")], "read")
+ channels = create_view(db_session, "u", "channels", {"layers": []},
+ [layer(0, "idr", "zarr/img.zarr", "DAPI"), layer(1, "idr", "zarr/img.zarr", "GFP")], "read")
+ create_view(db_session, "other", "theirs", {"layers": []}, [layer(0, "idr", "zarr/img.zarr")], "read")
+
+ found = get_views_for_dataset(db_session, "u", "idr", "zarr/img.zarr")
+ assert sorted(v.short_key for v in found) == sorted([bucket.short_key, channels.short_key])
+ assert [v.short_key for v in get_views_for_dataset(db_session, "u", "nrs_lab", "data_external/a/img.zarr")] == [external.short_key]
+
+
+def test_get_views_for_dataset_skips_broken_layers_and_matches_legacy_root(db_session):
+ v = create_view(db_session, "u", "root", {"layers": []},
+ [{"layer_index": 0, "channel": None, "opts": None, "fsp_name": "nrs", "path": "."}], "read")
+ assert [x.short_key for x in get_views_for_dataset(db_session, "u", "nrs", "")] == [v.short_key]
+ v.layers[0].broken = True
+ db_session.commit()
+ assert get_views_for_dataset(db_session, "u", "nrs", "") == []
diff --git a/tests/test_ngstate.py b/tests/test_ngstate.py
index 442b53f0c..e9bf160d1 100644
--- a/tests/test_ngstate.py
+++ b/tests/test_ngstate.py
@@ -75,7 +75,8 @@ def test_default_url_prefix():
def test_bucket_source_matches_public_url_and_unquotes_path():
from fileglancer.ngstate import bucket_source
- roots = [("idr", "https://idr.s3.amazonaws.com"), ("deep", "https://s3.example.org/b/pre%20fix")]
+ roots = [("idr", "https://idr.s3.amazonaws.com", ""), ("deep", "https://s3.example.org/b/pre%20fix", ""),
+ ("nrs", "https://s3.example.org/pub/", "data_external")]
assert bucket_source({"source": "zarr://https://idr.s3.amazonaws.com/zarr/v0.4/my%20img.zarr|zarr2:"}, roots) == (
"idr", "zarr/v0.4/my img.zarr")
# the longest root wins; the bucket root itself is the empty path
@@ -84,3 +85,7 @@ def test_bucket_source_matches_public_url_and_unquotes_path():
assert bucket_source({"source": "https://idr.s3.amazonaws.com.evil/x"}, roots) is None
assert bucket_source({"source": "s3://idr/x.zarr"}, roots) is None
assert bucket_source({"type": "annotation"}, roots) is None
+ # an externally mapped path: the URL's path sits under the mapped folder
+ assert bucket_source({"source": "https://s3.example.org/pub/a/b.zarr/|zarr3:"}, roots) == (
+ "nrs", "data_external/a/b.zarr")
+ assert bucket_source({"source": "https://s3.example.org/pub"}, roots) == ("nrs", "data_external")