diff --git a/cellxgene_gateway/backend_cache.py b/cellxgene_gateway/backend_cache.py index c038101..31dd4a3 100644 --- a/cellxgene_gateway/backend_cache.py +++ b/cellxgene_gateway/backend_cache.py @@ -33,12 +33,12 @@ class BackendCache: contents = self.entry_list return [c.port for c in contents] - def check_entry(self, dataset): + def check_entry(self, key): contents = self.entry_list matches = [ c for c in contents - if c.dataset == dataset and c.status != "terminated" + if c.key.dataset == key.dataset and c.key.annotation_file == key.annotation_file and c.status != "terminated" ] if len(matches) == 0: @@ -51,13 +51,14 @@ class BackendCache: "Found " + str(len(matches)) + " for " + dataset, ) - def create_entry(self, dataset, file_path, scripts): + def create_entry(self, key, scripts): port = 8000 existing_ports = self.get_ports() + while (port in existing_ports) or is_port_in_use(port): port += 1 - entry = CacheEntry.for_dataset(dataset, file_path, port) + entry = CacheEntry.for_key(key, port) background_thread = Thread( target=process_backend.launch, diff --git a/cellxgene_gateway/cache_entry.py b/cellxgene_gateway/cache_entry.py index 56c9616..2b1a429 100644 --- a/cellxgene_gateway/cache_entry.py +++ b/cellxgene_gateway/cache_entry.py @@ -8,20 +8,21 @@ # the specific language governing permissions and limitations under the License. import psutil import logging +import datetime -from flask import make_response, request +from flask import make_response, request, render_template from requests import get, post, put from cellxgene_gateway import env from cellxgene_gateway.cellxgene_exception import CellxgeneException from cellxgene_gateway.util import current_time_stamp +from cellxgene_gateway.flask_util import querystring class CacheEntry: def __init__( self, pid, - dataset, - file_path, + key, port, launchtime, timestamp, @@ -32,8 +33,7 @@ class CacheEntry: http_status, ): self.pid = pid - self.dataset = dataset - self.file_path = file_path + self.key = key self.port = port self.launchtime = launchtime self.timestamp = timestamp @@ -44,11 +44,11 @@ class CacheEntry: self.http_status = http_status @classmethod - def for_dataset(cls, dataset, file_path, port): + def for_key(cls, key, port): + return cls( None, - dataset, - file_path, + key, port, current_time_stamp(), current_time_stamp(), @@ -93,45 +93,62 @@ class CacheEntry: self.status = "terminated" def serve_content(self, path): - dataset = self.dataset - gateway_basepath = ( - f"{env.external_protocol}://{env.external_host}/view/{dataset}/" + f"{env.external_protocol}://{env.external_host}/view/{self.key.pathpart}/" ) - subpath = path[len(dataset) :] # noqa: E203 - + subpath = path[len(self.key.pathpart) :] # noqa: E203 + if len(subpath) == 0: r = make_response(f"Redirect to {gateway_basepath}\n", 301) - r.headers["location"] = gateway_basepath + r.headers["location"] = gateway_basepath+querystring() return r + elif self.status == "loading": + launch_time = datetime.datetime.fromtimestamp(self.launchtime) + return render_template( + "loading.html", launchtime=launch_time, all_output=self.all_output + ) port = self.port cellxgene_basepath = f"http://127.0.0.1:{port}" - headers = {} + copy_headers = [ + 'accept', + 'accept-encoding', + 'accept-language', + 'cache-control', + 'connection', + 'content-length', + 'content-type', + 'cookie', + 'host', + 'origin', + 'pragma', + 'referer', + 'sec-fetch-mode', + 'sec-fetch-site', + 'user-agent' + ] + for h in copy_headers: + if h in request.headers: + headers[h] = request.headers[h] - if "accept" in request.headers: - headers["accept"] = request.headers["accept"] - if "user-agent" in request.headers: - headers["user-agent"] = request.headers["user-agent"] - if "content-type" in request.headers: - headers["content-type"] = request.headers["content-type"] + full_path = cellxgene_basepath + subpath + querystring() if request.method in ["GET", "HEAD", "OPTIONS"]: cellxgene_response = get( - cellxgene_basepath + subpath, headers=headers + full_path, headers=headers ) elif request.method == "PUT": cellxgene_response = put( - cellxgene_basepath + subpath, + full_path, headers=headers, - data=request.data.decode(), + data=request.data, ) elif request.method == "POST": cellxgene_response = post( - cellxgene_basepath + subpath, + full_path, headers=headers, - data=request.data.decode(), + data=request.data, ) else: raise CellxgeneException( @@ -146,10 +163,15 @@ class CacheEntry: else: gateway_content = cellxgene_response.content + resp_headers = {} + for h in copy_headers: + if h in cellxgene_response.headers: + resp_headers[h] = cellxgene_response.headers[h] + gateway_response = make_response( gateway_content, cellxgene_response.status_code, - {"Content-Type": content_type}, + resp_headers, ) return gateway_response diff --git a/cellxgene_gateway/cache_key.py b/cellxgene_gateway/cache_key.py new file mode 100644 index 0000000..72c1810 --- /dev/null +++ b/cellxgene_gateway/cache_key.py @@ -0,0 +1,29 @@ +# Copyright 2019 Novartis Institutes for BioMedical Research Inc. Licensed +# under the Apache License, Version 2.0 (the "License"); you may not use +# this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless +# required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +# OR CONDITIONS OF ANY KIND, either express or implied. See the License for +# the specific language governing permissions and limitations under the License. + +import os + +from flask_api import status + +from cellxgene_gateway import env +from cellxgene_gateway.cellxgene_exception import CellxgeneException + +# There are three kinds of CacheKey: +# 1) somedir/dataset.h5ad: a dataset +# in this case, pathpart == dataset == 'somedir/dataset.h5ad' +# 2) somedir/dataset_annotations/saldaal1-T5HMVBNV.csv : an actual annotaitons file. +# in this case, pathpart == 'dataset_annotations/saldaal1-T5HMVBNV.csv', dataset == 'somedir/dataset.h5ad' +# 3) somedir/dataset_annotations: an annotation directory. The corresponding h5ad must exist, but the directory may not. +# in this case, pathpart == 'dataset_annotations', dataset == 'somedir/dataset.h5ad' + +class CacheKey: + def __init__(self, pathpart, dataset, annotation_file): + self.pathpart = pathpart + self.dataset = dataset + self.annotation_file = annotation_file diff --git a/cellxgene_gateway/dir_util.py b/cellxgene_gateway/dir_util.py index 05db382..4ddc1c7 100644 --- a/cellxgene_gateway/dir_util.py +++ b/cellxgene_gateway/dir_util.py @@ -51,45 +51,8 @@ def create_dir(parent_path, dir_name): else: os.mkdir(full_path) - -def recurse_dir(path): - if not os.path.exists(path): - raise CellxgeneException( - "The given path does not exist.", status.HTTP_400_BAD_REQUEST - ) - - def make_entry(el): - full_path = os.path.join(path, el) - if os.path.isfile(full_path): - return { - "path": full_path.replace(env.cellxgene_data, ""), - "name": el, - "type": "file", - } - elif os.path.isdir(full_path): - return { - "path": full_path.replace(env.cellxgene_data, ""), - "name": el, - "type": "directory", - "children": recurse_dir(full_path), - } - else: - raise CellxgeneException( - "Given path is neither file nor directory.", - status.HTTP_400_BAD_REQUEST, - ) - - return [make_entry(x) for x in os.listdir(path)] - - -def render_entries(entries): - return "" - - -def render_entry(entry): - if entry["type"] == "file": - url = f"/view/{entry['path'].lstrip('/')}" - return f"
  • {entry['name']}
  • " - elif entry["type"] == "directory": - url = f"/filecrawl/{entry['path'].lstrip('/')}" - return f"
  • {entry['name']}{render_entries(entry['children'])}
  • " +annotations_suffix = '_annotations' +def make_h5ad(el): + return el[:-len(annotations_suffix)]+'.h5ad' +def make_annotations(el): + return el[:-5]+annotations_suffix diff --git a/cellxgene_gateway/env.py b/cellxgene_gateway/env.py index cf397a1..5e8a6de 100644 --- a/cellxgene_gateway/env.py +++ b/cellxgene_gateway/env.py @@ -20,6 +20,7 @@ ip = os.environ.get("GATEWAY_IP") extra_scripts = os.environ.get("GATEWAY_EXTRA_SCRIPTS") ttl = os.environ.get("GATEWAY_TTL") enable_upload = os.environ.get("GATEWAY_ENABLE_UPLOAD", "").lower() in ['true', '1'] +enable_annotations = os.environ.get("GATEWAY_ENABLE_ANNOTATIONS", "").lower() in ['true', '1'] env_vars = { "CELLXGENE_LOCATION": cellxgene_location, @@ -34,6 +35,7 @@ optional_env_vars = { "GATEWAY_EXTRA_SCRIPTS": extra_scripts, "GATEWAY_TTL": ttl, "GATEWAY_ENABLE_UPLOAD": enable_upload, + "GATEWAY_ENABLE_ANNOTATIONS": enable_annotations, } def validate(): diff --git a/cellxgene_gateway/filecrawl.py b/cellxgene_gateway/filecrawl.py new file mode 100644 index 0000000..57f0c41 --- /dev/null +++ b/cellxgene_gateway/filecrawl.py @@ -0,0 +1,75 @@ +import os +from cellxgene_gateway import env +from cellxgene_gateway.dir_util import make_h5ad, make_annotations, annotations_suffix + +def recurse_dir(path): + if not os.path.exists(path): + raise CellxgeneException( + "The given path does not exist.", status.HTTP_400_BAD_REQUEST + ) + + all_entries = os.listdir(path) + def is_h5ad(el): + return el.endswith('.h5ad') and os.path.isfile(os.path.join(path, el)) + h5ad_entries = [x for x in all_entries if is_h5ad(x)] + annotation_dir_entries = [x for x in all_entries if x.endswith(annotations_suffix) and make_h5ad(x) in h5ad_entries] + def list_annotations(el): + full_path = os.path.join(path, el) + if not os.path.isdir(full_path): + entries = [] + else: + entries = [{ + "name": x[:-13] if (len(x) > 13 and x[-13] in ['-','_']) else ( + x[:-4] if x.endswith('.csv') else x), + "path": os.path.join(full_path, x).replace(env.cellxgene_data, ""), + } for x in os.listdir(full_path) if x.endswith('.csv') and os.path.isfile(os.path.join(full_path, x))] + return [{"name":'new', "class":'new', "path":full_path.replace(env.cellxgene_data, "")}] + entries + + def make_entry(el): + full_path = os.path.join(path, el) + if el in h5ad_entries: + return { + "path": full_path.replace(env.cellxgene_data, ""), + "name": el, + "type": "file", + "annotations": list_annotations(make_annotations(el)), + } + elif os.path.isdir(full_path) and el not in annotation_dir_entries: + return { + "path": full_path.replace(env.cellxgene_data, ""), + "name": el, + "type": "directory", + "children": recurse_dir(full_path), + } + else: + return { + "path": full_path, + "name": el, + "type": "neither", + } + + return [make_entry(x) for x in os.listdir(path)] + + +def render_entries(entries): + return "" + +def get_url(entry): + return f"/view/{ entry['path'].lstrip('/') }" +def get_class(entry): + return f" class='{entry['class']}'" if 'class' in entry else '' + +def render_annotations(entry): + if len(entry['annotations']) > 0: + return ' | annotations: ' + ", ".join([f"{a['name']}" for a in entry['annotations']]) + else: + return '' + +def render_entry(entry): + if entry["type"] == "file": + return f"
  • {entry['name']} {render_annotations(entry)}
  • " + elif entry["type"] == "directory": + url = f"/filecrawl/{entry['path'].lstrip('/')}" + return f"
  • {entry['name']}{render_entries(entry['children'])}
  • " + else: + return "" diff --git a/cellxgene_gateway/flask_util.py b/cellxgene_gateway/flask_util.py new file mode 100644 index 0000000..9727220 --- /dev/null +++ b/cellxgene_gateway/flask_util.py @@ -0,0 +1,5 @@ +from flask import request + +def querystring(): + qs = request.query_string.decode() + return f'?{qs}' if len(qs) > 0 else '' diff --git a/cellxgene_gateway/gateway.py b/cellxgene_gateway/gateway.py index 28187e2..e34e181 100644 --- a/cellxgene_gateway/gateway.py +++ b/cellxgene_gateway/gateway.py @@ -8,7 +8,6 @@ # the specific language governing permissions and limitations under the License. # import BaseHTTPServer -import datetime import os import logging from threading import Thread, Lock @@ -17,6 +16,7 @@ import json from flask import ( Flask, redirect, + make_response, render_template, request, send_from_directory, @@ -28,14 +28,23 @@ from werkzeug import secure_filename from cellxgene_gateway import env from cellxgene_gateway.backend_cache import BackendCache from cellxgene_gateway.cellxgene_exception import CellxgeneException -from cellxgene_gateway.dir_util import create_dir, recurse_dir, render_entries, is_subdir +from cellxgene_gateway.dir_util import create_dir, is_subdir +from cellxgene_gateway.filecrawl import recurse_dir, render_entries from cellxgene_gateway.extra_scripts import get_extra_scripts -from cellxgene_gateway.path_util import get_dataset, get_file_path from cellxgene_gateway.process_exception import ProcessException from cellxgene_gateway.prune_process_cache import PruneProcessCache from cellxgene_gateway.util import current_time_stamp +from cellxgene_gateway.path_util import get_key app = Flask(__name__) + +def _force_https(app): + def wrapper(environ, start_response): + environ['wsgi.url_scheme'] = env.external_protocol + return app(environ, start_response) + return wrapper +app.wsgi_app = _force_https(app.wsgi_app) + cache = BackendCache() location = f"{env.external_protocol}://{env.external_host}" @@ -73,7 +82,8 @@ def handle_invalid_process(error): http_status=error.http_status, stdout=error.stdout, stderr=error.stderr, - dataset=error.dataset, + dataset=error.key.dataset, + annotation_file=error.key.annotation_file, ), error.http_status, ) @@ -149,7 +159,7 @@ def upload_file(): "Invalid directory.", status.HTTP_400_BAD_REQUEST ) - return redirect(env.location, code=302) + return redirect(location, code=302) if env.enable_upload: @@ -159,14 +169,18 @@ if env.enable_upload: @app.route("/filecrawl.html") def filecrawl(): - entries = recurse_dir(env.cellxgene_data) rendered_html = render_entries(entries) - return render_template( + resp = make_response(render_template( "filecrawl.html", extra_scripts=get_extra_scripts(), rendered_html=rendered_html, - ) + )) + resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + resp.headers["Pragma"] = "no-cache" + resp.headers["Expires"] = "0" + resp.headers['Cache-Control'] = 'public, max-age=0' + return resp @app.route("/filecrawl/") def do_filecrawl(path): @@ -187,23 +201,18 @@ def do_filecrawl(path): entry_lock = Lock() @app.route("/view/", methods=["GET", "PUT", "POST"]) def do_view(path): - dataset = get_dataset(path) - file_path = get_file_path(dataset) + key = get_key(path) + print(f"view path={path}, dataset={key.dataset}, annotation_file= {key.annotation_file}, key={key.pathpart}") with entry_lock: - match = cache.check_entry(dataset) + match = cache.check_entry(key) if match is None: uascripts = get_extra_scripts() - match = cache.create_entry(dataset, file_path, uascripts) + match = cache.create_entry(key, uascripts) match.timestamp = current_time_stamp() - if match.status == "loaded": + if match.status == "loaded" or match.status == "loading": return match.serve_content(path) - elif match.status == "loading": - launch_time = datetime.datetime.fromtimestamp(match.launchtime) - return render_template( - "loading.html", launchtime=launch_time, all_output=match.all_output - ) elif match.status == "error": raise ProcessException.from_cache_entry(match) @@ -216,7 +225,8 @@ def do_GET_status(): def do_GET_status_json(): return json.dumps({'launchtime':app.launchtime, 'entry_list':[{ - 'dataset': entry.dataset, + 'dataset': entry.key.dataset, + 'annotation_file': entry.key.annotation_file, 'launchtime': entry.launchtime, 'last_access': entry.timestamp, 'status': entry.status @@ -224,16 +234,17 @@ def do_GET_status_json(): @app.route("/relaunch/", methods=["GET"]) def do_relaunch(path): - dataset = get_dataset(path) - match = cache.check_entry(dataset) + key = get_key(path) + match = cache.check_entry(key) if not match is None: match.terminate() - return redirect(url_for("do_view", path=path), code=302) + qs = request.query_string.decode() + return redirect(url_for("do_view", path=path) + (f'?{qs}' if len(qs) > 0 else ''), code=302) @app.route("/terminate/", methods=["GET"]) def do_terminate(path): - dataset = get_dataset(path) - match = cache.check_entry(dataset) + key = get_key(path) + match = cache.check_entry(key) if not match is None: match.terminate() return redirect(url_for("do_GET_status"), code=302) diff --git a/cellxgene_gateway/path_util.py b/cellxgene_gateway/path_util.py index 6dc4fb6..08e5ccf 100644 --- a/cellxgene_gateway/path_util.py +++ b/cellxgene_gateway/path_util.py @@ -13,37 +13,83 @@ from flask_api import status from cellxgene_gateway import env from cellxgene_gateway.cellxgene_exception import CellxgeneException +from cellxgene_gateway.dir_util import make_h5ad +from cellxgene_gateway.cache_key import CacheKey - -def get_dataset(path): +def get_key(path): if path == "/" or path == "": raise CellxgeneException( "No matching dataset found.", status.HTTP_404_NOT_FOUND ) trimmed = path[:-1] if path[-1] == "/" else path - try: - get_file_path(trimmed) - return trimmed + # valid paths come in three forms: + if trimmed.endswith('.h5ad') and data_file_exists(trimmed): + # 1) somedir/dataset.h5ad: a dataset + return CacheKey(trimmed, trimmed, None) + elif trimmed.endswith('.csv'): + + # 2) somedir/dataset_annotations/saldaal1-T5HMVBNV.csv : an actual annotations file. + annotations_dir = os.path.split(trimmed)[0] + dataset = make_h5ad(annotations_dir) + if data_file_exists(dataset): + data_dir_ensure(annotations_dir) + return CacheKey(trimmed, dataset, trimmed) + elif trimmed.endswith('_annotations') and data_dir_exists(trimmed): + # 3) somedir/dataset_annotations: an annotation directory. The corresponding h5ad must exist, but the directory may not. + dataset = make_h5ad(trimmed) + if data_file_exists(dataset): + return CacheKey(trimmed, dataset, '') except CellxgeneException: - split = os.path.split(trimmed) - return get_dataset(split[0]) + pass + split = os.path.split(trimmed) + return get_key(split[0]) - -def validate_path(file_path): +def validate_exists(file_path): if not os.path.exists(file_path): raise CellxgeneException( "File does not exist: " + file_path, status.HTTP_400_BAD_REQUEST ) + +def validate_is_file(file_path): + validate_exists(file_path) if not os.path.isfile(file_path): raise CellxgeneException( "Path is not file: " + file_path, status.HTTP_400_BAD_REQUEST ) return +def validate_is_dir(file_path): + validate_exists(file_path) + if not os.path.isdir(file_path): + raise CellxgeneException( + "Path is not dir: " + file_path, status.HTTP_400_BAD_REQUEST + ) + return - -def get_file_path(dataset): +def data_file_exists(dataset): file_path = os.path.join(env.cellxgene_data, dataset) - validate_path(file_path) + validate_is_file(file_path) + return True +def data_dir_exists(dataset): + file_path = os.path.join(env.cellxgene_data, dataset) + validate_is_dir(file_path) + return True +def data_dir_ensure(dataset): + file_path = os.path.join(env.cellxgene_data, dataset) + if not os.path.exists(file_path): + os.makedirs(file_path) + +def get_file_path(key): + dataset = key.dataset + file_path = os.path.join(env.cellxgene_data, dataset) + validate_is_file(file_path) + return file_path + +def get_annotation_file_path(key): + if key.annotation_file is None: + return None + if key.annotation_file == '': + return '' + file_path = os.path.join(env.cellxgene_data, key.annotation_file) return file_path diff --git a/cellxgene_gateway/process_exception.py b/cellxgene_gateway/process_exception.py index a90f35d..6e6ea36 100644 --- a/cellxgene_gateway/process_exception.py +++ b/cellxgene_gateway/process_exception.py @@ -15,7 +15,7 @@ class ProcessException(Exception): self.stdout = stdout self.stderr = stderr self.http_status = http_status - self.dataset = dataset + self.key = key @classmethod def from_cache_entry(cls, cache_entry): @@ -24,5 +24,5 @@ class ProcessException(Exception): cache_entry.all_output, cache_entry.stderr, cache_entry.http_status, - cache_entry.dataset, + cache_entry.key, ) diff --git a/cellxgene_gateway/prune_process_cache.py b/cellxgene_gateway/prune_process_cache.py index ca89788..3bb8f2b 100644 --- a/cellxgene_gateway/prune_process_cache.py +++ b/cellxgene_gateway/prune_process_cache.py @@ -34,7 +34,7 @@ class PruneProcessCache: for process in processes_to_delete: try: - logger.info(f"pruning process {process.pid} ({process.dataset})") + logger.info(f"pruning process {process.pid} ({process.key.dataset})") self.cache.prune(process) except Exception: logger.exception("failed to prune process {process.pid} ({process.dataset})") diff --git a/cellxgene_gateway/static/js/annotation.js b/cellxgene_gateway/static/js/annotation.js new file mode 100644 index 0000000..73c1739 --- /dev/null +++ b/cellxgene_gateway/static/js/annotation.js @@ -0,0 +1,19 @@ +// neandertal javascript +const new_annotation_callback = (() =>{ + const suffix = `.csv`; + return (e) => { + e.preventDefault(); + const el = $(e.target); + const href = el.attr('href'); + const base = prompt(`Name your annotations collection\nnote: the suffix "${suffix}" will be appended`); + if (base !== null && base.length > 0) { + if (/^[0-9a-zA-Z_]+$/.test(base)) { + window.location = `${href}/${base}${suffix}`; + } else { + alert("Error: name must match ^[0-9a-zA-Z_]+$\nthat is, only numbers, letters and underscore are allowed") + } + } + return false; + } +})() + diff --git a/cellxgene_gateway/subprocess_backend.py b/cellxgene_gateway/subprocess_backend.py index 3948da0..b2d6a36 100644 --- a/cellxgene_gateway/subprocess_backend.py +++ b/cellxgene_gateway/subprocess_backend.py @@ -11,21 +11,30 @@ import logging import subprocess from flask_api import status - +from cellxgene_gateway.env import enable_annotations from cellxgene_gateway.process_exception import ProcessException - +from cellxgene_gateway.dir_util import make_annotations +from cellxgene_gateway.path_util import get_file_path, get_annotation_file_path class SubprocessBackend: def __init__(self): pass - def create_cmd(self, cellxgene_loc, file_path, port, scripts): - + def create_cmd(self, cellxgene_loc, file_path, port, scripts, annotation_file_path): + if enable_annotations and not annotation_file_path is None: + annotation_args_prefix = " --experimental-annotations" + if annotation_file_path == "": + annotation_args = f"{annotation_args_prefix} --experimental-annotations-output-dir {make_annotations(file_path)}" + else: + annotation_args = f"{annotation_args_prefix} --experimental-annotations-file {annotation_file_path}" + else: + annotation_args = "" cmd = ( f"yes | {cellxgene_loc} launch {file_path}" + " --port " + str(port) + " --host 127.0.0.1" + + annotation_args ) for s in scripts: @@ -36,7 +45,7 @@ class SubprocessBackend: def launch(self, cellxgene_loc, scripts, cache_entry): cmd = self.create_cmd( - cellxgene_loc, cache_entry.file_path, cache_entry.port, scripts + cellxgene_loc, get_file_path(cache_entry.key), cache_entry.port, scripts, get_annotation_file_path(cache_entry.key) ) logging.getLogger("cellxgene_gateway").info(f"launching {cmd}") process = subprocess.Popen( diff --git a/cellxgene_gateway/templates/cache_status.html b/cellxgene_gateway/templates/cache_status.html index aad3813..34b6f52 100644 --- a/cellxgene_gateway/templates/cache_status.html +++ b/cellxgene_gateway/templates/cache_status.html @@ -29,6 +29,7 @@ PID dataset + annotation_file port launchtime last access @@ -42,7 +43,8 @@ {% for entry in entry_list %} {{ entry.pid }} - {{ entry.dataset }} + {{ entry.key.dataset }} + {{ entry.key.annotation_file }} {{ entry.port }} {{ entry.launchtime }} {{ entry.timestamp }} @@ -51,7 +53,7 @@ {{ entry.http_status }} {% if entry.status == 'loaded' %} - terminate + terminate {% endif %} diff --git a/cellxgene_gateway/templates/filecrawl.html b/cellxgene_gateway/templates/filecrawl.html index d752163..7d59e34 100644 --- a/cellxgene_gateway/templates/filecrawl.html +++ b/cellxgene_gateway/templates/filecrawl.html @@ -16,7 +16,8 @@ {% for script in extra_scripts %} - {% endfor %} + {% endfor %} + @@ -27,11 +28,9 @@

    Cellxgene Gateway - FILE CRAWLER

    {% endif %} -

    Please click on a dataset to view it in Cellxgene Server.

    -
    {{ rendered_html|safe }}

    Navigation: @@ -43,5 +42,10 @@

  • homepage
  • +