From eb108feb37fc4e244b717bb9bc8a1bf14ed55f17 Mon Sep 17 00:00:00 2001 From: Madison Dunitz Date: Wed, 7 Oct 2020 12:36:02 -0500 Subject: [PATCH 01/16] Performance test annotations (#1908) * make testing plan * create annotaions sets for different num categories/dataset size * annotation creation testing * create scale and perf tests for annotations * create make commands for tests * get cell count if not set in test_datasets dict --- Makefile | 2 +- server/Makefile | 8 + .../performance_test_annotations_backend.py | 215 ++++++++++++++++++ .../performance/scale_test_annotations.py | 46 ++++ 4 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 server/test/performance/performance_test_annotations_backend.py create mode 100644 server/test/performance/scale_test_annotations.py diff --git a/Makefile b/Makefile index ca8754f7..8f9a4083 100644 --- a/Makefile +++ b/Makefile @@ -83,7 +83,7 @@ lint: lint-server lint-client .PHONY: lint-server lint-server: fmt-py - flake8 server --per-file-ignores='server/test/fixtures/dataset_config_outline.py:F821 server/test/fixtures/server_config_outline.py:F821' + flake8 server --per-file-ignores='server/test/fixtures/dataset_config_outline.py:F821 server/test/fixtures/server_config_outline.py:F821 server/test/performance/scale_test_annotations.py:E501' .PHONY: lint-client diff --git a/server/Makefile b/server/Makefile index 700b9a27..016f89cc 100644 --- a/server/Makefile +++ b/server/Makefile @@ -39,3 +39,11 @@ create-test-db: clean-test-db: -docker stop test_db -docker rm test_db + +.PHONY: test-annotations-performance +test-annotations-performance: + python test/performance/performance_test_annotations_backend.py + +.PHONY: test-annotations-scale +test-annotations-scale: + locust -f test/performance/scale_test_annotations.py --headless -u 30 -r 10 --host https://api.cellxgene.dev.single-cell.czi.technology/cellxgene/e/ --run-time 5m 2>&1 | tee locust_dev_stats.txt diff --git a/server/test/performance/performance_test_annotations_backend.py b/server/test/performance/performance_test_annotations_backend.py new file mode 100644 index 00000000..d0548523 --- /dev/null +++ b/server/test/performance/performance_test_annotations_backend.py @@ -0,0 +1,215 @@ +import json +import string +from contextlib import contextmanager +from timeit import default_timer +import concurrent.futures +import numpy as np +import requests +import sys +from server.data_common.fbs.matrix import encode_matrix_fbs +import pandas as pd +import random + +""" +Before running, sign into the dataportal, copy the cookie and paste it below. To test in staging or prod update the +url base below. It is also possible to configure the number of categories created and the number of unique labels per +category. +""" + +cookie = "" + +test_datasets = { + "smallest": { + "dataset_url": "kampmann_lab_human_AD_snRNAseq_EC_inhibitoryNeurons-53-remixed.cxg", + "name": "smallest", + "num_cells": 5270, + }, + "10k": { + "dataset_url": "krasnow_lab_human_lung_cell_atlas_smartseq2-2-remixed.cxg", + "name": "10k", + "num_cells": 9409, + }, + "80k": { + "dataset_url": "Single_cell_gene_expression_profiling_of_SARS_CoV_2_infected_human_cell_lines_H1299-27-remixed.cxg", # noqa E501 + "name": "80k", + "num_cells": 81736, + }, + "140k": {"dataset_url": "Single_cell_drug_screening_a549-42-remixed.cxg", "name": "140k", "num_cells": 143015}, + "largest": {"dataset_url": "human_cell_landscape.cxg", "name": "largest", "num_cells": 599926}, + "1million": {"dataset_url": None, "name": "1million", "num_cells": 1000000}, + "4million": {"dataset_url": None, "name": "4million", "num_cells": 4000000}, +} + +url_base = "https://api.cellxgene.dev.single-cell.czi.technology/cellxgene/e/" +annotations_category_count = [1, 10, 50] +max_labels = [5, 50, 100] + + +class PerformanceTestingAnnotations: + def __init__( + self, + datasets=test_datasets, + annotations_category_count=annotations_category_count, + max_labels=max_labels, + url_base=url_base, + ): + self.test_datasets = datasets + self.annotations_category_count = annotations_category_count + self.max_labels = max_labels + self.url_base = url_base + self.test_notes = self.create_info_dict() + + def set_cell_count(self, dataset_name): + dataset_url = self.test_datasets[dataset_name]["dataset_url"] + headers = {"Content-Type": "application/octet-stream", "Cookie": cookie} + response = self.client.get(f"{self.url_base}{dataset_url}/api/v0.2/schema", headers=headers) + cell_count = json.loads(response._content)["schema"]["dataframe"]["nObs"] + self.test_datasets[dataset_name]["cell_count"] = cell_count + + def create_info_dict(self): + request_info = {} + for dataset in self.test_datasets.keys(): + request_info[dataset] = {} + for cat_count in self.annotations_category_count: + request_info[dataset][f"num_categories_{cat_count}"] = {} + for unique_labels in self.max_labels: + request_info[dataset][f"num_categories_{cat_count}"][f"max_label_{unique_labels}"] = {} + return request_info + + def create_annotations_dict_multi_process(self, dataset_name, category_count, label_max): + annotation_dict = {} + futures = [] + categories = [f"Category{i}" for i in range(category_count)] + if not self.test_datasets[dataset_name]["num_cells"]: + self.set_cell_count(dataset_name) + with concurrent.futures.ProcessPoolExecutor(max_workers=5) as executor: + for category in categories: + futures.append( + executor.submit( + self.build_array_for_category, + category, + self.test_datasets[dataset_name]["num_cells"], + label_max, + ) + ) + for future in concurrent.futures.as_completed(futures): + try: + result = future.result() + category_name, cells = result + annotation_dict[category_name] = pd.Series(cells, dtype="category") + except Exception as e: + print(f"Issue creating the annotations dict: {e}") + return annotation_dict + + def build_array_for_category(self, category_name, cell_count, label_max): + unique_label_count = label_max + labels = self.generate_labels(unique_label_count) + cells_per_label = int(cell_count / len(labels)) + extra = cell_count % len(labels) + cells = [] + for label in labels: + cells.extend([label] * cells_per_label) + cells.extend(["extra"] * extra) + rng = np.random.default_rng() + rng.shuffle(cells) + return category_name, cells + + @staticmethod + def convert_to_fbs(annotation_dict): + df = pd.DataFrame(annotation_dict) + return encode_matrix_fbs(matrix=df, row_idx=None, col_idx=df.columns) + + @staticmethod + def generate_labels(unique_label_count): + labels = ["undefined"] + for i in range(unique_label_count): + length = random.randrange(10, 20) + labels.append(f"{i}__" + "".join(random.choice(string.ascii_letters) for z in range(length))) + return labels + + @contextmanager + def elapsed_timer(self): + start = default_timer() + elapser = lambda: default_timer() - start # noqa E731 + yield lambda: elapser() + end = default_timer() + elapser = lambda: end - start # noqa E731 + + def create_matrix(self, dataset_name, num_cat, max_labels): + with self.elapsed_timer() as elapsed: + annon_dict = self.create_annotations_dict_multi_process(dataset_name, num_cat, max_labels) + dict_size = sum(sys.getsizeof(value) for value in annon_dict.values()) / 1024 ** 2 + self.test_notes[dataset_name][f"num_categories_{num_cat}"][f"max_label_{max_labels}"]["annotation_dict"] = { + "creation_time": str(elapsed()), + "size": f"{dict_size} mb", + } + df = pd.DataFrame(annon_dict) + df_size = sys.getsizeof(df) / 1024 ** 2 + self.test_notes[dataset_name][f"num_categories_{num_cat}"][f"max_label_{max_labels}"]["data_frame"] = { + "creation_time": str(elapsed()), + "size": f"{df_size} mb", + } + try: + matrix = encode_matrix_fbs(matrix=df, row_idx=None, col_idx=df.columns) + matrix_size = sys.getsizeof(matrix) / 1024 ** 2 + self.test_notes[dataset_name][f"num_categories_{num_cat}"][f"max_label_{max_labels}"]["fbs_matrix"] = { + "creation_time": str(elapsed()), + "size": f"{matrix_size} mb", + } + return matrix + except Exception as e: + print(f"Issue creating fbs matrix: {e}, for {dataset_name}") + return [] + + def send_put_request(self, dataset_url, data): + url = self.url_base + f"{dataset_url}/api/v0.2/annotations/obs" + with self.elapsed_timer() as elapsed: + try: + headers = {"Content-Type": "application/octet-stream", "Cookie": cookie} + response = requests.put(url=url, data=data, headers=headers) + except Exception as e: + print(f"Issue with put request: {e}") + return None, elapsed() + return response, elapsed() + + def test_categories_max_label_matrix(self, dataset_name): + for unique_labels in self.max_labels: + for category_count in self.annotations_category_count: + print(f"Starting dataset: {dataset_name}, categories: {category_count}, labels: {unique_labels}") + fbs_matrix = self.create_matrix(dataset_name, category_count, unique_labels) + if self.test_datasets[dataset_name]["dataset_url"] and fbs_matrix: + response, response_time = self.send_put_request( + self.test_datasets[dataset_name]["dataset_url"], fbs_matrix + ) + if response is None: + self.test_notes[dataset_name][f"num_categories_{category_count}"][f"max_label_{unique_labels}"][ + "put_request" + ] = {"response_status": "failed", "request_time": str(response_time)} + else: + self.test_notes[dataset_name][f"num_categories_{category_count}"][f"max_label_{unique_labels}"][ + "put_request" + ] = {"response_status": response.status_code, "request_time": str(response_time)} + + +def test_all_datasets(): + """ + Run time is dependent on number of datasets, dataset size, number of categories/number being tested and number of + unique label counts being tested. However it generally takes a long time. I recommend running this in tmux + """ + perf_test = PerformanceTestingAnnotations() + for dataset_name in perf_test.test_datasets.keys(): + print(f"Testing annotation creation for: {dataset_name}") + try: + perf_test.test_categories_max_label_matrix(dataset_name) + except Exception as e: + print(f"something went wrong with {dataset_name}: {e}") + return perf_test.test_notes + + +def main(): + notes = test_all_datasets() + print(notes) + + +if __name__ == "__main__": + main() diff --git a/server/test/performance/scale_test_annotations.py b/server/test/performance/scale_test_annotations.py new file mode 100644 index 00000000..5aa2ac6e --- /dev/null +++ b/server/test/performance/scale_test_annotations.py @@ -0,0 +1,46 @@ +import time +import random + +from locust import HttpUser, between, task + +random.seed(time.time()) +""" +To run this script sign into cellxgene in the desired environment and grab the returned cookie, update the cookie +variable below with your cookie and run the following command to see results in the terminal: +locust -f server/test/performance/scale_test_annotations.py --headless -u 30 -r 10 --host https://api.cellxgene.dev.single-cell.czi.technology/cellxgene/e/ --run-time 5m 2>&1 | tee locust_dev_stats.txt + +Or if you want to use the locust gui run: +locust -f server/test/performance/scale_test_annotations.py -u 30 -r 10 --host https://api.cellxgene.dev.single-cell.czi.technology/cellxgene/e/ + +If you want to test staging you'll need to substitute staging for dev in the host url +To test prod you'll need to replace dev.single-cell.czi.technology with cziscience.com +If you'd like to test additional datasets you'll need to add them to the dataset_urls array + +Todo @mdunitz update script to retrieve different annotation categories -- may need to create them to ensure the +categories are shared across datasets for a given user. +""" +cookie = "" + + +class WebsiteUser(HttpUser): + wait_time = between(1, 2) + dataset_urls = [ + "human_cell_landscape.cxg", + "Single_cell_drug_screening_a549-42-remixed.cxg", + "kampmann_lab_human_AD_snRNAseq_EC_inhibitoryNeurons-53-remixed.cxg", + "krasnow_lab_human_lung_cell_atlas_smartseq2-2-remixed.cxg", + "Single_cell_gene_expression_profiling_of_SARS_CoV_2_infected_human_cell_lines_H1299-27-remixed.cxg", + ] + + @task + def get_annotations(self): + dataset_url = random.choice(self.dataset_urls) + url = f"{dataset_url}/api/v0.2/annotations/obs?annotation-name=cell_type" + headers = {"Content-Type": "application/octet-stream", "Cookie": cookie} + self.client.get(url, headers=headers) + + @task + def get_schema(self): + dataset_url = random.choice(self.dataset_urls) + headers = {"Content-Type": "application/octet-stream", "Cookie": cookie} + self.client.get(f"{dataset_url}/api/v0.2/schema", headers=headers) From cf77a8da9e4138af1e95a41071227140df064e4b Mon Sep 17 00:00:00 2001 From: bmccandless Date: Wed, 7 Oct 2020 12:17:23 -0700 Subject: [PATCH 02/16] Add "picture" to the /userinfo endpoint. (#1914) * Add "picture" to the /userinfo endpoint. This may be null or a URL. add picture for the test authentication method --- server/auth/auth.py | 4 ++++ server/auth/auth_oauth.py | 16 +++++++--------- server/auth/auth_test.py | 6 ++++++ server/common/config/client_config.py | 1 + server/test/unit/auth/test_auth.py | 7 +++++++ 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/server/auth/auth.py b/server/auth/auth.py index 145616cc..03184e8d 100644 --- a/server/auth/auth.py +++ b/server/auth/auth.py @@ -43,6 +43,10 @@ class AuthTypeBase(ABC): """Return the name of the user (string)""" pass + def get_user_picture(self): + """Return the location to the user's picture""" + return None + class AuthTypeClientBase(AuthTypeBase): """Base type for all authentication types that require the client to login""" diff --git a/server/auth/auth_oauth.py b/server/auth/auth_oauth.py index d7162318..b2724c57 100644 --- a/server/auth/auth_oauth.py +++ b/server/auth/auth_oauth.py @@ -146,21 +146,19 @@ class AuthTypeOAuth(AuthTypeClientBase): def get_user_id(self): payload = self.get_userinfo() - if payload and payload.get("sub"): - return payload.get("sub") - return None + return payload.get("sub") if payload else None def get_user_name(self): payload = self.get_userinfo() - if payload and payload.get("name"): - return payload.get("name") - return None + return payload.get("name") if payload else None def get_user_email(self): payload = self.get_userinfo() - if payload and payload.get("email"): - return payload.get("email") - return None + return payload.get("email") if payload else None + + def get_user_picture(self): + payload = self.get_userinfo() + return payload.get("picture") if payload else None def update_response(self, response): response.cache_control.update(dict(public=True, max_age=0, no_store=True, no_cache=True, must_revalidate=True)) diff --git a/server/auth/auth_test.py b/server/auth/auth_test.py index 4594dae0..06b92cc7 100644 --- a/server/auth/auth_test.py +++ b/server/auth/auth_test.py @@ -10,12 +10,14 @@ class AuthTypeTest(AuthTypeClientBase): CXGUID = "cxguid_test" CXGUNAME = "cxguname_test" CXGUEMAIL = "cxguemail_test" + CXGUPICTURE = "cxgupicture_test" def __init__(self, app_config): super().__init__() self.user_name = "test_account" self.user_id = "id0001" self.user_email = "test_account@test.com" + self.user_picture = None def is_valid_authentication_type(self): return True @@ -42,12 +44,16 @@ class AuthTypeTest(AuthTypeClientBase): def get_user_email(self): return session.get(self.CXGUEMAIL) + def get_user_picture(self): + return session.get(self.CXGUPICTURE) + def login(self): args = request.args return_to = args.get("dataset", "/") session[self.CXGUID] = args.get("userid", self.user_id) session[self.CXGUNAME] = args.get("username", self.user_name) session[self.CXGUEMAIL] = args.get("email", self.user_email) + session[self.CXGUPICTURE] = args.get("picture", self.user_picture) return redirect(return_to) def logout(self): diff --git a/server/common/config/client_config.py b/server/common/config/client_config.py index ccddf2c1..8a70c0b6 100644 --- a/server/common/config/client_config.py +++ b/server/common/config/client_config.py @@ -117,5 +117,6 @@ def get_client_userinfo(app_config, data_adaptor): "username": auth.get_user_name(), "user_id": auth.get_user_id(), "email": auth.get_user_email(), + "picture": auth.get_user_picture(), } return userinfo diff --git a/server/test/unit/auth/test_auth.py b/server/test/unit/auth/test_auth.py index f7f6f358..0b8c0bf1 100644 --- a/server/test/unit/auth/test_auth.py +++ b/server/test/unit/auth/test_auth.py @@ -82,6 +82,7 @@ class AuthTest(unittest.TestCase): userinfo = session.get(f"{server}/auth/pbmc3k.cxg/api/v0.2/userinfo").json() self.assertTrue(userinfo["userinfo"]["is_authenticated"]) self.assertEqual(userinfo["userinfo"]["username"], "test_account") + self.assertEqual(userinfo["userinfo"]["picture"], None) self.assertTrue(config["config"]["parameters"]["annotations"]) r = session.get(f"{server}/{logout_uri}") @@ -100,6 +101,12 @@ class AuthTest(unittest.TestCase): self.assertIsNone(userinfo) self.assertFalse(config["config"]["parameters"]["annotations"]) + # login with a picture + r = session.get(f"{server}/{login_uri}&picture=myimage.png") + userinfo = session.get(f"{server}/auth/pbmc3k.cxg/api/v0.2/userinfo").json() + self.assertTrue(userinfo["userinfo"]["is_authenticated"]) + self.assertEqual(userinfo["userinfo"]["picture"], "myimage.png") + def test_auth_test_single(self): c = AppConfig() c.update_server_config( From 1c4c501c43b24504b26b289c3668b5d544dd8292 Mon Sep 17 00:00:00 2001 From: Severiano Badajoz Date: Wed, 7 Oct 2020 15:02:55 -0700 Subject: [PATCH 03/16] Auth UI tweaks (#1915) * remove auth buttons and dataset info from info menu * add auth buttons to menubar * remove auth from top left * new auth buttons * move infomenu to lsb dir * styling fixes * feedback * more feedback Co-authored-by: Timmy Huang --- client/src/components/leftSidebar/infoMenu.js | 72 ++++++++++++ .../leftSidebar/topLeftLogoAndTitle.js | 14 +-- client/src/components/menubar/authButtons.js | 87 +++++++++++--- client/src/components/menubar/index.js | 4 + client/src/components/menubar/infoMenu.js | 108 ------------------ 5 files changed, 148 insertions(+), 137 deletions(-) create mode 100644 client/src/components/leftSidebar/infoMenu.js delete mode 100644 client/src/components/menubar/infoMenu.js diff --git a/client/src/components/leftSidebar/infoMenu.js b/client/src/components/leftSidebar/infoMenu.js new file mode 100644 index 00000000..6ab9bdde --- /dev/null +++ b/client/src/components/leftSidebar/infoMenu.js @@ -0,0 +1,72 @@ +// jshint esversion: 6 +import React from "react"; +import { Button, Menu, MenuItem, Popover, Position } from "@blueprintjs/core"; +import { IconNames } from "@blueprintjs/icons"; + +const InformationMenu = React.memo((props) => { + const { libraryVersions, tosURL, privacyURL } = props; + return ( + + + + + + + {tosURL && ( + + )} + {privacyURL && ( + + )} + + } + position={Position.BOTTOM_RIGHT} + modifiers={{ + preventOverflow: { enabled: false }, + hide: { enabled: false }, + }} + > + + + ); + } return ( - - + - - {!userinfo.is_authenticated ? "Log In" : "Log Out"} - - - + Log In + + ); }); diff --git a/client/src/components/menubar/index.js b/client/src/components/menubar/index.js index b4271302..115eb6ff 100644 --- a/client/src/components/menubar/index.js +++ b/client/src/components/menubar/index.js @@ -7,6 +7,7 @@ import styles from "./menubar.css"; import actions from "../../actions"; import Clip from "./clip"; +import AuthButtons from "./authButtons"; import Subset from "./subset"; import UndoRedoReset from "./undoRedo"; import DiffexpButtons from "./diffexpButtons"; @@ -216,6 +217,8 @@ class MenuBar extends React.PureComponent { subsetPossible, subsetResetPossible, enableReembedding, + userinfo, + auth, } = this.props; const { pendingClipPercentiles } = this.state; @@ -241,6 +244,7 @@ class MenuBar extends React.PureComponent { zIndex: 3, }} > + { - dispatch({ type: "toggle dataset drawer" }); -}; - -const InformationMenu = React.memo((props) => { - const { - libraryVersions, - tosURL, - privacyURL, - auth, - userinfo, - dispatch, - } = props; - return ( - - - handleClick(dispatch)} - icon="info-sign" - text="Dataset Overview" - /> - - - - - - - {tosURL ? ( - - ) : null} - {privacyURL ? ( - - ) : null} - - {auth?.["requires_client_login"] && - userinfo?.["is_authenticated"] ? ( - <> - - - - ) : null} - - } - position={Position.BOTTOM_RIGHT} - modifiers={{ - preventOverflow: { enabled: false }, - hide: { enabled: false }, - }} - > - + + + ); +} + export default Auth; diff --git a/client/src/components/termsPrompt/index.js b/client/src/components/termsPrompt/index.js index c6209a00..9aef013c 100644 --- a/client/src/components/termsPrompt/index.js +++ b/client/src/components/termsPrompt/index.js @@ -8,26 +8,7 @@ import { Colors, Icon, } from "@blueprintjs/core"; - -const CookieDecision = "cxg.cookieDecision"; - -function storageGet(key, defaultValue = null) { - try { - const val = window.localStorage.getItem(key); - if (val === null) return defaultValue; - return val; - } catch (e) { - return defaultValue; - } -} - -function storageSet(key, value) { - try { - window.localStorage.setItem(key, value); - } catch { - // continue - } -} +import { storageGet, storageSet, KEYS } from "../util/localStorage"; @connect((state) => ({ tosURL: state.config?.parameters?.["about_legal_tos"], @@ -37,7 +18,7 @@ class TermsPrompt extends React.PureComponent { constructor(props) { super(props); const { tosURL, privacyURL } = this.props; - const cookieDecision = storageGet(CookieDecision, null); + const cookieDecision = storageGet(KEYS.COOKIE_DECISION, null); const hasDecided = cookieDecision !== null; this.state = { hasDecided, @@ -55,7 +36,7 @@ class TermsPrompt extends React.PureComponent { handleOK = () => { this.setState({ isOpen: false }); - storageSet(CookieDecision, "yes"); + storageSet(KEYS.COOKIE_DECISION, "yes"); if (window.cookieDecisionCallback instanceof Function) { try { window.cookieDecisionCallback(); @@ -67,7 +48,7 @@ class TermsPrompt extends React.PureComponent { handleNo = () => { this.setState({ isOpen: false }); - storageSet(CookieDecision, "no"); + storageSet(KEYS.COOKIE_DECISION, "no"); }; renderTos() { diff --git a/client/src/components/util/localStorage.js b/client/src/components/util/localStorage.js new file mode 100644 index 00000000..5766d9f9 --- /dev/null +++ b/client/src/components/util/localStorage.js @@ -0,0 +1,22 @@ +export const KEYS = { + COOKIE_DECISION: "cxg.cookieDecision", + LOGIN_PROMPT: "cxg.LOGIN_PROMPT", +}; + +export function storageGet(key, defaultValue = null) { + try { + const val = window.localStorage.getItem(key); + if (val === null) return defaultValue; + return val; + } catch (e) { + return defaultValue; + } +} + +export function storageSet(key, value) { + try { + window.localStorage.setItem(key, value); + } catch { + // continue + } +} From 86ff48ae36d55944aa3ba66b5025a5fba51c8183 Mon Sep 17 00:00:00 2001 From: maniarathi Date: Fri, 9 Oct 2020 10:09:32 -0700 Subject: [PATCH 09/16] Revert "Allow columns encoded in float64 to be rendered as part of continuous value histograms. (#1905)" (#1925) This reverts commit b048fd8d9a8102f479214e7b8aff85362665c88c. --- client/src/components/continuous/continuous.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/client/src/components/continuous/continuous.js b/client/src/components/continuous/continuous.js index f1119507..ad5c908d 100644 --- a/client/src/components/continuous/continuous.js +++ b/client/src/components/continuous/continuous.js @@ -14,12 +14,7 @@ class Continuous extends React.PureComponent { if (!schema) return null; const obsIndex = schema.annotations.obs.index; const allContinuousNames = schema.annotations.obs.columns - .filter( - (col) => - col.type === "int32" || - col.type === "float32" || - col.type === "float64" - ) + .filter((col) => col.type === "int32" || col.type === "float32") .filter((col) => col.name !== obsIndex) .filter((col) => !col.writable) // skip user annotations - they will be treated as categorical .map((col) => col.name); From beb46bf3df3f69eac17b527be7c3afe4d5015944 Mon Sep 17 00:00:00 2001 From: Severiano Badajoz Date: Fri, 9 Oct 2020 12:42:02 -0600 Subject: [PATCH 10/16] add and check system arg to state auth type in e2e test(#1924) * add and check system arg to state auth type * add tolower Co-authored-by: czimergebot <35308261+czimergebot@users.noreply.github.com> --- client/Makefile | 2 +- client/__tests__/e2e/e2e.test.js | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/client/Makefile b/client/Makefile index eb5c1a78..1a8d3a81 100644 --- a/client/Makefile +++ b/client/Makefile @@ -35,7 +35,7 @@ smoke-test: start_server_and_test \ 'CXG_OPTIONS="--config-file $(CXG_CONFIG)" $(MAKE) start-server' \ $(CXG_SERVER_PORT) \ - 'CXG_URL_BASE="http://localhost:$(CXG_SERVER_PORT)" npm run e2e -- --verbose false' + 'CXG_URL_BASE="http://localhost:$(CXG_SERVER_PORT)" CXG_AUTH_TYPE="test" npm run e2e -- --verbose false' # start an instance of cellxgene and run the end-to-end annotations tests .PHONY: smoke-test-annotations diff --git a/client/__tests__/e2e/e2e.test.js b/client/__tests__/e2e/e2e.test.js index 2bd8ea8c..7e044c40 100644 --- a/client/__tests__/e2e/e2e.test.js +++ b/client/__tests__/e2e/e2e.test.js @@ -522,7 +522,12 @@ test("lasso moves after pan", async () => { expect(panCount).toBe(initialCount); }); -describe("auth buttons", () => { +const describeIfCalledByMakeFileTarget = + process.env.CXG_AUTH_TYPE?.toLowerCase() === "test" + ? describe + : describe.skip; + +describeIfCalledByMakeFileTarget("auth buttons", () => { test("login then logout", async () => { await goToPage(appUrlBase); await clickOnUntil("log-in", async () => { From 5325495123f426f0e22f4949b8eab54281b79679 Mon Sep 17 00:00:00 2001 From: maniarathi Date: Mon, 12 Oct 2020 11:20:58 -0700 Subject: [PATCH 11/16] Speed up dataset drawer rendering (#1926) --- .../src/components/infoDrawer/infoDrawer.js | 98 ++++--------------- .../src/components/infoDrawer/infoFormat.js | 88 ++++++----------- 2 files changed, 53 insertions(+), 133 deletions(-) diff --git a/client/src/components/infoDrawer/infoDrawer.js b/client/src/components/infoDrawer/infoDrawer.js index 57bfd77b..9f41ef14 100644 --- a/client/src/components/infoDrawer/infoDrawer.js +++ b/client/src/components/infoDrawer/infoDrawer.js @@ -1,13 +1,9 @@ import React, { PureComponent } from "react"; -import { connect, shallowEqual } from "react-redux"; +import { connect } from "react-redux"; import { Drawer } from "@blueprintjs/core"; -import Async from "react-async"; import InfoFormat from "./infoFormat"; -import { - selectableCategoryNames, - createCategorySummaryFromDfCol, -} from "../../util/stateManager/controlsHelpers"; +import { selectableCategoryNames } from "../../util/stateManager/controlsHelpers"; @connect((state) => { return { @@ -20,44 +16,6 @@ import { }; }) class InfoDrawer extends PureComponent { - static watchAsync(props, prevProps) { - return !shallowEqual(props.watchProps, prevProps.watchProps); - } - - fetchAsyncProps = async (props) => { - const { schema } = props.watchProps; - const { annoMatrix } = this.props; - - const allCategoryNames = selectableCategoryNames(schema).sort(); - - const nonUserAnnoCategories = allCategoryNames.map((catName) => { - const isUserAnno = schema?.annotations?.obsByName[catName]?.writable; - if (!isUserAnno) return annoMatrix.fetch("obs", catName); - return null; - }); - const singleValueCategories = ( - await Promise.all(nonUserAnnoCategories) - ).reduce((acc, categoryData, i) => { - // Actually check to see if it is null(user anno) - if (!categoryData) return acc; - const catName = allCategoryNames[i]; - - const column = categoryData.icol(0); - const colSchema = schema.annotations.obsByName[catName]; - - const categorySummary = createCategorySummaryFromDfCol(column, colSchema); - - const { numCategoryValues } = categorySummary; - // Add to the array if the category has only one value - if (numCategoryValues === 1) { - acc.set(catName, categorySummary.allCategoryValues[0]); - } - return acc; - }, new Map()); - - return { singleValueCategories }; - }; - handleClose = () => { const { dispatch } = this.props; @@ -74,45 +32,31 @@ class InfoDrawer extends PureComponent { dataPortalProps, } = this.props; + const allCategoryNames = selectableCategoryNames(schema).sort(); + const singleValueCategories = new Map(); + + allCategoryNames.forEach((catName) => { + const isUserAnno = schema?.annotations?.obsByName[catName]?.writable; + const colSchema = schema.annotations.obsByName[catName]; + if (!isUserAnno && colSchema.categories?.length === 1) { + singleValueCategories.set(catName, colSchema.categories[0]); + } + }); + return ( - - - - - - {(error) => { - console.error(error); - return Failed to load info; - }} - - - {(asyncProps) => { - const { singleValueCategories } = asyncProps; - return ( - - ); - }} - - + ); } diff --git a/client/src/components/infoDrawer/infoFormat.js b/client/src/components/infoDrawer/infoFormat.js index eacaa406..880a5f7d 100644 --- a/client/src/components/infoDrawer/infoFormat.js +++ b/client/src/components/infoDrawer/infoFormat.js @@ -1,15 +1,15 @@ -import { H3, H1, UL, Classes } from "@blueprintjs/core"; +import { H3, H1, UL } from "@blueprintjs/core"; import React from "react"; import Truncate from "../util/truncate"; -const renderContributors = (contributors, affiliations, skeleton) => { +const renderContributors = (contributors, affiliations) => { // eslint-disable-next-line no-constant-condition -- Temp removed contributor section to avoid publishing PII if (!contributors || contributors.length === 0 || true) return null; return ( <> -

Contributors

-

+

Contributors

+

{contributors.map((contributor) => { const { email, name, institution } = contributor; @@ -22,7 +22,7 @@ const renderContributors = (contributors, affiliations, skeleton) => { ); })}

- {renderAffiliations(affiliations, skeleton)} + {renderAffiliations(affiliations)} ); }; @@ -39,14 +39,14 @@ const buildAffiliations = (contributors = []) => { return affiliations; }; -const renderAffiliations = (affiliations, skeleton) => { +const renderAffiliations = (affiliations) => { if (affiliations.length === 0) return null; return ( <> -

Affiliations

+

Affiliations

    {affiliations.map((item, index) => ( -
    +
    {index + 1} {" "} {item} @@ -57,12 +57,12 @@ const renderAffiliations = (affiliations, skeleton) => { ); }; -const renderDOILink = (type, doi, skeleton) => { +const renderDOILink = (type, doi) => { if (!doi) return null; return ( <> -

    {type}

    -

    +

    {type}

    +

    {doi} @@ -71,12 +71,12 @@ const renderDOILink = (type, doi, skeleton) => { ); }; -const renderOrganism = (organism, skeleton) => { +const renderOrganism = (organism) => { if (!organism) return null; return ( <> -

    Organism

    -

    {organism}

    +

    Organism

    +

    {organism}

    ); }; @@ -85,11 +85,11 @@ const ONTOLOGY_KEY = "ontology_term_id"; const CAT_WIDTH = "30%"; const VAL_WIDTH = "35%"; // Render list of metadata attributes found in categorical field -const renderSingleValueCategories = (singleValueCategories, skeleton) => { +const renderSingleValueCategories = (singleValueCategories) => { if (singleValueCategories.size === 0) return null; return ( <> -

    Dataset Metadata

    +

    Dataset Metadata

      {Array.from(singleValueCategories).reduce((elems, pair) => { const [category, value] = pair; @@ -115,11 +115,7 @@ const renderSingleValueCategories = (singleValueCategories, skeleton) => { } else { // Create the list item elems.push( -
    • +
    • {`${category}:`} @@ -138,20 +134,17 @@ const renderSingleValueCategories = (singleValueCategories, skeleton) => { // Renders any links found in the config where link_type is not "SUMMARY" // If there are no links in the config, render the aboutURL -const renderLinks = (projectLinks, aboutURL, skeleton) => { +const renderLinks = (projectLinks, aboutURL) => { if (!projectLinks && !aboutURL) return null; if (projectLinks) return ( <> -

      Project Links

      +

      Project Links

        {projectLinks.map((link) => { if (link.link_type === "SUMMARY") return null; return ( -
      • +
      • {link.link_name} @@ -164,14 +157,9 @@ const renderLinks = (projectLinks, aboutURL, skeleton) => { return ( <> -

        More Info

        +

        More Info

        - + {aboutURL}

        @@ -179,24 +167,14 @@ const renderLinks = (projectLinks, aboutURL, skeleton) => { ); }; -const NUM_CATEGORIES = 8; - -// Generates arbitrary placeholder array for singleValueCategories skeleton shape -const singleValueCategoriesPlaceholder = Array.from(Array(NUM_CATEGORIES)).map( - (_, index) => { - return [index, index]; - } -); - const InfoFormat = React.memo( ({ datasetTitle, - singleValueCategories = new Map(singleValueCategoriesPlaceholder), - aboutURL = "thisisabouthtelengthofaurl", + singleValueCategories, + aboutURL, dataPortalProps = {}, - skeleton = false, }) => { - if (dataPortalProps.corpora_schema_version === "1.0.0") { + if (dataPortalProps.version?.["corpora_schema_version"] !== "1.0.0") { dataPortalProps = {}; } const { @@ -212,15 +190,13 @@ const InfoFormat = React.memo( return (
        -

        - {title ?? datasetTitle} -

        - {renderContributors(contributors, affiliations, skeleton)} - {renderDOILink("DOI", doi, skeleton)} - {renderDOILink("Preprint DOI", preprintDOI, skeleton)} - {renderOrganism(organism, skeleton)} - {renderSingleValueCategories(singleValueCategories, skeleton)} - {renderLinks(projectLinks, aboutURL, skeleton)} +

        {title ?? datasetTitle}

        + {renderContributors(contributors, affiliations)} + {renderDOILink("DOI", doi)} + {renderDOILink("Preprint DOI", preprintDOI)} + {renderOrganism(organism)} + {renderSingleValueCategories(singleValueCategories)} + {renderLinks(projectLinks, aboutURL)}
        ); } From 798976e4c1f44f35be7acc4d8a95ea2beb5b825c Mon Sep 17 00:00:00 2001 From: maniarathi Date: Tue, 13 Oct 2020 15:47:56 -0700 Subject: [PATCH 12/16] Fix custom color handling (#1929) --- client/src/components/infoDrawer/infoFormat.js | 7 +------ client/src/util/stateManager/colorHelpers.js | 10 +++++++--- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/client/src/components/infoDrawer/infoFormat.js b/client/src/components/infoDrawer/infoFormat.js index 880a5f7d..6909e7cf 100644 --- a/client/src/components/infoDrawer/infoFormat.js +++ b/client/src/components/infoDrawer/infoFormat.js @@ -168,12 +168,7 @@ const renderLinks = (projectLinks, aboutURL) => { }; const InfoFormat = React.memo( - ({ - datasetTitle, - singleValueCategories, - aboutURL, - dataPortalProps = {}, - }) => { + ({ datasetTitle, singleValueCategories, aboutURL, dataPortalProps = {} }) => { if (dataPortalProps.version?.["corpora_schema_version"] !== "1.0.0") { dataPortalProps = {}; } diff --git a/client/src/util/stateManager/colorHelpers.js b/client/src/util/stateManager/colorHelpers.js index 4eaea2b7..d324dd37 100644 --- a/client/src/util/stateManager/colorHelpers.js +++ b/client/src/util/stateManager/colorHelpers.js @@ -106,15 +106,19 @@ export function loadUserColorConfig(userColors) { return -1; }) .reduce( - (acc, label, i) => { + (acc, label) => { const color = parseRGB(userColors[category][label]); acc[0][label] = color; - acc[1][i] = d3.rgb(255 * color[0], 255 * color[1], 255 * color[2]); + acc[1][label] = d3.rgb( + 255 * color[0], + 255 * color[1], + 255 * color[2] + ); return acc; }, [{}, {}] ); - const scale = (i) => scaleMap[i]; + const scale = (label) => scaleMap[label]; convertedUserColors[category] = { colors, scale }; }); return convertedUserColors; From 242546371b643031d99f3be8fab3ba05f80bc1f4 Mon Sep 17 00:00:00 2001 From: Madison Dunitz Date: Wed, 14 Oct 2020 12:46:24 -0500 Subject: [PATCH 13/16] Remove Continuous vars with 1 value from histogram, add to info drawer (#1927) * remove single val continous metadata from histogram, add to info drawer * refactor to save singleContinuous values in state * fix edge case, single continuous values reappeard in rsb when clipped --- .../components/brushableHistogram/index.js | 36 ++++++++++++++++--- .../src/components/infoDrawer/infoDrawer.js | 18 +++++++--- .../src/components/infoDrawer/infoFormat.js | 10 +++--- client/src/reducers/index.js | 3 +- client/src/reducers/singleContinuousValue.js | 14 ++++++++ 5 files changed, 65 insertions(+), 16 deletions(-) create mode 100644 client/src/reducers/singleContinuousValue.js diff --git a/client/src/components/brushableHistogram/index.js b/client/src/components/brushableHistogram/index.js index a18608b2..e7c6432c 100644 --- a/client/src/components/brushableHistogram/index.js +++ b/client/src/components/brushableHistogram/index.js @@ -450,6 +450,7 @@ const Histogram = ({ isScatterplotYYaccessor: state.controls.scatterplotYYaccessor === field, continuousSelectionRange: state.continuousSelection[myName], isColorAccessor: state.colors.colorAccessor === field, + singleContinuousValues: state.singleContinuousValue.singleContinuousValues, }; }) class HistogramBrush extends React.PureComponent { @@ -608,18 +609,44 @@ class HistogramBrush extends React.PureComponent { }; fetchAsyncProps = async () => { - const { annoMatrix } = this.props; + const { annoMatrix, field, dispatch, singleContinuousValues } = this.props; const { isClipped } = annoMatrix; - + if (singleContinuousValues.has(field)) { + return { + histogram: undefined, + range: undefined, + unclippedRange: undefined, + unclippedRangeColor: globals.blue, + isSingleValue: true, + OK2Render: false, + }; + } const query = this.createQuery(); const df = await annoMatrix.fetch(...query); const column = df.icol(0); - // if we are clipped, fetch both our value and our unclipped value, - // as we need the absolute min/max range, not just the clipped min/max. const summary = column.summarize(); const range = [summary.min, summary.max]; + if (summary.min === summary.max && !isClipped) { + dispatch({ + type: "add single continuous value", + field, + value: summary.min, + }); + return { + histogram: undefined, + range, + unclippedRange: range, + unclippedRangeColor: globals.blue, + isSingleValue: true, + OK2Render: false, + }; + } + + const isSingleValue = summary.min === summary.max; + // if we are clipped, fetch both our value and our unclipped value, + // as we need the absolute min/max range, not just the clipped min/max. let unclippedRange = [...range]; if (isClipped) { const parent = await annoMatrix.viewOf.fetch(...query); @@ -643,7 +670,6 @@ class HistogramBrush extends React.PureComponent { this.height ); - const isSingleValue = summary.min === summary.max; const nonFiniteExtent = summary.min === undefined || summary.max === undefined || diff --git a/client/src/components/infoDrawer/infoDrawer.js b/client/src/components/infoDrawer/infoDrawer.js index 9f41ef14..aad395bc 100644 --- a/client/src/components/infoDrawer/infoDrawer.js +++ b/client/src/components/infoDrawer/infoDrawer.js @@ -1,7 +1,6 @@ import React, { PureComponent } from "react"; -import { connect } from "react-redux"; +import { connect, shallowEqual } from "react-redux"; import { Drawer } from "@blueprintjs/core"; - import InfoFormat from "./infoFormat"; import { selectableCategoryNames } from "../../util/stateManager/controlsHelpers"; @@ -13,9 +12,14 @@ import { selectableCategoryNames } from "../../util/stateManager/controlsHelpers aboutURL: state.config?.links?.["about-dataset"], isOpen: state.controls.datasetDrawer, dataPortalProps: state.config?.["corpora_props"] ?? {}, + singleContinuousValues: state.singleContinuousValue.singleContinuousValues, }; }) class InfoDrawer extends PureComponent { + static watchAsync(props, prevProps) { + return !shallowEqual(props.watchProps, prevProps.watchProps); + } + handleClose = () => { const { dispatch } = this.props; @@ -30,18 +34,22 @@ class InfoDrawer extends PureComponent { schema, isOpen, dataPortalProps, + singleContinuousValues, } = this.props; const allCategoryNames = selectableCategoryNames(schema).sort(); - const singleValueCategories = new Map(); + const allSingleValues = new Map(); allCategoryNames.forEach((catName) => { const isUserAnno = schema?.annotations?.obsByName[catName]?.writable; const colSchema = schema.annotations.obsByName[catName]; if (!isUserAnno && colSchema.categories?.length === 1) { - singleValueCategories.set(catName, colSchema.categories[0]); + allSingleValues.set(catName, colSchema.categories[0]); } }); + singleContinuousValues.forEach((value, catName) => { + allSingleValues.set(catName, value); + }); return ( diff --git a/client/src/components/infoDrawer/infoFormat.js b/client/src/components/infoDrawer/infoFormat.js index 6909e7cf..04de063b 100644 --- a/client/src/components/infoDrawer/infoFormat.js +++ b/client/src/components/infoDrawer/infoFormat.js @@ -85,13 +85,13 @@ const ONTOLOGY_KEY = "ontology_term_id"; const CAT_WIDTH = "30%"; const VAL_WIDTH = "35%"; // Render list of metadata attributes found in categorical field -const renderSingleValueCategories = (singleValueCategories) => { - if (singleValueCategories.size === 0) return null; +const renderSingleValues = (singleValues) => { + if (singleValues.size === 0) return null; return ( <>

        Dataset Metadata

          - {Array.from(singleValueCategories).reduce((elems, pair) => { + {Array.from(singleValues).reduce((elems, pair) => { const [category, value] = pair; // If the value is empty skip it if (!value) return elems; @@ -168,7 +168,7 @@ const renderLinks = (projectLinks, aboutURL) => { }; const InfoFormat = React.memo( - ({ datasetTitle, singleValueCategories, aboutURL, dataPortalProps = {} }) => { + ({ datasetTitle, allSingleValues, aboutURL, dataPortalProps = {} }) => { if (dataPortalProps.version?.["corpora_schema_version"] !== "1.0.0") { dataPortalProps = {}; } @@ -190,7 +190,7 @@ const InfoFormat = React.memo( {renderDOILink("DOI", doi)} {renderDOILink("Preprint DOI", preprintDOI)} {renderOrganism(organism)} - {renderSingleValueCategories(singleValueCategories)} + {renderSingleValues(allSingleValues)} {renderLinks(projectLinks, aboutURL)}
    ); diff --git a/client/src/reducers/index.js b/client/src/reducers/index.js index f40dd946..5305706b 100644 --- a/client/src/reducers/index.js +++ b/client/src/reducers/index.js @@ -21,7 +21,7 @@ import centroidLabels from "./centroidLabels"; import pointDialation from "./pointDilation"; import { reembedController } from "./reembed"; import { gcMiddleware as annoMatrixGC } from "../annoMatrix"; - +import singleContinuousValue from "./singleContinuousValue"; import undoableConfig from "./undoableConfig"; const Reducer = undoable( @@ -32,6 +32,7 @@ const Reducer = undoable( ["ontology", ontology], ["annotations", annotations], ["layoutChoice", layoutChoice], + ["singleContinuousValue", singleContinuousValue], ["categoricalSelection", categoricalSelection], ["continuousSelection", continuousSelection], ["graphSelection", graphSelection], diff --git a/client/src/reducers/singleContinuousValue.js b/client/src/reducers/singleContinuousValue.js new file mode 100644 index 00000000..7bea9dfe --- /dev/null +++ b/client/src/reducers/singleContinuousValue.js @@ -0,0 +1,14 @@ +const initialState = { + singleContinuousValues: new Map(), +}; +const singleContinuousValue = (state = initialState, action) => { + switch (action.type) { + case "add single continuous value": + state.singleContinuousValues.set(action.field, action.value); + return state; + default: + return state; + } +}; + +export default singleContinuousValue; From c9f95491182d134332edf8fe218b8ff2554ecff0 Mon Sep 17 00:00:00 2001 From: Severiano Badajoz Date: Fri, 16 Oct 2020 11:59:28 -0700 Subject: [PATCH 14/16] Adopt JS standards once `userinfo` data is in frontend (#1930) --- client/src/actions/index.js | 8 ++++---- client/src/components/autosave/filenameDialog.js | 6 +++--- client/src/components/categorical/index.js | 8 ++++---- client/src/components/menubar/authButtons.js | 12 ++++++------ client/src/components/menubar/index.js | 6 +++--- client/src/reducers/index.js | 4 ++-- client/src/reducers/{userinfo.js => userInfo.js} | 5 ++--- 7 files changed, 24 insertions(+), 25 deletions(-) rename client/src/reducers/{userinfo.js => userInfo.js} (83%) diff --git a/client/src/actions/index.js b/client/src/actions/index.js index 8730730e..a71149ee 100644 --- a/client/src/actions/index.js +++ b/client/src/actions/index.js @@ -43,12 +43,12 @@ async function configFetch(dispatch) { async function userInfoFetch(dispatch) { return fetchJson("userinfo").then((response) => { - const { userinfo } = response || {}; + const { userinfo: userInfo } = response || {}; dispatch({ - type: "userinfo load complete", - userinfo, + type: "userInfo load complete", + userInfo, }); - return userinfo; + return userInfo; }); } diff --git a/client/src/components/autosave/filenameDialog.js b/client/src/components/autosave/filenameDialog.js index 08fe1462..3ff7e500 100644 --- a/client/src/components/autosave/filenameDialog.js +++ b/client/src/components/autosave/filenameDialog.js @@ -15,7 +15,7 @@ import { idhash: state.config?.parameters?.["annotations-user-data-idhash"] ?? null, annotations: state.annotations, auth: state.config?.authentication, - userinfo: state.userinfo, + userInfo: state.userInfo, writableCategoriesEnabled: state.config?.parameters?.annotations ?? false, })) class FilenameDialog extends React.Component { @@ -97,7 +97,7 @@ class FilenameDialog extends React.Component { writableCategoriesEnabled, annotations, idhash, - userinfo, + userInfo, } = this.props; const { filenameText } = this.state; @@ -105,7 +105,7 @@ class FilenameDialog extends React.Component { annotations.promptForFilename && !annotations.dataCollectionNameIsReadOnly && !annotations.dataCollectionName && - userinfo.is_authenticated ? ( + userInfo.is_authenticated ? ( Create new category diff --git a/client/src/components/menubar/authButtons.js b/client/src/components/menubar/authButtons.js index 91729679..91af0b42 100644 --- a/client/src/components/menubar/authButtons.js +++ b/client/src/components/menubar/authButtons.js @@ -31,11 +31,11 @@ const LOGIN_PROMPT_OFF = "off"; const Auth = React.memo((props) => { const [isPromptOpen, setIsPromptOpen] = useState(shouldShowPrompt()); - const { auth, userinfo } = props; + const { auth, userInfo } = props; - const isAuthenticated = userinfo && userinfo.is_authenticated; + const isAuthenticated = userInfo && userInfo.is_authenticated; - window.userinfo = userinfo; + window.userInfo = userInfo; const randomInt = Math.random() * 15; const sexIndex = Math.floor(randomInt / 5); @@ -55,7 +55,7 @@ const Auth = React.memo((props) => { { style={{ padding: 0 }} > {/* eslint-disable-next-line no-constant-condition -- disable profile picture until CSP is tweaked */} - {userinfo?.picture && false ? ( - profile + {userInfo?.picture && false ? ( + profile ) : ( {scientist} )} diff --git a/client/src/components/menubar/index.js b/client/src/components/menubar/index.js index 115eb6ff..cbfeec5e 100644 --- a/client/src/components/menubar/index.js +++ b/client/src/components/menubar/index.js @@ -42,7 +42,7 @@ import { getEmbSubsetView } from "../../util/stateManager/viewStackHelpers"; celllist2: state.differential.celllist2, libraryVersions: state.config?.["library_versions"], auth: state.config?.authentication, - userinfo: state.userinfo, + userInfo: state.userInfo, undoDisabled: state["@@undoable/past"].length === 0, redoDisabled: state["@@undoable/future"].length === 0, aboutLink: state.config?.links?.["about-dataset"], @@ -217,7 +217,7 @@ class MenuBar extends React.PureComponent { subsetPossible, subsetResetPossible, enableReembedding, - userinfo, + userInfo, auth, } = this.props; const { pendingClipPercentiles } = this.state; @@ -244,7 +244,7 @@ class MenuBar extends React.PureComponent { zIndex: 3, }} > - + { switch (action.type) { case "initial data load start": @@ -7,12 +6,12 @@ const UserInfo = (state = {}, action) => { loading: true, error: null, }; - case "userinfo load complete": + case "userInfo load complete": return { ...state, loading: false, error: null, - ...action.userinfo, + ...action.userInfo, }; case "initial data load error": return { From 6a741956e1ee92496c09943f5f8fd8d19f1462a8 Mon Sep 17 00:00:00 2001 From: bmccandless Date: Fri, 16 Oct 2020 14:02:05 -0700 Subject: [PATCH 15/16] Update readme for eb server. (#1928) * Update readme for eb server. Update the README with new way of handling secrets. Update portions that were out of date. Add a section for Authentication and a placeholder for User Annotations. Also remove an obsolete function that processes the AWS secrets. #1522 Co-authored-by: Madison Dunitz --- server/common/config/__init__.py | 64 +------ server/eb/README.md | 165 +++++++++++++----- server/eb/app.py | 20 +-- server/eb/check_config.py | 2 +- .../unit/common/config/test_server_config.py | 27 --- 5 files changed, 123 insertions(+), 155 deletions(-) diff --git a/server/common/config/__init__.py b/server/common/config/__init__.py index fb2439e7..8a74427b 100644 --- a/server/common/config/__init__.py +++ b/server/common/config/__init__.py @@ -1,66 +1,4 @@ -import logging -import os -import sys - -from server.common.aws_secret_utils import get_secret_key -from server.common.data_locator import discover_s3_region_name +from server.common.aws_secret_utils import get_secret_key # noqa F504 DEFAULT_SERVER_PORT = 5005 BIG_FILE_SIZE_THRESHOLD = 100 * 2 ** 20 # 100MB - - -def handle_config_from_secret(app_config): - """Update configuration from the secret manager""" - secret_name = os.getenv("CXG_AWS_SECRET_NAME") - if not secret_name: - return - - # need to find the secret manager region. - # 1. from CXG_AWS_SECRET_REGION_NAME - # 2. discover from dataroot location (if on s3) - # 3. discover from config file location (if on s3) - secret_region_name = os.getenv("CXG_AWS_SECRET_REGION_NAME") - if secret_region_name is None: - secret_region_name = discover_s3_region_name(app_config.multi_dataset__dataroot) - if not secret_region_name: - from server.eb.app import config_file - - secret_region_name = discover_s3_region_name(config_file) - if not secret_region_name: - logging.error("Could not determine the AWS Secret Manager region") - sys.exit(1) - - secrets = get_secret_key(secret_region_name, secret_name) - - if not secrets: - return - - server_attrs = ( - ("flask_secret_key", "app__flask_secret_key"), - ("oauth_client_secret", "authentication__params_oauth__client_secret"), - ) - default_dataset_attrs = (("db_uri", "user_annotations__hosted_tiledb_array__db_uri"),) - - # update server configuration attributes - for key, attr in server_attrs: - cur_val = getattr(app_config.server_config, attr) - if cur_val: - continue - - # replace the attr with the secret if it is not set - val = secrets.get(key) - if val: - logging.info(f"set {attr} from secret") - app_config.update_server_config(**{attr: val}) - - # update default dataset configuration attributes - for key, attr in default_dataset_attrs: - cur_val = getattr(app_config.default_dataset_config, attr) - if cur_val: - continue - - # replace the attr with the secret if it is not set - val = secrets.get(key) - if val: - logging.info(f"set {attr} from secret") - app_config.update_default_dataset_config(**{attr: val}) diff --git a/server/eb/README.md b/server/eb/README.md index f4209f15..306eda00 100644 --- a/server/eb/README.md +++ b/server/eb/README.md @@ -1,12 +1,12 @@ # AWS Elastic Beanstalk -This directory contains script to aid in creating and deploying cellxgene on -an AWS Elastic Beanstalk instance. +This directory contains scripts to aid in creating and deploying cellxgene on +AWS Elastic Beanstalk. This will result in a variant of cellxgene, running on AWS EC2 instances, serving data from S3. -All datasets must be in the new CXG (tiledb) format - see the converter script cxgtool.py -in server/converters - and located in a single S3 prefix, which is accessible to the instance. -In the current incarnation, no access control or authentication support is available +All datasets must be in the CXG (tiledb) format (see `cellxene convert --help`), +and located under a single S3 prefix, which is accessible to the instance. +In the current incarnation, no access control is available (outside of anything you configure yourself), so this is most appropriate for public datasets. This is early development work, and will change significantly in the near future. @@ -17,10 +17,10 @@ We would love feedback on it, but please assume it will change. 1. Some familiarity with AWS EB, S3, and IAM are needed. 2. Install the awsebcli. -Instruction are here: -https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/eb-cli3-install.html + Instruction are here: + https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/eb-cli3-install.html -3. In the top level directory, run ```make build-client``` to create the client static assets. +3. In the top level directory, run `make build-client` to create the client static assets. ## Steps @@ -31,20 +31,21 @@ There are many more options to these commands that may be important or necessary The following choices are known to work. -* S3 Bucket. -* POSIX filesystem (such as Lustre) -* Lustre filesystem backed by S3 +- S3 Bucket. +- POSIX filesystem (such as Lustre) +- Lustre filesystem backed by S3 S3 is convenient and the relatively inexpensive option. Lustre is higher performance, but more expensive, and slightly more complex to setup and manage. -AWS supports a feature to back the Lustre filesystem with S3, which give an easy to manage and high +AWS supports a feature to back the Lustre filesystem with S3, which gives an easy to manage, high performance option. -Once the storage is in place, the next step is to copy your matrix files to that location. -Currently cellxgene supports a flat file organization. Each matrix file is located from -the same s3 prefix or filesystem directory. This location is specified in the configuration as the dataroot. +Once the storage is in place, the next step is to copy your data files to that location. +Currently cellxgene supports a flat file organization. Each matrix file is located under +the same s3 prefix or filesystem directory. This location is specified in the configuration +as the dataroot. -### 2. Create an elastic beanstalk application. For example: +### 2. Create an elastic beanstalk application. For example: ``` EB_APP=cellxgene-app @@ -54,9 +55,9 @@ eb init -p python-3.6 $EB_APP ### 3. Configuring cellxgene All the cellxgene configuration options can be set from a configuration file. -This file can be generated like this: +A yaml config file containing all of the default configuration options can be generated like this: -```cellxgene launch --dump-default-config > myconfig.yaml``` +`cellxgene launch --dump-default-config > myconfig.yaml` The config file may then be customized before the app is deployed. @@ -66,18 +67,14 @@ First, if your config file is named "config.yaml" and exists in `customize/confi then it will be bundled with the application zip file and installed along side the app on the EB servers. -Second, a potentially more flexible approach is to place your config file in a location accessible to the EB -servers, such as in S3. For example: s3://my-bucket/my-datasets/config.yaml. +Second, a potentially more flexible approach is to place your config file in a location accessible +to the EB servers, such as in S3. For example: s3://my-bucket/my-datasets/config.yaml. Set the CXG_CONFIG_FILE environment variable to specify this location. -Another option is to set the CXG_DATAROOT environment variable. The dataroot +Another option is to set the CXG_DATAROOT environment variable. The dataroot is the location where the matrix files are located. This environment variable will override the dataroot in the config file (if specified). -- Note: Certain features, such as user annotations, are automatically disabled by the EB app, -and cannot be enabled using configuration. They may be enabled manually by modifying app.py, however -this is not supported or recommended at this time. - ### 4. Customization The deployment can be customized in several ways, by adding files to a directory called @@ -93,15 +90,15 @@ The cellxgene server can serve additional static webpages that will be associate These include the about_legal_tos (terms of service), and about_legal_privacy, for example. To use this feature, do the following: -* In this directory, create a sub directory called "customize/deploy/". -* Copy the files you want to serve into this directory -* modify your configuration file to set the location to these file: /static/cellxgene/deploy/ +- In this directory, create a sub directory called "customize/deploy/". +- Copy the files you want to serve into this directory +- modify your configuration file to set the location to these file: /static/cellxgene/deploy/ -Example: you want to include an "about_legal_tos" and "about_legal_privacy" page to cellxgene. +Example: you want to include an "about_legal_tos" and "about_legal_privacy" page to cellxgene. Assume files called "tos.html" and "privacy.html" exist. ``` -$ mkdir static +$ mkdir -p customize/deploy $ cp /tos.html customize/deploy/tos.html $ cp /privacy.html customize/deploy/privacy.html @@ -116,14 +113,14 @@ about_legal_privacy: /static/cellxgene/deploy/privacy.html Additional scripts can be added using the server/inline_scripts config parameters. To include these scripts in the deployment, use the following steps: -* In this directory, create a sub directory called "customize/inline_scripts". -* Copy the script files into this directory -* modify your configuration file to set the location to these file (leaving off customize/inline_scripts) +- In this directory, create a sub directory called "customize/inline_scripts". +- Copy the script files into this directory +- Modify your configuration file to set the location to these file (leaving off customize/inline_scripts) For example, to add an inline script called "myscript.js": ``` -$ mkdir scripts +$ mkdir -p customize/inline_scripts $ cp /myscript.js customize/inline_scripts/myscript.js # edit the config.yaml $ grep inline_scripts config.yaml @@ -135,14 +132,14 @@ $ grep inline_scripts config.yaml Optionally, you can add plugins to the server python code. To include a plugin in the deployment use the following steps: ``` -$ mkdir plugins +$ mkdir -p customize/plugins $ cp /.py customize/plugins/.py ``` #### ebextensions Any additional config files intended for the `.ebextensions` directory of the artifact can be added -to the `customize/ebextensions` directory. Any file found here will be copied over. +to the `customize/ebextensions` directory. Any file found here will be copied over. #### requirements.txt @@ -152,6 +149,7 @@ This is useful to ensure that the dependencies do not change from one deployment Therefore the custom/requirements.txt must all have exact versions specified (e.g. anndata==0.7.1). This file can be generated the first time using a process like this: + ``` # assume you are running in this directory $ virtualenv temp @@ -168,6 +166,20 @@ If a future cellxgene version updates its requirements by modifying a module ver or adding a new dependency, then the `make build` process will detect any incompatibilities and raise an error. +#### File structure for customizations + +The following diagram shows the file structure for the customization directory. + +``` +customization ++-- config.yaml ++-- deploy/ ++-- inline_scripts/ ++-- plugins/ ++-- ebextensions/ ++-- requirements.txt +``` + ### 5. Create the artifact.zip file for the application ``` @@ -176,19 +188,13 @@ $ make build ### 6. Flask secret key -The application requires as secret key to be provided to flask, the web framework used by cellxgene. +The application requires a secret key to be provided to flask, the web framework used by cellxgene. There are three ways to provide the secret key: -- In the configuration file: update the server/flask_secret_key attribute. +- In the configuration file, update the server/flask_secret_key attribute. +- In the configuration file, update the external/aws_secrets_manager section to set the + secret name and key that defines the flask secret key. - An environment variable: `CXG_SECRET_KEY` -- Managed by the AWS Secret Manager - -If using the AWS Secret Manager, then the secret name is passed as an environment variable: CXG_AWS_SECRET_NAME. -The secret must contain a key with the name "flask_secret_key". -The region name for the AWS Secret Manager must be specified (e.g. us-east-1). -The most straightforward way is to specified it with the CXG_AWS_SECRET_REGION_NAME environment variable. -If this environment variable is not defined, then the app attempts to determine the region from the -dataroot (if in s3), or the config file location (if in s3). ### 7. Create an environment @@ -203,7 +209,8 @@ $ EB_INSTANCE=m5.large $ CXG_DATAROOT= $ CXG_CONFIG_FILE= -# Potentially also set envvars for the secret key. +# Potentially also set an environment variable for the flask secret key, +# and other environemet variable described in the configuration file. $ eb create $EB_ENV --instance-type $EB_INSTANCE \ --envvars CXG_DATAROOT=$CXG_DATAROOT,CXG_CONFIG_FILE=$CXG_CONFIG_FILE @@ -227,3 +234,67 @@ $ eb deploy $EB_ENV ``` $ eb open $EB_ENV ``` + +## Advanced Features + +### Authentication + +Authentication can be configured in the configuration file. Authentication is required +for User Annotations (see below). User Annotations is a feature where annotations can be +created by the user +, and +then associated with the user's id. +When the user revisits the site, their annotations will be available. + +There are three main authentication modes: null, session, or oauth. +In the configuration file specify the authentication mode by setting +`server / authentication / type`. + +#### null + +Authentication is disabled: user annotations cannot be enabled. + +#### session + +The user is associated with their client browser session. This approach is +simple to setup, but not recommended for hosted cellxgene, since the user will not have access to +their annotations when running from a different browser, or if their cookies get cleared. + +#### oauth + +A user logs into cellxgene using an identity provider (like Google), or logs in using +an email/password. This is the best option, but requires making use of an oauth service and +additional configuration of the cellxgene server. + +To see what this looks like, please look at https://cellxgene.cziscience.com/, +and view one of the cellxgene datasets. +For this server, Auth0 (auth0.com) is used for authentication, but there are other options. +There are good sources of documentation online that describe how to use one of these +services. + +The `params_oauth` section in the configuration file describes characteristics of the +authentication service, like "client_id" and "client_secret". +For security, the client_secret needs to be protected. One option is to +store it in the AWS Secrets Manager. + +### User Annotations + +User annotations can be configured in the configuration file both generally and for a specific data route. The annotations feature is only available when Authorization is enabled. +To enable Annotations, it is necessary to create a relational database and add the database uri (typically `postgresql://[user[:password]@][netloc][:port][/dbname]`) to the secrets manager under `DB_URI`. +The hosted version of cellxgene runs on AWS's [Aurora PostgreSQL](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.AuroraPostgreSQL.html) but any sqlalchemy compatible relational database should work. +Once the database is set up apply the cellxgene schema to your database by running the following inside the cellxgene repo +`PROJECT_ROOT=$(git rev-parse --show-toplevel)` +`python3` +Inside the python console +`from sqlalchemy import create_engine` +`from server.db.cellxgene_orm import Base` +`uri = "[DB_URI]”` +`engine = create_engine(uri)` + + Base.metadata.create_all(engine)` + +To check the schema was properly applied (or just to check what is in the database at any point) +ssh into your database. For a postgres database this entails running: +`psql [DB_URI]` + +You'll also need to update your IAM policies to allow the instance to write to the s3 bucket. diff --git a/server/eb/app.py b/server/eb/app.py index 0844f599..4b2212f5 100644 --- a/server/eb/app.py +++ b/server/eb/app.py @@ -9,8 +9,6 @@ from flask import json import logging from flask_talisman import Talisman from flask_cors import CORS -from server.common.config import handle_config_from_secret -from server.common.errors import SecretKeyRetrievalError if os.path.isdir("/opt/python/log"): @@ -165,26 +163,14 @@ try: logging.info("Configuration from CXG_DATAROOT") app_config.update_server_config(multi_dataset__dataroot=dataroot) - # update from secret manager - try: - handle_config_from_secret(app_config) - except SecretKeyRetrievalError: - sys.exit(1) - - # features are unsupported in the current hosted server + # overwrite configuration for the eb app app_config.update_default_dataset_config(embeddings__enable_reembedding=False,) app_config.update_server_config(multi_dataset__allowed_matrix_types=["cxg"],) + + # complete config app_config.complete_config(logging.info) - if not app_config.server_config.app__flask_secret_key: - logging.critical( - "flask_secret_key is not provided. Either set in config file, CXG_SECRET_KEY environment variable, " - "or in AWS Secret Manager" - ) - sys.exit(1) - server = WSGIServer(app_config) - debug = False application = server.app diff --git a/server/eb/check_config.py b/server/eb/check_config.py index 6886e101..2ea1c25e 100644 --- a/server/eb/check_config.py +++ b/server/eb/check_config.py @@ -19,8 +19,8 @@ def main(): args = parser.parse_args() app_config = AppConfig() - app_config.update_from_config_file(args.config_file) try: + app_config.update_from_config_file(args.config_file) app_config.complete_config() except Exception as e: print(f"Error: {str(e)}") diff --git a/server/test/unit/common/config/test_server_config.py b/server/test/unit/common/config/test_server_config.py index f862a40f..69257bfd 100644 --- a/server/test/unit/common/config/test_server_config.py +++ b/server/test/unit/common/config/test_server_config.py @@ -310,30 +310,3 @@ class TestServerConfig(ConfigTests): mock_tiledb_context.assert_called_once_with( {"sm.tile_cache_size": 10, "sm.num_reader_threads": 2, "vfs.s3.region": "us-east-1"} ) - - @mockenv(CXG_AWS_SECRET_NAME="TESTING", CXG_AWS_SECRET_REGION_NAME="TEST_REGION") - @patch("server.common.config.get_secret_key") - def test_get_config_vars_from_aws_secrets(self, mock_get_secret_key): - mock_get_secret_key.return_value = { - "flask_secret_key": "mock_flask_secret", - "oauth_client_secret": "mock_oauth_secret", - "db_uri": "mock_db_uri", - } - - config = AppConfig() - - with self.assertLogs(level="INFO") as logger: - from server.common.config import handle_config_from_secret - - # should not throw error - # "AttributeError: 'XConfig' object has no attribute 'x'" - handle_config_from_secret(config) - - # should log 3 lines (one for each var set from a secret) - self.assertEqual(len(logger.output), 3) - self.assertIn("INFO:root:set app__flask_secret_key from secret", logger.output[0]) - self.assertIn("INFO:root:set authentication__params_oauth__client_secret from secret", logger.output[1]) - self.assertIn("INFO:root:set user_annotations__hosted_tiledb_array__db_uri from secret", logger.output[2]) - self.assertEqual(config.server_config.app__flask_secret_key, "mock_flask_secret") - self.assertEqual(config.server_config.authentication__params_oauth__client_secret, "mock_oauth_secret") - self.assertEqual(config.default_dataset_config.user_annotations__hosted_tiledb_array__db_uri, "mock_db_uri") From 377e4bccaa51d12142b1e643c8fdc14ff94acc31 Mon Sep 17 00:00:00 2001 From: maniarathi Date: Mon, 19 Oct 2020 10:31:36 -0700 Subject: [PATCH 16/16] Remove errornous checking for converting float64 to float32. In reality the slight difference by downcasting is totally fine. (#1935) --- server/common/utils/type_conversion_utils.py | 23 ++++++-------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/server/common/utils/type_conversion_utils.py b/server/common/utils/type_conversion_utils.py index ccda1177..762dccb2 100644 --- a/server/common/utils/type_conversion_utils.py +++ b/server/common/utils/type_conversion_utils.py @@ -88,24 +88,15 @@ def get_schema_type_hint_from_dtype(dtype, array_values=None): def can_cast_to_float32(dtype, array_values): """ - A dtype can be cast to float32 if it is a float type and converting it to float32 presents the same output as the - original values. Note that NaNs fail equality (i.e. np.NaN != np.NaN) so we use np.testing.assert_equal to ensure - that the arrays are equal minus NaNs. + Optimistically returns True signifying that a type downcast to float32 is possible whenever the incoming type is + a float. We also handle a special case here where the array is a Series object with integer categorical values AND NaNs. - Since NaNs are floating points in numpy, we upcast the integer array to float32. + Since NaNs are floating points in numpy, we upcast the integer array to float32 and return True. """ if dtype.kind == "f": - # Try to convert the array to float32 - converted_float32_values = array_values.to_numpy(np.float32) - original_values = array_values.to_numpy() - - # Verify that the two arrays are equal except for NaNs (which will equate to be unequal). - if not ((converted_float32_values != original_values) == np.isnan(original_values)).all(): - return False - - if dtype != np.float32: + if not np.can_cast(dtype, np.float32): logging.warning(f"Type {dtype.name} will be converted to 32 bit float and may lose precision.") return True @@ -138,9 +129,9 @@ def can_cast_to_int32(dtype, array_values=None): return True ii32 = np.iinfo(np.int32) if ( - not ordered_array_values.empty - and (ordered_array_values.min() >= ii32.min and ordered_array_values.max() <= ii32.max) - or ordered_array_values.empty + not ordered_array_values.empty + and (ordered_array_values.min() >= ii32.min and ordered_array_values.max() <= ii32.max) + or ordered_array_values.empty ): return True return False