From ebb3c7f234b48ea59707b1847f900babb803a1ee Mon Sep 17 00:00:00 2001 From: Alex Surtees Date: Tue, 30 Jun 2026 10:34:57 +0100 Subject: [PATCH 1/6] Handle missing metadata --- .../c4904df5-0005-4a80-953e-62b1ede447b4.yaml | 6 - fixtures/local/only_zarr_json.zarr/zarr.json | 0 sites/app/src/App.tsx | 8 ++ viewer/src/api.tsx | 9 ++ viewer/src/components/VizarrViewer.tsx | 13 +- viewer/src/error.ts | 24 +--- viewer/src/io.ts | 8 +- viewer/src/services/http.ts | 104 +++++++++++++++ viewer/src/utils.ts | 8 +- viewer/tests/error-handling.test.ts | 120 ++++++++++++++++++ viewer/tests/metadata.js | 3 +- 11 files changed, 267 insertions(+), 36 deletions(-) delete mode 100644 fixtures/generic/c4904df5-0005-4a80-953e-62b1ede447b4.yaml create mode 100644 fixtures/local/only_zarr_json.zarr/zarr.json create mode 100644 viewer/src/services/http.ts create mode 100644 viewer/tests/error-handling.test.ts 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..e69de29b diff --git a/sites/app/src/App.tsx b/sites/app/src/App.tsx index e1359b50..e0d5b316 100644 --- a/sites/app/src/App.tsx +++ b/sites/app/src/App.tsx @@ -19,6 +19,13 @@ function parseViewStateFromUrl(): ViewState | undefined { return undefined; } +const logger = { + debug: (message: string) => console.debug(message), + info: (message: string) => console.info(message), + warn: (message: string) => console.warn(message), + error: (message: string) => console.error(message), +}; + export default function App() { const urlString = window.location.href; @@ -88,6 +95,7 @@ export default function App() { pluginCursor={enableRoi ? cursor : undefined} onPluginClick={enableRoi ? onClick : undefined} onPluginHover={enableRoi ? onHover : undefined} + logger={logger} > {enableRoi && viewerInfo && ( void; + +export interface Logger { + debug: LogFunction; + info: LogFunction; + warn: LogFunction; + error: LogFunction; +} + type Events = { viewStateChange: ViewState; }; diff --git a/viewer/src/components/VizarrViewer.tsx b/viewer/src/components/VizarrViewer.tsx index 48cceb8f..c6425cb7 100644 --- a/viewer/src/components/VizarrViewer.tsx +++ b/viewer/src/components/VizarrViewer.tsx @@ -4,7 +4,7 @@ import { Box, Link, 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 { getSourceDataError, handleError, sourceDataValid, writeUserErrorMessage } from "../error"; import { ViewStateContext, useViewState } from "../hooks"; import { createSourceData } from "../io"; import { @@ -27,6 +27,7 @@ import theme from "../theme"; import Menu from "./Menu"; import { InfoSnackbar } from "./Snackbar"; import Viewer from "./Viewer"; +import type { Logger } from "../api"; /** Viewer state snapshot exposed to the host application via onViewerStateChange. */ export interface ViewerInfo { @@ -50,6 +51,7 @@ export interface VizarrViewerProps { onPluginClick?: (coordinate: [number, number]) => boolean; onPluginHover?: (coordinate: [number, number] | null) => void; children?: React.ReactNode; + logger: Logger; } /** @@ -130,6 +132,7 @@ function VizarrViewerComponent({ onPluginClick, onPluginHover, children, + logger, }: VizarrViewerProps) { const setSourceInfo = useSetAtom(sourceInfoAtom); const setViewStateAtom = useSetAtom(viewStateAtom); @@ -165,9 +168,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); @@ -181,7 +184,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) { @@ -196,7 +201,7 @@ function VizarrViewerComponent({ } loadSources(); - }, [configs, setSourceInfo, setSourceError]); + }, [configs, setSourceInfo, setSourceError, logger]); return ( <> {redirectObj === null && ( diff --git a/viewer/src/error.ts b/viewer/src/error.ts index e04594b8..33c814ab 100644 --- a/viewer/src/error.ts +++ b/viewer/src/error.ts @@ -1,23 +1,8 @@ +import type { Logger } from "./api"; import type { SourceData } from "./state"; -import { AssertionError } from "./utils"; - -export const errorToMessageMapping: Record = { - "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.", -}; 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 +18,8 @@ export function getSourceDataError(sourceData: Array): Promise { const { color, contrast_limits, visibility, name, colormap = "", opacity = 1 } = config; const lowres = data[data.length - 1]; @@ -72,7 +74,7 @@ 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) { @@ -259,7 +261,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..5ee0e33d --- /dev/null +++ b/viewer/src/services/http.ts @@ -0,0 +1,104 @@ +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> | undefined> { + 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 === 404) { + throw new HttpError( + `404: Resource at ${source} could not be found. Please check the specified URL is correct and that the resource still exists.`, + 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(`No valid metadata file found at zarr group ${source}`, error.message); + } + + throw error; + } +} diff --git a/viewer/src/utils.ts b/viewer/src/utils.ts index bdc1255e..d65a2b9f 100644 --- a/viewer/src/utils.ts +++ b/viewer/src/utils.ts @@ -53,7 +53,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..47c0184d --- /dev/null +++ b/viewer/tests/error-handling.test.ts @@ -0,0 +1,120 @@ +import * as http from "node:http"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import { expect, test, beforeEach, afterEach } from "vitest"; + +import { createSourceData } from "../src/io"; +import { HttpError, MetadataNotFoundError, openZarrRoot } from "../src/services/http"; +import type { Server } from "node:http"; +import { Group } from "zarrita"; + +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.code).toBe(404); + expect(result.reason).instanceOf(HttpError); + }); +}); + +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..045114b0 100644 --- a/viewer/tests/metadata.js +++ b/viewer/tests/metadata.js @@ -2,9 +2,10 @@ import fs from "node:fs"; import path from "node:path"; import { stringify } from "yaml"; import * as utils from "../src/utils"; +import { openZarrRoot } from "../src/services/http"; 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, From b0bb4f95010297a5e4fc2f68f6aa42a99852e934 Mon Sep 17 00:00:00 2001 From: Alex Surtees Date: Tue, 30 Jun 2026 10:50:06 +0100 Subject: [PATCH 2/6] Satisfy typescript and linting checks --- fixtures/local/only_zarr_json.zarr/zarr.json | 1 + viewer/src/components/VizarrViewer.tsx | 2 +- viewer/src/services/http.ts | 4 +--- viewer/tests/error-handling.test.ts | 11 ++++++----- viewer/tests/metadata.js | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/fixtures/local/only_zarr_json.zarr/zarr.json b/fixtures/local/only_zarr_json.zarr/zarr.json index e69de29b..0967ef42 100644 --- a/fixtures/local/only_zarr_json.zarr/zarr.json +++ b/fixtures/local/only_zarr_json.zarr/zarr.json @@ -0,0 +1 @@ +{} diff --git a/viewer/src/components/VizarrViewer.tsx b/viewer/src/components/VizarrViewer.tsx index c6425cb7..5fe0c671 100644 --- a/viewer/src/components/VizarrViewer.tsx +++ b/viewer/src/components/VizarrViewer.tsx @@ -4,6 +4,7 @@ import { Box, Link, 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 type { Logger } from "../api"; import { getSourceDataError, handleError, sourceDataValid, writeUserErrorMessage } from "../error"; import { ViewStateContext, useViewState } from "../hooks"; import { createSourceData } from "../io"; @@ -27,7 +28,6 @@ import theme from "../theme"; import Menu from "./Menu"; import { InfoSnackbar } from "./Snackbar"; import Viewer from "./Viewer"; -import type { Logger } from "../api"; /** Viewer state snapshot exposed to the host application via onViewerStateChange. */ export interface ViewerInfo { diff --git a/viewer/src/services/http.ts b/viewer/src/services/http.ts index 5ee0e33d..9653e6a5 100644 --- a/viewer/src/services/http.ts +++ b/viewer/src/services/http.ts @@ -39,9 +39,7 @@ export class MetadataNotFoundError extends MetadataError { } } -export async function openZarrRoot( - source: string | zarr.Readable, -): Promise> | undefined> { +export async function openZarrRoot(source: string | zarr.Readable): Promise>> { let url: string; if (typeof source === "string") { diff --git a/viewer/tests/error-handling.test.ts b/viewer/tests/error-handling.test.ts index 47c0184d..07185042 100644 --- a/viewer/tests/error-handling.test.ts +++ b/viewer/tests/error-handling.test.ts @@ -1,12 +1,13 @@ +// @ts-nocheck +import * as fs from "node:fs"; import * as http from "node:http"; import * as path from "node:path"; -import * as fs from "node:fs"; -import { expect, test, beforeEach, afterEach } from "vitest"; +import { afterEach, beforeEach, expect, test } from "vitest"; -import { createSourceData } from "../src/io"; -import { HttpError, MetadataNotFoundError, openZarrRoot } from "../src/services/http"; 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; @@ -59,7 +60,7 @@ beforeEach(() => { return; }); }); - server.listen(() => { }); + server.listen(() => {}); port = server.address().port; server_url = `http://localhost:${port}`; }); diff --git a/viewer/tests/metadata.js b/viewer/tests/metadata.js index 045114b0..3f45983b 100644 --- a/viewer/tests/metadata.js +++ b/viewer/tests/metadata.js @@ -1,8 +1,8 @@ import fs from "node:fs"; import path from "node:path"; import { stringify } from "yaml"; -import * as utils from "../src/utils"; import { openZarrRoot } from "../src/services/http"; +import * as utils from "../src/utils"; export async function writeImageYaml(url, imageName, savePath) { const node = await openZarrRoot(url); From ae71b2694c5e206f531d27c745a6b6e48aa6c8f6 Mon Sep 17 00:00:00 2001 From: Alex Surtees Date: Tue, 30 Jun 2026 12:06:29 +0100 Subject: [PATCH 3/6] Fix error when warning raised --- viewer/src/components/LayerController/ChannelOptions.tsx | 4 ++-- viewer/src/components/VizarrViewer.tsx | 2 +- viewer/src/io.ts | 1 + viewer/src/services/http.ts | 7 ------- 4 files changed, 4 insertions(+), 10 deletions(-) diff --git a/viewer/src/components/LayerController/ChannelOptions.tsx b/viewer/src/components/LayerController/ChannelOptions.tsx index 2d9eaf85..f2dd63bb 100644 --- a/viewer/src/components/LayerController/ChannelOptions.tsx +++ b/viewer/src/components/LayerController/ChannelOptions.tsx @@ -1,7 +1,7 @@ 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 { useAtom, useSetAtom } from "jotai"; import React, { useState } from "react"; import type { ChangeEvent, MouseEvent } from "react"; import { useLayerState, useSourceData } from "../../hooks"; @@ -23,7 +23,7 @@ function ChannelOptions({ channelIndex }: Props) { const [layer, setLayer] = useLayerState(); const [anchorEl, setAnchorEl] = useState(null); const { channel_axis, names } = sourceData; - const setSourceWarning = useSetAtom(sourceWarningAtom); + const [, setSourceWarning] = useAtom(sourceWarningAtom); const defaultNames = getDefaultChannelLabels(names.length); React.useEffect(() => { diff --git a/viewer/src/components/VizarrViewer.tsx b/viewer/src/components/VizarrViewer.tsx index 5fe0c671..ce877ae1 100644 --- a/viewer/src/components/VizarrViewer.tsx +++ b/viewer/src/components/VizarrViewer.tsx @@ -245,7 +245,7 @@ function VizarrViewerComponent({ )} {sourceWarning.length && sourceWarning.map((warning, index) => { - return ; + return ; })} {redirectObj !== null && ( Date: Tue, 30 Jun 2026 14:53:59 +0100 Subject: [PATCH 4/6] Fix warnings --- .../LayerController/ChannelOptions.tsx | 11 ---- viewer/src/components/Snackbar.tsx | 32 ------------ viewer/src/components/VizarrViewer.tsx | 52 +++++++++++++++---- viewer/src/error.ts | 9 ++++ viewer/src/io.ts | 1 - viewer/src/state.ts | 1 - viewer/tests/error-handling.test.ts | 3 +- 7 files changed, 52 insertions(+), 57 deletions(-) delete mode 100644 viewer/src/components/Snackbar.tsx diff --git a/viewer/src/components/LayerController/ChannelOptions.tsx b/viewer/src/components/LayerController/ChannelOptions.tsx index f2dd63bb..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 { useAtom, 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] = useAtom(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 ce877ae1..6ef343bf 100644 --- a/viewer/src/components/VizarrViewer.tsx +++ b/viewer/src/components/VizarrViewer.tsx @@ -1,11 +1,17 @@ -import { Info } from "@mui/icons-material"; import { ThemeProvider } from "@mui/material"; import { Box, Link, 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 { type SnackbarKey, SnackbarProvider, closeSnackbar, enqueueSnackbar } from "notistack"; +import React from "react"; import type { Logger } from "../api"; -import { getSourceDataError, handleError, sourceDataValid, writeUserErrorMessage } from "../error"; +import { + getSourceDataError, + getSourceDataWarnings, + handleError, + sourceDataValid, + writeUserErrorMessage, +} from "../error"; import { ViewStateContext, useViewState } from "../hooks"; import { createSourceData } from "../io"; import { @@ -20,13 +26,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. */ @@ -139,7 +143,8 @@ function VizarrViewerComponent({ 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); @@ -191,6 +196,10 @@ function VizarrViewerComponent({ for (const res of results) { if (res.status === "fulfilled") { + const warnings = getSourceDataWarnings(res.value); + warnings.map((warning: string) => { + handleWarning(warning); + }); sourceDatas.push(res.value); } else { console.error(res.reason); @@ -202,8 +211,35 @@ function VizarrViewerComponent({ loadSources(); }, [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 && ( = 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/tests/error-handling.test.ts b/viewer/tests/error-handling.test.ts index 07185042..5df687e9 100644 --- a/viewer/tests/error-handling.test.ts +++ b/viewer/tests/error-handling.test.ts @@ -73,8 +73,7 @@ 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.code).toBe(404); - expect(result.reason).instanceOf(HttpError); + expect(result.reason).instanceOf(MetadataNotFoundError); }); }); From ffd7b8313728136c9268e78b8063715dab8d8d63 Mon Sep 17 00:00:00 2001 From: Alex Surtees Date: Tue, 30 Jun 2026 15:20:09 +0100 Subject: [PATCH 5/6] Better error message when channel names missing --- viewer/src/error.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/viewer/src/error.ts b/viewer/src/error.ts index 0193ba23..7e397e4e 100644 --- a/viewer/src/error.ts +++ b/viewer/src/error.ts @@ -23,7 +23,7 @@ export function getSourceDataError(sourceData: Array Date: Tue, 7 Jul 2026 11:11:49 +0100 Subject: [PATCH 6/6] More informative error message for 404 --- viewer/src/services/http.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/viewer/src/services/http.ts b/viewer/src/services/http.ts index 8229186f..8abb9e58 100644 --- a/viewer/src/services/http.ts +++ b/viewer/src/services/http.ts @@ -87,7 +87,10 @@ export async function openZarrRoot(source: string | zarr.Readable): Promise