Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions fileglancer/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -597,9 +597,13 @@ def get_cloud_buckets(session: Session, username: str) -> List[CloudBucketDB]:
return session.query(CloudBucketDB).filter_by(username=username).order_by(CloudBucketDB.name).all()


def bucket_url_roots(session: Session, username: str) -> List[Tuple[str, str]]:
"""(fsp_name, public base URL) of each of the user's buckets, for ngstate.bucket_source."""
return [(b.name, s3_public_url(b.bucket, b.endpoint_url, b.prefix)) for b in get_cloud_buckets(session, username)]
def bucket_url_roots(session: Session, username: str) -> List[Tuple[str, str, str]]:
"""(fsp_name, public base URL, path it serves) of each of the user's buckets
and each externally mapped path, for ngstate.bucket_source."""
return ([(b.name, s3_public_url(b.bucket, b.endpoint_url, b.prefix), '')
for b in get_cloud_buckets(session, username)]
+ [(e.fsp_name, e.external_url, e.relative_path or '')
for e in get_external_buckets(session) if e.external_url])


def get_cloud_bucket(session: Session, username: str, name: str) -> Optional[CloudBucketDB]:
Expand Down Expand Up @@ -1328,6 +1332,24 @@ def _path_aliases(path: str) -> List[str]:
return ["", "."] if path == "" else [path]


def get_views_for_dataset(session: Session, owner: str, fsp_name: str, path: str) -> List[ViewDB]:
"""The owner's Views with a working layer on this dataset, matched by
(fsp_name, path) so layers without a Data Link (cloud buckets, externally
mapped paths) count too. IN-subquery for the reason in get_views_for_data_link."""
layer_view_ids = (
session.query(ViewLayerDB.view_id)
.filter(ViewLayerDB.broken.is_(False),
ViewLayerDB.fsp_name == fsp_name,
ViewLayerDB.path.in_(_path_aliases(path)))
)
return (
session.query(ViewDB)
.filter(ViewDB.id.in_(layer_view_ids), ViewDB.owner == owner)
.order_by(ViewDB.created_at.desc())
.all()
)


def get_relinkable_views(session: Session, owner: str, fsp_name: str, path: str) -> List[ViewDB]:
"""The owner's Views with a broken layer on this dataset that can be
relinked (its dead Data Link key and prefix are known)."""
Expand Down
15 changes: 9 additions & 6 deletions fileglancer/ngstate.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,19 @@ def layer_sharing_key(layer: dict, proxy_url: str) -> Optional[str]:
return None


