diff --git a/fixtures/generic/c4904df5-0005-4a80-953e-62b1ede447b4.yaml b/fixtures/generic/c4904df5-0005-4a80-953e-62b1ede447b4.yaml deleted file mode 100644 index 319f562b..00000000 --- a/fixtures/generic/c4904df5-0005-4a80-953e-62b1ede447b4.yaml +++ /dev/null @@ -1,6 +0,0 @@ -source: https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.5/idr0062A/6001240_labels.zarr -name: c4904df5-0005-4a80-953e-62b1ede447b4.yaml -testable: true -version: "0.5" -features: - multiscale: true diff --git a/fixtures/local/only_zarr_json.zarr/zarr.json b/fixtures/local/only_zarr_json.zarr/zarr.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/fixtures/local/only_zarr_json.zarr/zarr.json @@ -0,0 +1 @@ +{} diff --git a/viewer/src/api.tsx b/viewer/src/api.tsx index d6284bab..d7bbf3b8 100644 --- a/viewer/src/api.tsx +++ b/viewer/src/api.tsx @@ -21,6 +21,15 @@ import { import theme from "./theme"; import { defer, typedEmitter } from "./utils"; +export type LogFunction = (message: string) => void; + +export interface Logger { + debug: LogFunction; + info: LogFunction; + warn: LogFunction; + error: LogFunction; +} + type Events = { viewStateChange: ViewState; }; diff --git a/viewer/src/components/LayerController/ChannelOptions.tsx b/viewer/src/components/LayerController/ChannelOptions.tsx index 2d9eaf85..2414da3a 100644 --- a/viewer/src/components/LayerController/ChannelOptions.tsx +++ b/viewer/src/components/LayerController/ChannelOptions.tsx @@ -1,12 +1,9 @@ import { MoreHoriz, Remove } from "@mui/icons-material"; import { Divider, IconButton, Input, NativeSelect, Paper, Popover, Typography } from "@mui/material"; import { styled } from "@mui/material/styles"; -import { useSetAtom } from "jotai"; import React, { useState } from "react"; import type { ChangeEvent, MouseEvent } from "react"; import { useLayerState, useSourceData } from "../../hooks"; -import { sourceWarningAtom } from "../../state"; -import { arraysIdentical, getDefaultChannelLabels } from "../../utils"; import ColorPalette from "./ColorPalette"; const DenseInput = styled(Input)` @@ -23,14 +20,6 @@ function ChannelOptions({ channelIndex }: Props) { const [layer, setLayer] = useLayerState(); const [anchorEl, setAnchorEl] = useState(null); const { channel_axis, names } = sourceData; - const setSourceWarning = useSetAtom(sourceWarningAtom); - const defaultNames = getDefaultChannelLabels(names.length); - - React.useEffect(() => { - if (arraysIdentical(names, defaultNames)) { - setSourceWarning((prev) => [...prev, "Channel metadata either does not exist or was not loaded correctly."]); - } - }, [setSourceWarning, names, defaultNames]); const handleClick = (event: MouseEvent) => { setAnchorEl(event.currentTarget); diff --git a/viewer/src/components/Snackbar.tsx b/viewer/src/components/Snackbar.tsx deleted file mode 100644 index 75e3ba74..00000000 --- a/viewer/src/components/Snackbar.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { type SnackbarKey, SnackbarProvider, closeSnackbar, enqueueSnackbar } from "notistack"; -import React from "react"; - -export function InfoSnackbar(props: { message: string }) { - const hideSnackbar = (snackbarId: SnackbarKey) => ( - <> - - - ); - - React.useEffect(() => { - enqueueSnackbar(props.message, { action: hideSnackbar }); - }); - - return ( -
- -
- ); -} diff --git a/viewer/src/components/VizarrViewer.tsx b/viewer/src/components/VizarrViewer.tsx index e219a7c5..209bf3d2 100644 --- a/viewer/src/components/VizarrViewer.tsx +++ b/viewer/src/components/VizarrViewer.tsx @@ -1,8 +1,16 @@ import { Box, Link, ThemeProvider, Typography } from "@mui/material"; import type { Layer } from "deck.gl"; import { type PrimitiveAtom, Provider, atom, useAtomValue, useSetAtom } from "jotai"; -import React, { useId } from "react"; -import { getSourceDataError, sourceDataValid, writeUserErrorMessage } from "../error"; +import { type SnackbarKey, SnackbarProvider, closeSnackbar, enqueueSnackbar } from "notistack"; +import React from "react"; +import type { Logger } from "../api"; +import { + getSourceDataError, + getSourceDataWarnings, + handleError, + sourceDataValid, + writeUserErrorMessage, +} from "../error"; import { ViewStateContext, useViewState } from "../hooks"; import { createSourceData } from "../io"; import { @@ -17,13 +25,11 @@ import { setZSliceAtom, sourceErrorAtom, sourceInfoAtom, - sourceWarningAtom, viewStateAtom, viewportAtom, } from "../state"; import theme from "../theme"; import Menu from "./Menu"; -import { InfoSnackbar } from "./Snackbar"; import Viewer from "./Viewer"; /** Viewer state snapshot exposed to the host application via onViewerStateChange. */ @@ -51,6 +57,7 @@ export interface VizarrViewerProps { onPluginClick?: (coordinate: [number, number]) => boolean; onPluginHover?: (coordinate: [number, number] | null) => void; children?: React.ReactNode; + logger?: Logger; } /** @@ -131,13 +138,15 @@ function VizarrViewerComponent({ onPluginClick, onPluginHover, children, + logger = console, }: VizarrViewerProps) { const setSourceInfo = useSetAtom(sourceInfoAtom); const setViewStateAtom = useSetAtom(viewStateAtom); const sourceError = useAtomValue(sourceErrorAtom); const redirectObj = useAtomValue(redirectObjAtom); const setSourceError = useSetAtom(sourceErrorAtom); - const sourceWarning = useAtomValue(sourceWarningAtom); + const [snackbarId, setSnackbarId] = React.useState(); + React.useEffect(() => { if (initialViewState) { setViewStateAtom(initialViewState); @@ -166,9 +175,9 @@ function VizarrViewerComponent({ return config; }), ); - React.useEffect(() => { async function loadSources() { + logger.debug("Loading sources"); const results = await Promise.allSettled( configs.map(async (config, index) => { const sourceData = await createSourceData(config); @@ -183,7 +192,9 @@ function VizarrViewerComponent({ ); let sourceDatas = []; if (!sourceDataValid(results)) { - setSourceError(writeUserErrorMessage(getSourceDataError(results))); + const error = getSourceDataError(results); + setSourceError(writeUserErrorMessage(error)); + handleError(error, logger); } for (const res of results) { @@ -195,13 +206,48 @@ function VizarrViewerComponent({ } sourceDatas = sourceDatas.filter((s) => s !== null); sourceDatas = sourceDatas.flat(); + + for (const sourceData of sourceDatas) { + const warnings = getSourceDataWarnings(sourceData); + warnings.map((warning: string) => { + handleWarning(warning); + }); + } + setSourceInfo(sourceDatas); } loadSources(); - }, [configs, setSourceInfo, setSourceError]); + }, [configs, setSourceInfo, setSourceError, logger]); + + const hideSnackbar = (snackbarId: SnackbarKey) => ( + <> + + + ); + + function handleWarning(warning: string): void { + logger.warn(warning); + setSnackbarId(enqueueSnackbar(warning, { variant: "warning", action: hideSnackbar })); + } + return ( <> +
+ +
{redirectObj === null && ( )} - {sourceWarning.length && - sourceWarning.map((warning, index) => { - return ; - })} {redirectObj !== null && ( = { - "Store does not support range requests": "Sharded .ozx files are not currently supported.", - "Failed to fetch": "An error occurred while trying to fetch the file from the server - this is likely a CORs issue.", - "Node not found: v3 array or group": - "No valid .zattrs, .zarray, .zgroup, or zarr.json was found at this URL - please check that the file exists and is correctly formatted.", -}; +import { arraysIdentical, getDefaultChannelLabels } from "./utils"; export function writeUserErrorMessage(error: Error) { - if (error instanceof AssertionError) { - //Error message is raised by this application - return error.message; - } - //Error raised externally - if (Object.keys(errorToMessageMapping).includes(error.message)) { - return errorToMessageMapping[error.message]; - } - return "An unknown error occurred."; + return error.message; } export function sourceDataValid(sourceData: Array>): boolean { @@ -33,3 +19,16 @@ export function getSourceDataError(sourceData: Array): Promise { const { color, contrast_limits, visibility, name, colormap = "", opacity = 1 } = config; const lowres = data[data.length - 1]; @@ -74,7 +76,8 @@ async function loadMultiChannel( } export async function createSourceData(config: ImageLayerConfig): Promise { - const node = await utils.open(config.source); + const node = await openZarrRoot(config.source); + let data: zarr.Array[]; let axes: Ome.Axis[] | undefined; if (node instanceof zarr.Group) { @@ -269,7 +272,9 @@ function getSourceSelectionTransform( ); utils.assert( labels.labels.every((label) => source.labels.includes(label)), - `Label axes MUST be a subset of source. Source: ${JSON.stringify(source.labels)} Labels: ${JSON.stringify(labels.labels)}`, + `Label axes MUST be a subset of source. Source: ${JSON.stringify(source.labels)} Labels: ${JSON.stringify( + labels.labels, + )}`, ); // Identify labels that should always map to 0, regardless of the source selection. const excludeFromTransformedSelection = new Set( diff --git a/viewer/src/services/http.ts b/viewer/src/services/http.ts new file mode 100644 index 00000000..8abb9e58 --- /dev/null +++ b/viewer/src/services/http.ts @@ -0,0 +1,98 @@ +import * as zarr from "zarrita"; +import { normalizeStore } from "../utils"; + +const MAYBE_CORS_ERROR_MESSAGE = "Failed to fetch"; + +export class HttpError extends Error { + message: string; + cause: string; + code?: number; + constructor(message: string, cause: string, code?: number) { + super(message); + this.name = "HttpError"; + this.message = message; + this.cause = cause; + this.code = code; + Object.setPrototypeOf(this, HttpError.prototype); + } +} + +export class MetadataError extends Error { + message: string; + cause: string; + constructor(message: string, cause: string) { + super(message); + this.name = "MetadataError"; + this.message = message; + this.cause = cause; + Object.setPrototypeOf(this, MetadataError.prototype); + } +} + +export class MetadataNotFoundError extends MetadataError { + constructor(message: string, cause: string) { + super(message, cause); + this.name = "MetadataNotFoundError"; + this.message = message; + this.cause = cause; + Object.setPrototypeOf(this, MetadataNotFoundError.prototype); + } +} + +export async function openZarrRoot(source: string | zarr.Readable): Promise>> { + let url: string; + + if (typeof source === "string") { + url = source; + } else { + url = zarr.root(source).path; + } + try { + const { statusText, status } = await fetch(url, { method: "GET" }); + if (status === 400) { + throw new HttpError( + `400: The server could not process the request to access the resource at ${source}. The request was invalid.`, + statusText, + status, + ); + } + + if (status === 403) { + throw new HttpError( + `403: Unauthorized to access resource at ${source}. Please check the specified URL is correct and that permission to access it is not restricted.`, + statusText, + status, + ); + } + + if (status === 401) { + throw new HttpError( + `401: Unauthorized to access resource at ${source}. Please check the specified URL is correct and that permission to access it is not restricted.`, + statusText, + status, + ); + } + + const store = await normalizeStore(source); + const location = await zarr.open(store, { kind: "group" }); + return location; + } catch (error) { + if (error instanceof TypeError) { + if (error.message === MAYBE_CORS_ERROR_MESSAGE) { + throw new HttpError( + `An unknown error occurred while trying to fetch the resource ${source} from the server - this is most likely a CORs issue.`, + error.message, + ); + } + } + + if (error instanceof zarr.NodeNotFoundError) { + throw new MetadataNotFoundError( + `404: No valid metadata file found at zarr group ${source}, please check the specified URL is correct.`, + error.message, + ); + } + + throw error; + } +} diff --git a/viewer/src/state.ts b/viewer/src/state.ts index a335387d..821f95ff 100644 --- a/viewer/src/state.ts +++ b/viewer/src/state.ts @@ -121,7 +121,6 @@ type WithId = T & { id: string }; export const viewStateAtom = atom(null); export const sourceErrorAtom = atom(null); -export const sourceWarningAtom = atom([]); /** * Derived atom that exposes the current Z-axis selection and metadata diff --git a/viewer/src/utils.ts b/viewer/src/utils.ts index b9fa3472..325c2a84 100644 --- a/viewer/src/utils.ts +++ b/viewer/src/utils.ts @@ -54,7 +54,8 @@ export async function normalizeStore(source: string | zarr.Readable): Promise( location: zarr.Location, options: { path?: string; zarrVersion: 2 | 3 }, diff --git a/viewer/tests/error-handling.test.ts b/viewer/tests/error-handling.test.ts new file mode 100644 index 00000000..5df687e9 --- /dev/null +++ b/viewer/tests/error-handling.test.ts @@ -0,0 +1,120 @@ +// @ts-nocheck +import * as fs from "node:fs"; +import * as http from "node:http"; +import * as path from "node:path"; +import { afterEach, beforeEach, expect, test } from "vitest"; + +import type { Server } from "node:http"; +import { Group } from "zarrita"; +import { createSourceData } from "../src/io"; +import { HttpError, MetadataNotFoundError, openZarrRoot } from "../src/services/http"; + +let server: Server; +let port: number; +let server_url: string; + +afterEach(() => { + server.close(); +}); + +beforeEach(() => { + server = http.createServer((req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + if (req.url === "/404") { + res.writeHead(404); + res.end("Not found!"); + return; + } + if (req.url === "/403") { + res.writeHead(403); + res.end("Forbidden"); + return; + } + + if (req.url === "/400") { + res.writeHead(400); + res.end("Invalid request"); + return; + } + + if (req.url === "/401") { + res.writeHead(401); + res.end("Forbidden"); + return; + } + const root = path.resolve(path.resolve(__dirname), "..", "..", "fixtures", "local"); + const relativePath = req.url; + + const fullPath = path.join(root, relativePath); + + fs.exists(fullPath, (exists) => { + if (!exists) { + res.writeHead(404); + res.end(); + return; + } + res.writeHead(409); + res.end(); + return; + }); + }); + server.listen(() => {}); + port = server.address().port; + server_url = `http://localhost:${port}`; +}); + +test("Enters test suite", async () => { + expect(1 + 1).toBe(2); +}); + +test("Server responds with status code 404", async () => { + const results = await Promise.allSettled([createSourceData({ source: `${server_url}/404` })]); + results.forEach((result, index) => { + expect(result.status).toBe("rejected"); + expect(result.reason).instanceOf(MetadataNotFoundError); + }); +}); + +test("Server responds with status code 403", async () => { + const results = await Promise.allSettled([createSourceData({ source: `${server_url}/403` })]); + results.forEach((result, index) => { + expect(result.status).toBe("rejected"); + expect(result.reason.code).toBe(403); + expect(result.reason).instanceOf(HttpError); + }); +}); + +test("Server responds with status code 401", async () => { + const results = await Promise.allSettled([createSourceData({ source: `${server_url}/401` })]); + results.forEach((result, index) => { + expect(result.status).toBe("rejected"); + expect(result.reason.code).toBe(401); + expect(result.reason).instanceOf(HttpError); + }); +}); + +test("Server responds with status code 401", async () => { + const results = await Promise.allSettled([createSourceData({ source: `${server_url}/401` })]); + results.forEach((result, index) => { + expect(result.status).toBe("rejected"); + expect(result.reason.code).toBe(401); + expect(result.reason).instanceOf(HttpError); + }); +}); + +test("Server handles zarr files with no metadata", async () => { + const results = await Promise.allSettled([createSourceData({ source: `${server_url}/empty.zarr` })]); + results.forEach((result, index) => { + expect(result.status).toBe("rejected"); + expect(result.reason).toBeInstanceOf(MetadataNotFoundError); + }); +}); + +//Ensure that the error handling is not over-eager +test("Server handles zarr metadata errors elegantly", async () => { + const source = "https://livingobjects.ebi.ac.uk/idr/zarr/v0.5/idr0062A/6001240_labels.zarr"; + const result = await openZarrRoot(source); + expect(result).toBeInstanceOf(Group); +}); diff --git a/viewer/tests/metadata.js b/viewer/tests/metadata.js index 3f211955..3f45983b 100644 --- a/viewer/tests/metadata.js +++ b/viewer/tests/metadata.js @@ -1,10 +1,11 @@ import fs from "node:fs"; import path from "node:path"; import { stringify } from "yaml"; +import { openZarrRoot } from "../src/services/http"; import * as utils from "../src/utils"; export async function writeImageYaml(url, imageName, savePath) { - const node = await utils.open(url); + const node = await openZarrRoot(url); const attrs = utils.resolveAttrs(node.attrs); const metadata = { source: url,