Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Url based remote dataset import #7844

Merged
merged 37 commits into from
Jul 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ce1ed7d
WIP [ci skip] pass through url param as default value to form and sta…
knollengewaechs May 22, 2024
00bef75
auto fill URI form
knollengewaechs May 28, 2024
828a88b
Merge branch 'master' into url-based-remote-dataset-import
knollengewaechs May 29, 2024
190f806
WIP: try to submit second form
knollengewaechs May 29, 2024
57e88ca
WIP: parse default dataset name from url
knollengewaechs May 30, 2024
648cd84
fix setting name
knollengewaechs Jun 4, 2024
617cfa1
WIP: add hook to automatically store dataset
knollengewaechs Jun 5, 2024
6b417be
WIP: automatically submit second form
knollengewaechs Jun 11, 2024
e00195d
start to extract post upload modal
knollengewaechs Jun 11, 2024
b7d90eb
WIP[ci skip]: automatically import dataset
knollengewaechs Jun 12, 2024
7ab6ab1
automatically store remote DS in happy path!
knollengewaechs Jun 13, 2024
cd9e6c5
keep search params when redirecting back after required login
knollengewaechs Jun 17, 2024
00a1ca0
overlay brain spinner
knollengewaechs Jun 18, 2024
8352e71
clean up code a bit
knollengewaechs Jun 18, 2024
93793d3
merge master
knollengewaechs Jun 18, 2024
f8ee6f7
remove console log
knollengewaechs Jun 18, 2024
73958c5
remove hook and use callback instead
knollengewaechs Jun 19, 2024
552fc4b
fix theming of pw input
knollengewaechs Jun 20, 2024
0110c0b
implement encoding and hash for remote dataset url
knollengewaechs Jun 21, 2024
557be76
add tests for hash function
knollengewaechs Jun 24, 2024
cdde01e
reroute to existing dataset
knollengewaechs Jun 24, 2024
21f7d5f
WIP: split URL better and set right DS name
knollengewaechs Jun 25, 2024
3fe7b8d
pass through the successful url to DatasetAddRemoteView
knollengewaechs Jun 26, 2024
e103eee
improve regex
knollengewaechs Jun 26, 2024
389f2fc
change text if we are in url import
knollengewaechs Jun 26, 2024
5a782e6
Merge branch 'master' into url-based-remote-dataset-import
knollengewaechs Jun 26, 2024
ea63ebd
clean up code
knollengewaechs Jun 27, 2024
9bd7dbc
add changelog
knollengewaechs Jun 27, 2024
a64086c
Merge branch 'master' into url-based-remote-dataset-import
knollengewaechs Jun 27, 2024
100b8de
WIP: address review
knollengewaechs Jul 5, 2024
92bfbe6
address review 2/2
knollengewaechs Jul 6, 2024
1974b47
remove outdated comments from other PR
knollengewaechs Jul 6, 2024
c784a26
merge master
knollengewaechs Jul 6, 2024
d04fa98
improve base62 encoding
knollengewaechs Jul 8, 2024
cd04e47
Merge branch 'master' into url-based-remote-dataset-import
knollengewaechs Jul 8, 2024
e6962d7
add more test cases for base62 encoding
knollengewaechs Jul 8, 2024
24915b3
improve interface of onSuccess method and remove some obsolete null c…
knollengewaechs Jul 9, 2024
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 CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released
[Commits](https://github.com/scalableminds/webknossos/compare/24.07.0...HEAD)

### Added
- Added route `/import?url=<url_to_datasource>` to automatically import and view remote datasets. [#7844](https://github.com/scalableminds/webknossos/pull/7844)

### Changed

Expand Down
154 changes: 132 additions & 22 deletions frontend/javascripts/admin/dataset/dataset_add_remote_view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import { exploreRemoteDataset, isDatasetNameValid, storeRemoteDataset } from "ad
import messages from "messages";
import { jsonStringify } from "libs/utils";
import { CardContainer, DatastoreFormItem } from "admin/dataset/dataset_components";
import Password from "antd/lib/input/Password";
import { AsyncButton } from "components/async_clickables";
import Toast from "libs/toast";
import _ from "lodash";
Expand All @@ -39,9 +38,12 @@ import { Unicode } from "oxalis/constants";
import { readFileAsText } from "libs/read_file";
import * as Utils from "libs/utils";
import { ArbitraryObject } from "types/globals";
import BrainSpinner from "components/brain_spinner";
import { useHistory } from "react-router-dom";

const FormItem = Form.Item;
const RadioGroup = Radio.Group;
const { Password } = Input;

type FileList = UploadFile<any>[];

Expand All @@ -52,6 +54,11 @@ type OwnProps = {
needsConversion?: boolean | null | undefined,
) => Promise<void>;
datastores: APIDataStore[];
// This next prop has a high impact on the component's behavior.
// If it is set, the component will automatically explore the dataset
// and import it. If it is not set, the user has to manually trigger
// the exploration and import.
defaultDatasetUrl?: string | null | undefined;
knollengewaechs marked this conversation as resolved.
Show resolved Hide resolved
};
type StateProps = {
activeUser: APIUser | null | undefined;
Expand All @@ -73,14 +80,14 @@ function mergeNewLayers(
existingDatasource: DatasourceConfiguration | null,
newDatasource: DatasourceConfiguration,
): DatasourceConfiguration {
if (existingDatasource == null) {
if (existingDatasource?.dataLayers == null) {
return newDatasource;
}
const allLayers = newDatasource.dataLayers.concat(existingDatasource.dataLayers);
const groupedLayers = _.groupBy(allLayers, (layer: DataLayer) => layer.name) as unknown as Record<
string,
DataLayer[]
>;
const groupedLayers: Record<string, DataLayer[]> = _.groupBy(
allLayers,
(layer: DataLayer) => layer.name,
);
const uniqueLayers: DataLayer[] = [];
for (const entry of _.entries(groupedLayers)) {
const [name, layerGroup] = entry;
Expand Down Expand Up @@ -172,24 +179,75 @@ export function GoogleAuthFormItem({
}

function DatasetAddRemoteView(props: Props) {
const { activeUser, onAdded, datastores } = props;
const { activeUser, onAdded, datastores, defaultDatasetUrl } = props;

const uploadableDatastores = datastores.filter((datastore) => datastore.allowsUpload);
const hasOnlyOneDatastoreOrNone = uploadableDatastores.length <= 1;

const [showAddLayerModal, setShowAddLayerModal] = useState(false);
const [showLoadingOverlay, setShowLoadingOverlay] = useState(defaultDatasetUrl != null);
const [dataSourceEditMode, setDataSourceEditMode] = useState<"simple" | "advanced">("simple");
const [form] = Form.useForm();
const [targetFolderId, setTargetFolderId] = useState<string | null>(null);
const isDatasourceConfigStrFalsy = !Form.useWatch("dataSourceJson", form);
const isDatasourceConfigStrFalsy = Form.useWatch("dataSourceJson", form) == null;
const maybeDataLayers = Form.useWatch(["dataSource", "dataLayers"], form);
const history = useHistory();

useEffect(() => {
const params = new URLSearchParams(location.search);
const targetFolderId = params.get("to");
setTargetFolderId(targetFolderId);
}, []);

const getDefaultDatasetName = (url: string) => {
if (url === "") return "";
let urlPathElements = url.split(/[^a-zA-Z\d_\-.~]/); // split by non url-safe characters
const defaultName = urlPathElements.filter((el) => el !== "").at(-1);
const urlHash = Utils.computeHash(url);
return defaultName + "-" + urlHash;
};

const maybeOpenExistingDataset = () => {
const maybeDSNameError = form
.getFieldError("datasetName")
.filter((error) => error === messages["dataset.name.already_taken"]);
if (maybeDSNameError == null) return;
history.push(
`/datasets/${activeUser?.organization}/${form.getFieldValue(["dataSource", "id", "name"])}`,
);
};

const hasFormAnyErrors = (form: FormInstance) =>
form.getFieldsError().filter(({ errors }) => errors.length).length > 0;

const onSuccesfulExplore = async (url: string) => {
const dataSourceJsonString = form.getFieldValue("dataSourceJson");
if (defaultDatasetUrl == null) {
setShowLoadingOverlay(false);
return;
}
if (!showLoadingOverlay) setShowLoadingOverlay(true); // show overlay again, e.g. after credentials were passed
const dataSourceJson = JSON.parse(dataSourceJsonString);
const defaultDatasetName = getDefaultDatasetName(url);
setDatasourceConfigStr(
JSON.stringify({ ...dataSourceJson, id: { name: defaultDatasetName, team: "" } }),
);
try {
await form.validateFields();
} catch (_e) {
console.warn(_e);
if (defaultDatasetUrl != null) {
maybeOpenExistingDataset();
return;
}
}
if (!hasFormAnyErrors(form)) {
handleStoreDataset();
} else {
setShowLoadingOverlay(false);
}
};

const setDatasourceConfigStr = (dataSourceJson: string) => {
form.setFieldsValue({ dataSourceJson });
// Since this function sets the JSON string, we have to update the
Expand All @@ -201,21 +259,34 @@ function DatasetAddRemoteView(props: Props) {
async function handleStoreDataset() {
// Sync simple with advanced and get newest datasourceJson
syncDataSourceFields(form, dataSourceEditMode === "simple" ? "advanced" : "simple");
await form.validateFields();
const datasourceConfigStr = form.getFieldValue("dataSourceJson");
try {
await form.validateFields();
} catch (_e) {
console.warn(_e);
if (defaultDatasetUrl != null) {
maybeOpenExistingDataset();
return;
}
}
if (hasFormAnyErrors(form)) {
setShowLoadingOverlay(false);
return;
}

const datastoreToUse = uploadableDatastores.find(
(datastore) => form.getFieldValue("datastoreUrl") === datastore.url,
);
if (!datastoreToUse) {
setShowLoadingOverlay(false);
Toast.error("Could not find datastore that allows uploading.");
return;
}

if (datasourceConfigStr && activeUser) {
const dataSourceJsonStr = form.getFieldValue("dataSourceJson");
if (dataSourceJsonStr && activeUser) {
let configJSON;
try {
configJSON = JSON.parse(datasourceConfigStr);
configJSON = JSON.parse(dataSourceJsonStr);
const nameValidationResult = await isDatasetNameValid({
name: configJSON.id.name,
owningOrganization: activeUser.organization,
Expand All @@ -227,10 +298,11 @@ function DatasetAddRemoteView(props: Props) {
datastoreToUse.url,
configJSON.id.name,
activeUser.organization,
datasourceConfigStr,
dataSourceJsonStr,
targetFolderId,
);
} catch (e) {
setShowLoadingOverlay(false);
Toast.error(`The datasource config could not be stored. ${e}`);
return;
}
Expand All @@ -242,6 +314,7 @@ function DatasetAddRemoteView(props: Props) {
return (
// Using Forms here only to validate fields and for easy layout
<div style={{ padding: 5 }}>
{showLoadingOverlay ? <BrainSpinner /> : null}
<CardContainer title="Add Remote Zarr / Neuroglancer Precomputed / N5 Dataset">
<Form form={form} layout="vertical">
<DatastoreFormItem datastores={uploadableDatastores} hidden={hasOnlyOneDatastoreOrNone} />
Expand All @@ -267,6 +340,9 @@ function DatasetAddRemoteView(props: Props) {
uploadableDatastores={uploadableDatastores}
setDatasourceConfigStr={setDatasourceConfigStr}
dataSourceEditMode={dataSourceEditMode}
defaultUrl={defaultDatasetUrl}
onError={() => setShowLoadingOverlay(false)}
onSuccess={(defaultDatasetUrl: string) => onSuccesfulExplore(defaultDatasetUrl)}
/>
)}
<Hideable hidden={hideDatasetUI}>
Expand Down Expand Up @@ -367,15 +443,19 @@ function AddRemoteLayer({
uploadableDatastores,
setDatasourceConfigStr,
onSuccess,
onError,
dataSourceEditMode,
defaultUrl,
}: {
form: FormInstance;
uploadableDatastores: APIDataStore[];
setDatasourceConfigStr: (dataSourceJson: string) => void;
onSuccess?: () => void;
onSuccess?: (datasetUrl: string) => Promise<void> | void;
onError?: () => void;
dataSourceEditMode: "simple" | "advanced";
defaultUrl?: string | null | undefined;
}) {
const isDatasourceConfigStrFalsy = !Form.useWatch("dataSourceJson", form);
const isDatasourceConfigStrFalsy = Form.useWatch("dataSourceJson", form) != null;
const datasourceUrl: string | null = Form.useWatch("url", form);
const [exploreLog, setExploreLog] = useState<string | null>(null);
const [showCredentialsFields, setShowCredentialsFields] = useState<boolean>(false);
Expand All @@ -384,6 +464,18 @@ function AddRemoteLayer({
const [selectedProtocol, setSelectedProtocol] = useState<"s3" | "https" | "gs">("https");
const [fileList, setFileList] = useState<FileList>([]);

useEffect(() => {
if (defaultUrl != null) {
// only set datasourceUrl in the first render
if (datasourceUrl == null) {
form.setFieldValue("url", defaultUrl);
form.validateFields(["url"]);
} else {
handleExplore();
}
}
}, [defaultUrl, datasourceUrl, form.setFieldValue, form.validateFields]);

const handleChange = (info: UploadChangeParam<UploadFile<any>>) => {
// Restrict the upload list to the latest file
const newFileList = info.fileList.slice(-1);
Expand All @@ -404,8 +496,13 @@ function AddRemoteLayer({
}
}

const handleFailure = () => {
if (onError) onError();
};

async function handleExplore() {
if (!datasourceUrl) {
handleFailure();
Toast.error("Please provide a valid URL for exploration.");
return;
}
Expand All @@ -417,6 +514,7 @@ function AddRemoteLayer({
(datastore) => form.getFieldValue("datastoreUrl") === datastore.url,
);
if (!datastoreToUse) {
handleFailure();
Toast.error("Could not find datastore that allows uploading.");
return;
}
Expand Down Expand Up @@ -458,6 +556,7 @@ function AddRemoteLayer({
})();
setExploreLog(report);
if (!newDataSource) {
handleFailure();
Toast.error(
"Exploring this remote dataset did not return a datasource. Please check the Log.",
);
Expand All @@ -466,18 +565,25 @@ function AddRemoteLayer({
ensureLargestSegmentIdsInPlace(newDataSource);
if (!datasourceConfigStr) {
setDatasourceConfigStr(jsonStringify(newDataSource));
if (onSuccess) {
onSuccess(datasourceUrl);
}
return;
}
let existingDatasource;
let existingDatasource: DatasourceConfiguration;
try {
existingDatasource = JSON.parse(datasourceConfigStr);
} catch (_e) {
handleFailure();
Toast.error(
"The current datasource config contains invalid JSON. Cannot add the new Zarr/N5 data.",
);
return;
}
if (existingDatasource != null && !_.isEqual(existingDatasource.scale, newDataSource.scale)) {
if (
existingDatasource?.scale != null &&
Copy link
Contributor Author

@knollengewaechs knollengewaechs Jun 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was failing in my case because the dataSourceJson didnt contain the scale at some point. It seems to me that its still making sense, but I just want to make sure that its not problematic in another case.

  • obvs check whether usual remote DS upload is still working fine

!_.isEqual(existingDatasource.scale, newDataSource.scale)
) {
Toast.warning(
`${messages["dataset.add_zarr_different_scale_warning"]}\n${formatScale(
newDataSource.scale,
Expand All @@ -487,16 +593,19 @@ function AddRemoteLayer({
}
setDatasourceConfigStr(jsonStringify(mergeNewLayers(existingDatasource, newDataSource)));
if (onSuccess) {
onSuccess();
onSuccess(datasourceUrl);
}
}

return (
<>
Please enter a URL that points to the Zarr, Neuroglancer Precomputed or N5 data you would like
to import. If necessary, specify the credentials for the dataset. For datasets with multiple
layers, e.g. raw microscopy and segmentation data, please add them separately with the ”Add
Layer” button below. Once you have approved of the resulting datasource you can import it.
to import. If necessary, specify the credentials for the dataset.{" "}
{defaultUrl == null
? "For datasets with multiple \
layers, e.g. raw microscopy and segmentation data, please add them separately with the ”Add \
Layer” button below. Once you have approved of the resulting datasource you can import it."
: "If the provided URL is valid, the datasource will be imported and you will be redirected to the dataset."}
<FormItem
style={{ marginTop: 16, marginBottom: 16 }}
name="url"
Expand All @@ -514,6 +623,7 @@ function AddRemoteLayer({
validateUrls(value);
return Promise.resolve();
} catch (e) {
handleFailure();
return Promise.reject(e);
}
},
Expand Down Expand Up @@ -599,7 +709,7 @@ function AddRemoteLayer({
style={{ width: "100%" }}
onClick={handleExplore}
>
Add Layer
{defaultUrl == null ? "Add Layer" : "Validate URL and Continue"}
</AsyncButton>
</Col>
</Row>
Expand Down
Loading