def bucket_source(layer: dict, buckets: Iterable[Tuple[str, str]]) -> Optional[Tuple[str, str]]:
def bucket_source(layer: dict, buckets: Iterable[Tuple[str, str, str]]) -> Optional[Tuple[str, str]]:
"""(fsp_name, path) of the first source in `layer` served straight from one
of `buckets`, given as (fsp_name, public base URL) pairs; None if none is.
A bucket layer has no Data Link, so its source URL is the only identity."""
roots = sorted(((name, root.rstrip('/')) for name, root in buckets), key=lambda r: -len(r[1]))
of `buckets`, given as (fsp_name, public base URL, path the URL serves)
triples; None if none is. A bucket layer has no Data Link, so its source
URL is the only identity."""
roots = sorted(((name, root.rstrip('/'), prefix.strip('/')) for name, root, prefix in buckets),
key=lambda r: -len(r[1]))
for url in _iter_source_urls(layer.get('source')):
base = re.split(r'[|?#]', _SCHEME_PREFIX.sub('', url), maxsplit=1)[0]
for name, root in roots:
for name, root, prefix in roots:
if base == root or base.startswith(root + '/'):
return name, unquote(base[len(root):]).strip('/')
rest = unquote(base[len(root):]).strip('/')
return name, '/'.join(p for p in (prefix, rest) if p)
return None


Expand Down
10 changes: 10 additions & 0 deletions fileglancer/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1756,6 +1756,16 @@ async def list_views_endpoint(username: str = Depends(get_current_user)):
views = db.get_views(session, username)
return ViewResponse(views=[View.model_validate(v) for v in views])

@app.get("/api/neuroglancer/views/for-dataset", response_model=ViewResponse,
description="The caller's Views with a working layer on this dataset")
async def views_for_dataset_endpoint(fsp_name: str = Query(..., description="File share path name"),
path: str = Query(..., description="Path relative to the file share path"),
username: str = Depends(get_current_user)):
path = _normalize_proxied_path(path)
with db.get_db_session(settings.db_url) as session:
views = db.get_views_for_dataset(session, username, fsp_name, path)
return ViewResponse(views=[View.model_validate(v) for v in views])

@app.get("/api/neuroglancer/views/relinkable", response_model=ViewSummaryResponse,
description="The caller's broken Views that a Data Link to this dataset would repair")
async def relinkable_views_endpoint(fsp_name: str = Query(..., description="File share path name"),
Expand Down
40 changes: 37 additions & 3 deletions frontend/src/__tests__/componentTests/AppearsInViews.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,25 @@ import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';

const { useViewsForDataLinkQuery } = vi.hoisted(() => ({
useViewsForDataLinkQuery: vi.fn()
const { useViewsForDataLinkQuery, useViewsForDatasetQuery } = vi.hoisted(
() => ({
useViewsForDataLinkQuery: vi.fn(),
useViewsForDatasetQuery: vi.fn()
})
);
vi.mock('@/queries/viewQueries', () => ({
useViewsForDataLinkQuery,
useViewsForDatasetQuery
}));
vi.mock('@/queries/viewQueries', () => ({ useViewsForDataLinkQuery }));

// What a disabled TanStack query returns
const disabled = { data: undefined, isPending: true, isError: false };

import AppearsInViews from '@/components/ui/PropertiesDrawer/AppearsInViews';

describe('AppearsInViews', () => {
it('lists the dependent Views with a count', () => {
useViewsForDatasetQuery.mockReturnValue(disabled);
useViewsForDataLinkQuery.mockReturnValue({
data: [
{ short_key: 'v1', name: 'Alpha', read_key: 'rk1' },
Expand All @@ -35,7 +45,31 @@ describe('AppearsInViews', () => {
);
});

it('lists the Views of a dataset without a Data Link', () => {
useViewsForDataLinkQuery.mockReturnValue(disabled);
useViewsForDatasetQuery.mockReturnValue({
data: [{ short_key: 'v1', name: 'Alpha', read_key: 'rk1' }],
isPending: false,
isError: false
});
render(
<MemoryRouter>
<AppearsInViews fspName="idr" path="zarr/img.zarr" />
</MemoryRouter>
);
expect(useViewsForDatasetQuery).toHaveBeenCalledWith(
'idr',
'zarr/img.zarr'
);
expect(screen.getByText(/appears in 1 view$/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Alpha' })).toHaveAttribute(
'href',
'/view/rk1'
);
});

it('renders nothing when there are no dependent Views', () => {
useViewsForDatasetQuery.mockReturnValue(disabled);
useViewsForDataLinkQuery.mockReturnValue({
data: [],
isPending: false,
Expand Down
13 changes: 9 additions & 4 deletions frontend/src/__tests__/componentTests/CreateViewButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ let automatic = true;
vi.mock('@/hooks/useCartCheckout', () => ({
useCartCheckout: () => ({
checkout,
isBucketDataset: (ds: { fsp_name: string }) => ds.fsp_name === 'bucket'
isServedWithoutLink: async (ds: { fsp_name: string }) =>
ds.fsp_name === 'bucket'
})
}));
vi.mock('@/contexts/PreferencesContext', () => ({
Expand Down Expand Up @@ -56,7 +57,7 @@ describe('CreateViewButton', () => {
).not.toBeInTheDocument();

await user.click(screen.getByRole('button', { name: /^create$/i }));
expect(checkout).toHaveBeenCalledWith(datasets, 'V');
expect(checkout).toHaveBeenCalledWith(datasets, 'V', undefined);
});

it('checks out with the edited name, not defaultName', async () => {
Expand All @@ -69,7 +70,11 @@ describe('CreateViewButton', () => {
await user.type(input, 'My Renamed View');

await user.click(screen.getByRole('button', { name: /^create$/i }));
expect(checkout).toHaveBeenCalledWith(datasets, 'My Renamed View');
expect(checkout).toHaveBeenCalledWith(
datasets,
'My Renamed View',
undefined
);
});

it('disables the create button when the name is empty', async () => {
Expand Down Expand Up @@ -99,6 +104,6 @@ describe('CreateViewButton', () => {
await user.type(input, 'Linked View');

await user.click(screen.getByRole('button', { name: /continue/i }));
expect(checkout).toHaveBeenCalledWith(datasets, 'Linked View');
expect(checkout).toHaveBeenCalledWith(datasets, 'Linked View', undefined);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ describe('SidebarFileBrowser', () => {
false,
expect.any(Function)
);
expect(addToView).toHaveBeenCalledWith(items);
expect(addToView).toHaveBeenCalledWith(items, { urlPrefixes: undefined });
expect(
screen.getByRole('checkbox', { name: 'Select a.zarr' })
).not.toBeChecked();
Expand Down
19 changes: 11 additions & 8 deletions frontend/src/__tests__/componentTests/ViewerSidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';

import { makeFakeBridge } from '@/__tests__/mocks/fakeNeuroglancer';
Expand Down Expand Up @@ -98,14 +99,16 @@ function setup() {
selectedLayer: { layer: 'a GFP', visible: true }
});
render(
<ViewerSidebar
bridge={fake.bridge}
editState={editState}
onClose={vi.fn()}
onTabChange={vi.fn()}
tab="sources"
viewLayers={VIEW_LAYERS}
/>
<QueryClientProvider client={new QueryClient()}>
<ViewerSidebar
bridge={fake.bridge}
editState={editState}
onClose={vi.fn()}
onTabChange={vi.fn()}
tab="sources"
viewLayers={VIEW_LAYERS}
/>
</QueryClientProvider>
);
return fake;
}
Expand Down
99 changes: 95 additions & 4 deletions frontend/src/__tests__/componentTests/useCartCheckout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type { ReactNode } from 'react';
const createProxied = vi.fn();
const createViewAsync = vi.fn();
const buildViewState = vi.hoisted(() => vi.fn());
const fetchExternalBucket = vi.hoisted(() => vi.fn());
let subpathMode = 'name';

vi.mock('@/queries/proxiedPathQueries', () => ({
useAllProxiedPathsQuery: () => ({
Expand All @@ -19,7 +21,14 @@ vi.mock('@/contexts/ViewsContext', () => ({
})
}));
vi.mock('@/contexts/PreferencesContext', () => ({
usePreferencesContext: () => ({ disableHeuristicalLayerTypeDetection: false })
usePreferencesContext: () => ({
disableHeuristicalLayerTypeDetection: false,
dataLinkSubpathMode: subpathMode
})
}));
vi.mock('@/queries/externalBucketQueries', async importOriginal => ({
...(await importOriginal<object>()),
fetchExternalBucket
}));
vi.mock('@/utils/viewCheckout', () => ({
buildViewState
Expand All @@ -28,7 +37,12 @@ vi.mock('@/contexts/ZonesAndFspMapContext', () => ({
useZoneAndFspMapContext: () => ({
zonesAndFspQuery: {
data: {
fsp_f: { name: 'f', protocol: 'file', mount_path: '/f' },
fsp_f: {
name: 'f',
protocol: 'file',
mount_path: '/f',
linux_path: '/groups/f'
},
fsp_bucket: {
name: 'bucket',
protocol: 's3',
Expand All @@ -49,6 +63,8 @@ function wrapper({ children }: { children: ReactNode }) {
}

beforeEach(() => {
subpathMode = 'name';
fetchExternalBucket.mockReset().mockResolvedValue(null);
createProxied.mockReset().mockResolvedValue({
fsp_name: 'f',
path: '/b',
Expand Down Expand Up @@ -117,8 +133,83 @@ describe('useCartCheckout', () => {
})
]);
expect(createProxied).not.toHaveBeenCalled();
expect(result.current.isBucketDataset({ fsp_name: 'bucket' })).toBe(true);
expect(result.current.isBucketDataset({ fsp_name: 'f' })).toBe(false);
expect(
await result.current.isServedWithoutLink({ fsp_name: 'bucket', path: '' })
).toBe(true);
expect(
await result.current.isServedWithoutLink({ fsp_name: 'f', path: '/b' })
).toBe(false);
});

it('names new Data Links by the subpath-mode preference, or the given custom prefix', async () => {
subpathMode = 'full_path';
const { result } = renderHook(() => useCartCheckout(), { wrapper });
await result.current.resolveCartDatasets([
{ fsp_name: 'f', path: 'x/b.zarr', label: 'B' }
]);
expect(createProxied).toHaveBeenLastCalledWith({
fsp_name: 'f',
path: 'x/b.zarr',
url_prefix: 'groups/f/x/b.zarr'
});

await result.current.resolveCartDatasets(
[{ fsp_name: 'f', path: 'x/c.zarr', label: 'C' }],
{ 'f::x/c.zarr': 'my-name' }
);
expect(createProxied).toHaveBeenLastCalledWith(
expect.objectContaining({ path: 'x/c.zarr', url_prefix: 'my-name' })
);
});

it('uses an external bucket URL instead of a Data Link', async () => {
fetchExternalBucket.mockResolvedValue({
full_path: '/groups/f/pub',
external_url: 'https://s3.example.org/pub',
fsp_name: 'f',
relative_path: 'pub'
});
const { result } = renderHook(() => useCartCheckout(), { wrapper });
const resolved = await result.current.resolveCartDatasets([
{ fsp_name: 'f', path: 'pub/s.zarr', label: 'S' }
]);
expect(resolved).toEqual([
expect.objectContaining({
url: 'https://s3.example.org/pub/s.zarr',
sharing_key: null
})
]);
expect(createProxied).not.toHaveBeenCalled();
expect(
await result.current.isServedWithoutLink({
fsp_name: 'f',
path: 'pub/s.zarr'
})
).toBe(true);
});

it('falls back to a Data Link when the external bucket lookup fails', async () => {
fetchExternalBucket.mockRejectedValue(new Error('boom'));
const { result } = renderHook(() => useCartCheckout(), { wrapper });
const resolved = await result.current.resolveCartDatasets([
{ fsp_name: 'f', path: '/b', label: 'B' }
]);
expect(resolved).toEqual([expect.objectContaining({ sharing_key: 'kb' })]);
expect(createProxied).toHaveBeenCalled();
expect(
await result.current.isServedWithoutLink({ fsp_name: 'f', path: '/b' })
).toBe(false);
});

it('rejects an invalid custom prefix before creating the link', async () => {
const { result } = renderHook(() => useCartCheckout(), { wrapper });
await expect(
result.current.resolveCartDatasets(
[{ fsp_name: 'f', path: 'x/c.zarr', label: 'C' }],
{ 'f::x/c.zarr': 'a//b' }
)
).rejects.toThrow(/consecutive slashes/);
expect(createProxied).not.toHaveBeenCalled();
});

it('resolves Data Links without building or creating a View', async () => {
Expand Down
Loading
Loading