diff --git a/.yamllint.yml b/.yamllint.yml index 63e23f3bd..4052f936c 100644 --- a/.yamllint.yml +++ b/.yamllint.yml @@ -8,11 +8,13 @@ yaml-files: ignore: | .lint-venv/ + build/ components/core/build/ components/core/cmake-build-debug/ components/core/cmake-build-release/ components/core/submodules/ components/core/third-party/ + components/webui/node_modules/ rules: anchors: diff --git a/Taskfile.yml b/Taskfile.yml index 7b698c0a4..6020f290d 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -4,11 +4,14 @@ vars: BUILD_DIR: "{{.TASKFILE_DIR}}/build" CORE_COMPONENT_BUILD_DIR: "{{.TASKFILE_DIR}}/build/core" LINT_VENV_DIR: "{{.TASKFILE_DIR}}/.lint-venv" + NODEJS_BIN_DIR: "{{.TASKFILE_DIR}}/build/nodejs/node/bin" + NODEJS_BUILD_DIR: "{{.TASKFILE_DIR}}/build/nodejs" PACKAGE_BUILD_DIR: "{{.TASKFILE_DIR}}/build/clp-package" PACKAGE_VENV_DIR: "{{.TASKFILE_DIR}}/build/package-venv" PACKAGE_VERSION: "0.0.3-dev" PYTHON_VERSION: sh: "python3 -c \"import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')\"" + WEBUI_BUILD_DIR: "{{.TASKFILE_DIR}}/build/webui" tasks: default: @@ -60,6 +63,7 @@ tasks: - "clp-py-utils" - "job-orchestration" - "package-venv" + - "webui" cmds: - task: "clean-package" - "mkdir -p '{{.PACKAGE_BUILD_DIR}}'" @@ -79,7 +83,13 @@ tasks: "{{.CORE_COMPONENT_BUILD_DIR}}/clo" "{{.CORE_COMPONENT_BUILD_DIR}}/clp" "{{.CORE_COMPONENT_BUILD_DIR}}/clp-s" + "{{.NODEJS_BIN_DIR}}/node" "{{.PACKAGE_BUILD_DIR}}/bin/" + - "mkdir -p '{{.PACKAGE_BUILD_DIR}}/var/www/'" + - >- + rsync -a --delete + "{{.WEBUI_BUILD_DIR}}/" + "{{.PACKAGE_BUILD_DIR}}/var/www/" # This step must be last since we use this file to detect whether the package was built # successfully - "echo {{.PACKAGE_VERSION}} > '{{.PACKAGE_VERSION_FILE}}'" @@ -90,6 +100,7 @@ tasks: - "{{.CORE_COMPONENT_BUILD_DIR}}/clp" - "{{.CORE_COMPONENT_BUILD_DIR}}/clp-s" - "{{.TASKFILE_DIR}}/Taskfile.yml" + - "{{.WEBUI_BUILD_DIR}}/**/*" - "components/clp-package-utils/dist/*.whl" - "components/clp-py-utils/dist/*.whl" - "components/job-orchestration/dist/*.whl" @@ -143,6 +154,53 @@ tasks: vars: COMPONENT: "{{.TASK}}" + nodejs: + vars: + NODEJS_VERSION: "14.21.3" + TAR_FILE_NAME: "node-v14.21.3-linux-x64.tar.xz" + TAR_FILE_PATH: "{{.NODEJS_BUILD_DIR}}/node-v14.21.3-linux-x64.tar.xz" + cmds: + - "rm -rf '{{.NODEJS_BUILD_DIR}}/node'" + - "mkdir -p '{{.NODEJS_BUILD_DIR}}'" + - >- + curl -fsSL + "https://nodejs.org/dist/v{{.NODEJS_VERSION}}/{{.TAR_FILE_NAME}}" + -o "{{.NODEJS_BUILD_DIR}}/{{.TAR_FILE_NAME}}" + - "tar xf '{{.NODEJS_BUILD_DIR}}/{{.TAR_FILE_NAME}}' -C '{{.NODEJS_BUILD_DIR}}'" + - >- + mv "{{.NODEJS_BUILD_DIR}}/node-v{{.NODEJS_VERSION}}-linux-x64" "{{.NODEJS_BUILD_DIR}}/node" + - "rm -f '{{.NODEJS_BUILD_DIR}}/{{.TAR_FILE_NAME}}'" + sources: + - "{{.NODEJS_BUILD_DIR}}/node/**/*" + + webui: + deps: + - "nodejs" + dir: "components/webui" + cmds: + - "rm -rf '{{.WEBUI_BUILD_DIR}}'" + - "mkdir -p '{{.WEBUI_BUILD_DIR}}'" + - "meteor npm install --production" + - "meteor build --directory '{{.WEBUI_BUILD_DIR}}'" + - >- + rsync -a + "{{.WEBUI_BUILD_DIR}}/bundle/" + launcher.js + settings.json + "{{.WEBUI_BUILD_DIR}}/" + - "rm -rf '{{.WEBUI_BUILD_DIR}}/bundle/'" + - |- + cd "{{.WEBUI_BUILD_DIR}}/programs/server" + PATH="{{.NODEJS_BIN_DIR}}":$PATH $(readlink -f "{{.NODEJS_BIN_DIR}}/npm") install + sources: + - "./.meteor/*" + - "./client/**/*" + - "./imports/**/*" + - "./server/**/*" + - "./tests/**/*" + - "./*" + - "{{.WEBUI_BUILD_DIR}}/**/*" + lint-check: cmds: - task: "core-lint-check" diff --git a/components/clp-package-utils/clp_package_utils/general.py b/components/clp-package-utils/clp_package_utils/general.py index 811147119..25448d55d 100644 --- a/components/clp-package-utils/clp_package_utils/general.py +++ b/components/clp-package-utils/clp_package_utils/general.py @@ -15,6 +15,7 @@ QUEUE_COMPONENT_NAME, REDIS_COMPONENT_NAME, RESULTS_CACHE_COMPONENT_NAME, + WEBUI_COMPONENT_NAME, ) from clp_py_utils.core import ( get_config_value, @@ -351,3 +352,19 @@ def validate_results_cache_config( def validate_worker_config(clp_config: CLPConfig): clp_config.validate_input_logs_dir() clp_config.validate_archive_output_dir() + + +def validate_webui_config( + clp_config: CLPConfig, logs_dir: pathlib.Path, settings_json_path: pathlib.Path +): + if not settings_json_path.exists(): + raise ValueError( + f"{WEBUI_COMPONENT_NAME} {settings_json_path} is not a valid path to Meteor settings.json" + ) + + try: + validate_path_could_be_dir(logs_dir) + except ValueError as ex: + raise ValueError(f"{WEBUI_COMPONENT_NAME} logs directory is invalid: {ex}") + + validate_port(f"{WEBUI_COMPONENT_NAME}.port", clp_config.webui.host, clp_config.webui.port) diff --git a/components/clp-package-utils/clp_package_utils/scripts/start_clp.py b/components/clp-package-utils/clp_package_utils/scripts/start_clp.py index a1e540a0d..b97c0e152 100755 --- a/components/clp-package-utils/clp_package_utils/scripts/start_clp.py +++ b/components/clp-package-utils/clp_package_utils/scripts/start_clp.py @@ -1,4 +1,5 @@ import argparse +import json import logging import multiprocessing import os @@ -7,10 +8,12 @@ import subprocess import sys import time +import typing import uuid import yaml from clp_py_utils.clp_config import ( + CLP_METADATA_TABLE_PREFIX, CLPConfig, COMPRESSION_SCHEDULER_COMPONENT_NAME, COMPRESSION_WORKER_COMPONENT_NAME, @@ -18,8 +21,10 @@ QUEUE_COMPONENT_NAME, REDIS_COMPONENT_NAME, RESULTS_CACHE_COMPONENT_NAME, + SEARCH_JOBS_TABLE_NAME, SEARCH_SCHEDULER_COMPONENT_NAME, SEARCH_WORKER_COMPONENT_NAME, + WEBUI_COMPONENT_NAME, ) from job_orchestration.scheduler.constants import QueueName from pydantic import BaseModel @@ -42,6 +47,7 @@ validate_queue_config, validate_redis_config, validate_results_cache_config, + validate_webui_config, validate_worker_config, ) @@ -622,6 +628,104 @@ def generic_start_worker( logger.info(f"Started {component_name}.") +def update_meteor_settings( + parent_key_prefix: str, + settings: typing.Dict[str, typing.Any], + updates: typing.Dict[str, typing.Any], +): + """ + Recursively updates the given Meteor settings object with the values from `updates`. + + :param parent_key_prefix: The prefix for keys at this level in the settings dictionary. + :param settings: The settings to update. + :param updates: The updates. + :raises ValueError: If a key in `updates` doesn't exist in `settings`. + """ + for key, value in updates.items(): + if key not in settings: + error_msg = f"{parent_key_prefix}{key} is not a valid configuration key for the webui." + raise ValueError(error_msg) + if isinstance(value, dict): + update_meteor_settings(f"{parent_key_prefix}{key}.", settings[key], value) + else: + settings[key] = updates[key] + + +def start_webui(instance_id: str, clp_config: CLPConfig, mounts: CLPDockerMounts): + logger.info(f"Starting {WEBUI_COMPONENT_NAME}...") + + container_name = f"clp-{WEBUI_COMPONENT_NAME}-{instance_id}" + if container_exists(container_name): + logger.info(f"{WEBUI_COMPONENT_NAME} already running.") + return + + webui_logs_dir = clp_config.logs_directory / WEBUI_COMPONENT_NAME + node_path = str( + CONTAINER_CLP_HOME / "var" / "www" / "programs" / "server" / "npm" / "node_modules" + ) + settings_json_path = get_clp_home() / "var" / "www" / "settings.json" + + validate_webui_config(clp_config, webui_logs_dir, settings_json_path) + + # Create directories + webui_logs_dir.mkdir(exist_ok=True, parents=True) + + container_webui_logs_dir = pathlib.Path("/") / "var" / "log" / WEBUI_COMPONENT_NAME + with open(settings_json_path, "r") as settings_json_file: + meteor_settings = json.loads(settings_json_file.read()) + meteor_settings_updates = { + "private": { + "SqlDbHost": clp_config.database.host, + "SqlDbPort": clp_config.database.port, + "SqlDbName": clp_config.database.name, + "SqlDbSearchJobsTableName": SEARCH_JOBS_TABLE_NAME, + "SqlDbClpArchivesTableName": f"{CLP_METADATA_TABLE_PREFIX}archives", + "SqlDbClpFilesTableName": f"{CLP_METADATA_TABLE_PREFIX}files", + } + } + update_meteor_settings("", meteor_settings, meteor_settings_updates) + + # Start container + # fmt: off + container_cmd = [ + "docker", "run", + "-d", + "--network", "host", + "--rm", + "--name", container_name, + "-e", f"NODE_PATH={node_path}", + "-e", f"MONGO_URL={clp_config.results_cache.get_uri()}", + "-e", f"PORT={clp_config.webui.port}", + "-e", f"ROOT_URL=http://{clp_config.webui.host}", + "-e", f"METEOR_SETTINGS={json.dumps(meteor_settings)}", + "-e", f"CLP_DB_USER={clp_config.database.username}", + "-e", f"CLP_DB_PASS={clp_config.database.password}", + "-e", f"WEBUI_LOGS_DIR={container_webui_logs_dir}", + "-e", f"WEBUI_LOGGING_LEVEL={clp_config.webui.logging_level}", + "-u", f"{os.getuid()}:{os.getgid()}", + ] + # fmt: on + necessary_mounts = [ + mounts.clp_home, + DockerMount(DockerMountType.BIND, webui_logs_dir, container_webui_logs_dir), + ] + for mount in necessary_mounts: + if mount: + container_cmd.append("--mount") + container_cmd.append(str(mount)) + container_cmd.append(clp_config.execution_container) + + node_cmd = [ + str(CONTAINER_CLP_HOME / "bin" / "node"), + str(CONTAINER_CLP_HOME / "var" / "www" / "launcher.js"), + str(CONTAINER_CLP_HOME / "var" / "www" / "main.js"), + ] + cmd = container_cmd + node_cmd + subprocess.run(cmd, stdout=subprocess.DEVNULL, check=True) + + logger.info(f"Started {WEBUI_COMPONENT_NAME}.") + + def main(argv): clp_home = get_clp_home() default_config_file_path = clp_home / CLP_DEFAULT_CONFIG_FILE_RELATIVE_PATH @@ -643,6 +747,8 @@ def main(argv): component_args_parser.add_parser(SEARCH_SCHEDULER_COMPONENT_NAME) component_args_parser.add_parser(COMPRESSION_WORKER_COMPONENT_NAME) component_args_parser.add_parser(SEARCH_WORKER_COMPONENT_NAME) + component_args_parser.add_parser(WEBUI_COMPONENT_NAME) + args_parser.add_argument( "--num-cpus", type=int, @@ -676,6 +782,7 @@ def main(argv): DB_COMPONENT_NAME, COMPRESSION_SCHEDULER_COMPONENT_NAME, SEARCH_SCHEDULER_COMPONENT_NAME, + WEBUI_COMPONENT_NAME, ]: validate_and_load_db_credentials_file(clp_config, clp_home, True) if component_name in [ @@ -751,6 +858,8 @@ def main(argv): ) if "" == component_name or SEARCH_WORKER_COMPONENT_NAME == component_name: start_search_worker(instance_id, clp_config, container_clp_config, num_cpus, mounts) + if "" == component_name or WEBUI_COMPONENT_NAME == component_name: + start_webui(instance_id, clp_config, mounts) except Exception as ex: # Stop CLP diff --git a/components/clp-package-utils/clp_package_utils/scripts/stop_clp.py b/components/clp-package-utils/clp_package_utils/scripts/stop_clp.py index a687365e2..b410472b9 100755 --- a/components/clp-package-utils/clp_package_utils/scripts/stop_clp.py +++ b/components/clp-package-utils/clp_package_utils/scripts/stop_clp.py @@ -13,6 +13,7 @@ RESULTS_CACHE_COMPONENT_NAME, SEARCH_SCHEDULER_COMPONENT_NAME, SEARCH_WORKER_COMPONENT_NAME, + WEBUI_COMPONENT_NAME, ) from clp_package_utils.general import ( @@ -66,6 +67,7 @@ def main(argv): component_args_parser.add_parser(SEARCH_SCHEDULER_COMPONENT_NAME) component_args_parser.add_parser(COMPRESSION_WORKER_COMPONENT_NAME) component_args_parser.add_parser(SEARCH_WORKER_COMPONENT_NAME) + component_args_parser.add_parser(WEBUI_COMPONENT_NAME) parsed_args = args_parser.parse_args(argv[1:]) @@ -107,6 +109,8 @@ def main(argv): with open(instance_id_file_path, "r") as f: instance_id = f.readline() + if "" == component_name or WEBUI_COMPONENT_NAME == component_name: + stop_container(f"clp-{WEBUI_COMPONENT_NAME}-{instance_id}") if "" == component_name or SEARCH_WORKER_COMPONENT_NAME == component_name: stop_container(f"clp-{SEARCH_WORKER_COMPONENT_NAME}-{instance_id}") if "" == component_name or COMPRESSION_WORKER_COMPONENT_NAME == component_name: diff --git a/components/clp-py-utils/clp_py_utils/clp_config.py b/components/clp-py-utils/clp_py_utils/clp_config.py index 0c5c3d2b7..444c82403 100644 --- a/components/clp-py-utils/clp_py_utils/clp_config.py +++ b/components/clp-py-utils/clp_py_utils/clp_config.py @@ -23,6 +23,7 @@ SEARCH_SCHEDULER_COMPONENT_NAME = "search_scheduler" COMPRESSION_WORKER_COMPONENT_NAME = "compression_worker" SEARCH_WORKER_COMPONENT_NAME = "search_worker" +WEBUI_COMPONENT_NAME = "webui" SEARCH_JOBS_TABLE_NAME = "search_jobs" COMPRESSION_JOBS_TABLE_NAME = "compression_jobs" @@ -207,6 +208,12 @@ def validate_host(cls, field): raise ValueError(f"{RESULTS_CACHE_COMPONENT_NAME}.host cannot be empty.") return field + @validator("db_name") + def validate_db_name(cls, field): + if "" == field: + raise ValueError(f"{RESULTS_CACHE_COMPONENT_NAME}.db_name cannot be empty.") + return field + def get_uri(self): return f"mongodb://{self.host}:{self.port}/{self.db_name}" @@ -260,6 +267,34 @@ def dump_to_primitive_dict(self): return d +class WebUi(BaseModel): + host: str = "localhost" + port: int = 4000 + logging_level: str = "INFO" + + @validator("host") + def validate_host(cls, field): + if "" == field: + raise ValueError(f"{WEBUI_COMPONENT_NAME}.host cannot be empty.") + return field + + @validator("port") + def validate_port(cls, field): + min_valid_port = 0 + max_valid_port = 2**16 - 1 + if min_valid_port > field or max_valid_port < field: + raise ValueError( + f"{WEBUI_COMPONENT_NAME}.port is not within valid range " + f"{min_valid_port}-{max_valid_port}." + ) + return field + + @validator("logging_level") + def validate_logging_level(cls, field): + _validate_logging_level(cls, field) + return field + + class CLPConfig(BaseModel): execution_container: str = "ghcr.io/y-scope/clp/clp-execution-x86-ubuntu-focal:main" @@ -274,6 +309,7 @@ class CLPConfig(BaseModel): search_scheduler: SearchScheduler = SearchScheduler() compression_worker: CompressionWorker = CompressionWorker() search_worker: SearchWorker = SearchWorker() + webui: WebUi = WebUi() credentials_file_path: pathlib.Path = CLP_DEFAULT_CREDENTIALS_FILE_PATH archive_output: ArchiveOutput = ArchiveOutput() diff --git a/components/package-template/src/etc/clp-config.yml b/components/package-template/src/etc/clp-config.yml index 6c2edd3fe..5b61a7573 100644 --- a/components/package-template/src/etc/clp-config.yml +++ b/components/package-template/src/etc/clp-config.yml @@ -44,6 +44,11 @@ #search_worker: # logging_level: "INFO" # +#webui: +# host: "localhost" +# port: 4000 +# logging_level: "INFO" +# ## Where archives should be output to #archive_output: # directory: "var/data/archives" diff --git a/components/webui/.gitignore b/components/webui/.gitignore new file mode 100644 index 000000000..c40aa38d0 --- /dev/null +++ b/components/webui/.gitignore @@ -0,0 +1,2 @@ +docs/ +node_modules/ diff --git a/components/webui/.meteor/.finished-upgraders b/components/webui/.meteor/.finished-upgraders new file mode 100644 index 000000000..c07b6ff75 --- /dev/null +++ b/components/webui/.meteor/.finished-upgraders @@ -0,0 +1,19 @@ +# This file contains information which helps Meteor properly upgrade your +# app when you run 'meteor update'. You should check it into version control +# with your project. + +notices-for-0.9.0 +notices-for-0.9.1 +0.9.4-platform-file +notices-for-facebook-graph-api-2 +1.2.0-standard-minifiers-package +1.2.0-meteor-platform-split +1.2.0-cordova-changes +1.2.0-breaking-changes +1.3.0-split-minifiers-package +1.4.0-remove-old-dev-bundle-link +1.4.1-add-shell-server-package +1.4.3-split-account-service-packages +1.5-add-dynamic-import-package +1.7-split-underscore-from-meteor-base +1.8.3-split-jquery-from-blaze diff --git a/components/webui/.meteor/.gitignore b/components/webui/.meteor/.gitignore new file mode 100644 index 000000000..408303742 --- /dev/null +++ b/components/webui/.meteor/.gitignore @@ -0,0 +1 @@ +local diff --git a/components/webui/.meteor/.id b/components/webui/.meteor/.id new file mode 100644 index 000000000..0428be3da --- /dev/null +++ b/components/webui/.meteor/.id @@ -0,0 +1,7 @@ +# This file contains a token that is unique to your project. +# Check it into your repository along with the rest of this directory. +# It can be used for purposes such as: +# - ensuring you don't accidentally deploy one app on top of another +# - providing package authors with aggregated statistics + +w2i4drbtdntc.kdbz5fehx6g diff --git a/components/webui/.meteor/packages b/components/webui/.meteor/packages new file mode 100644 index 000000000..78e004d54 --- /dev/null +++ b/components/webui/.meteor/packages @@ -0,0 +1,23 @@ +# Meteor packages used by this project, one per line. +# Check this file (and the other files in this directory) into your repository. +# +# 'meteor add' and 'meteor remove' will edit this file for you, +# but you can also edit it by hand. + +meteor-base@1.5.1 # Packages every Meteor app needs to have +mobile-experience@1.1.1 # Packages for a great mobile UX +mongo@1.16.8 # The database Meteor supports right now +reactive-var@1.0.12 # Reactive variable for tracker + +standard-minifier-css@1.9.2 # CSS minifier run for production mode +standard-minifier-js@2.8.1 # JS minifier run for production mode +es5-shim@4.8.0 # ECMAScript 5 compatibility for older browsers +ecmascript@0.16.8 # Enable ECMAScript2015+ syntax in app code +typescript@4.9.5 # Enable TypeScript syntax in .ts and .tsx modules +shell-server@0.5.0 # Server-side component of the `meteor shell` command +hot-module-replacement@0.5.3 # Update client in development without reloading the page + +static-html@1.3.2 # Define static page content in .html files +react-meteor-data # React higher-order component for reactively tracking Meteor data +fourseven:scss +accounts-password@2.4.0 # user authentication via password diff --git a/components/webui/.meteor/platforms b/components/webui/.meteor/platforms new file mode 100644 index 000000000..efeba1b50 --- /dev/null +++ b/components/webui/.meteor/platforms @@ -0,0 +1,2 @@ +server +browser diff --git a/components/webui/.meteor/release b/components/webui/.meteor/release new file mode 100644 index 000000000..c500c39d6 --- /dev/null +++ b/components/webui/.meteor/release @@ -0,0 +1 @@ +METEOR@2.14 diff --git a/components/webui/.meteor/versions b/components/webui/.meteor/versions new file mode 100644 index 000000000..b65159172 --- /dev/null +++ b/components/webui/.meteor/versions @@ -0,0 +1,80 @@ +accounts-base@2.2.9 +accounts-password@2.4.0 +allow-deny@1.1.1 +autoupdate@1.8.0 +babel-compiler@7.10.5 +babel-runtime@1.5.1 +base64@1.0.12 +binary-heap@1.0.11 +blaze-tools@1.1.3 +boilerplate-generator@1.7.2 +caching-compiler@1.2.2 +caching-html-compiler@1.2.1 +callback-hook@1.5.1 +check@1.3.2 +ddp@1.4.1 +ddp-client@2.6.1 +ddp-common@1.4.0 +ddp-rate-limiter@1.2.1 +ddp-server@2.7.0 +diff-sequence@1.1.2 +dynamic-import@0.7.3 +ecmascript@0.16.8 +ecmascript-runtime@0.8.1 +ecmascript-runtime-client@0.12.1 +ecmascript-runtime-server@0.11.0 +ejson@1.1.3 +email@2.2.5 +es5-shim@4.8.0 +fetch@0.1.4 +fourseven:scss@4.16.0 +geojson-utils@1.0.11 +hot-code-push@1.0.4 +hot-module-replacement@0.5.3 +html-tools@1.1.3 +htmljs@1.1.1 +id-map@1.1.1 +inter-process-messaging@0.1.1 +launch-screen@2.0.0 +localstorage@1.2.0 +logging@1.3.3 +meteor@1.11.4 +meteor-base@1.5.1 +minifier-css@1.6.4 +minifier-js@2.7.5 +minimongo@1.9.3 +mobile-experience@1.1.1 +mobile-status-bar@1.1.0 +modern-browsers@0.1.10 +modules@0.20.0 +modules-runtime@0.13.1 +modules-runtime-hot@0.14.2 +mongo@1.16.8 +mongo-decimal@0.1.3 +mongo-dev-server@1.1.0 +mongo-id@1.0.8 +npm-mongo@4.17.2 +ordered-dict@1.1.0 +promise@0.12.2 +random@1.2.1 +rate-limit@1.1.1 +react-fast-refresh@0.2.8 +react-meteor-data@2.7.2 +reactive-var@1.0.12 +reload@1.3.1 +retry@1.1.0 +routepolicy@1.1.1 +sha@1.0.9 +shell-server@0.5.0 +socket-stream-client@0.5.2 +spacebars-compiler@1.3.1 +standard-minifier-css@1.9.2 +standard-minifier-js@2.8.1 +static-html@1.3.2 +templating-tools@1.2.2 +tracker@1.3.3 +typescript@4.9.5 +underscore@1.0.13 +url@1.3.2 +webapp@1.13.6 +webapp-hashing@1.1.1 diff --git a/components/webui/README.md b/components/webui/README.md new file mode 100644 index 000000000..4b96db8a7 --- /dev/null +++ b/components/webui/README.md @@ -0,0 +1,52 @@ +# Setup + +## Requirements + +* [Node.js 14](https://nodejs.org/download/release/v14.21.3/) (Meteor.js only + [supports](https://docs.meteor.com/install#prereqs-node) Node.js versions >= 10 and <= 14) +* [Meteor.js](https://docs.meteor.com/install.html#installation) + +## Install the dependencies + +```shell +meteor npm install +``` + +If you ever add a package manually to `package.json` or `package.json` changes +for some other reason, you should rerun this command. + +# Running in development + +The full functionality of the webui depends on other components in the CLP +package: + +1. Build the [CLP package](../../docs/Building.md) +2. Start the package: `/sbin/start-clp.sh` +3. Stop the webui instance started by the package: `/sbin/stop-clp.sh webui` +4. Start the webui using meteor (refer to `/etc/clp-config.yml` for the config values): + ```shell + MONGO_URL="mongodb://localhost:/" \ + ROOT_URL="http://" \ + CLP_DB_HOST="" \ + CLP_DB_PORT= \ + CLP_DB_NAME="" \ + CLP_DB_USER="" \ + CLP_DB_PASS="" \ + meteor --port --settings settings.json + ``` + + Here is an example based on the default `clp-config.yml`: + ```shell + # Please update `` accordingly + + MONGO_URL="mongodb://localhost:27017/clp-search" \ + ROOT_URL="http://localhost" \ + CLP_DB_HOST="localhost" \ + CLP_DB_PORT=3306 \ + CLP_DB_NAME="clp-db" \ + CLP_DB_USER="clp-user" \ + CLP_DB_PASS="" \ + meteor --port 4000 --settings settings.json + ``` +5. The Web UI should now be available at `http://:` + (e.g., http://localhost:4000). diff --git a/components/webui/client/main.css b/components/webui/client/main.css new file mode 100644 index 000000000..1c25edaaf --- /dev/null +++ b/components/webui/client/main.css @@ -0,0 +1,3 @@ +#react-target { + height: 100%; +} diff --git a/components/webui/client/main.html b/components/webui/client/main.html new file mode 100644 index 000000000..efc3618f3 --- /dev/null +++ b/components/webui/client/main.html @@ -0,0 +1,9 @@ + + YScope CLP + + + + +
+ diff --git a/components/webui/client/main.jsx b/components/webui/client/main.jsx new file mode 100644 index 000000000..3e681306f --- /dev/null +++ b/components/webui/client/main.jsx @@ -0,0 +1,20 @@ +import React from "react"; + +import {createBrowserHistory} from "history"; +import {Meteor} from "meteor/meteor"; +import {render} from "react-dom"; +import {Router, Switch} from "react-router"; + +import {App} from "/imports/ui/App.jsx"; + +Meteor.startup(() => { + const routes = ( + + + + + + ); + + render(routes, document.getElementById("react-target")); +}); diff --git a/components/webui/imports/api/ingestion/collections.js b/components/webui/imports/api/ingestion/collections.js new file mode 100644 index 000000000..b886d2d66 --- /dev/null +++ b/components/webui/imports/api/ingestion/collections.js @@ -0,0 +1,8 @@ +import {Mongo} from "meteor/mongo"; + + +const StatsCollection = new Mongo.Collection(Meteor.settings.public.StatsCollectionName); + +const STATS_COLLECTION_ID_COMPRESSION = "compression_stats"; + +export {StatsCollection, STATS_COLLECTION_ID_COMPRESSION}; diff --git a/components/webui/imports/api/ingestion/server/StatsDbManager.js b/components/webui/imports/api/ingestion/server/StatsDbManager.js new file mode 100644 index 000000000..194cd6218 --- /dev/null +++ b/components/webui/imports/api/ingestion/server/StatsDbManager.js @@ -0,0 +1,73 @@ +const CLP_ARCHIVES_TABLE_COLUMN_NAMES = { + BEGIN_TIMESTAMP: "begin_timestamp", + END_TIMESTAMP: "end_timestamp", + UNCOMPRESSED_SIZE: "uncompressed_size", + SIZE: "size", +}; + +const CLP_FILES_TABLE_COLUMN_NAMES = { + ORIG_FILE_ID: "orig_file_id", + NUM_MESSAGES: "num_messages", +}; + +/** + * Class for retrieving compression stats from the database. + */ +class StatsDbManager { + #sqlDbConnection; + #clpArchivesTableName; + #clpFilesTableName; + + /** + * @param {mysql.Connection} sqlDbConnection + * @param {object} tableNames + * @param {string} tableNames.clpArchivesTableName + * @param {string} tableNames.clpFilesTableName + */ + constructor(sqlDbConnection, { + clpArchivesTableName, + clpFilesTableName, + }) { + this.#sqlDbConnection = sqlDbConnection; + + this.#clpArchivesTableName = clpArchivesTableName; + this.#clpFilesTableName = clpFilesTableName; + } + + /** + * @typedef {object} Stats + * @property {number|null} begin_timestamp + * @property {number|null} end_timestamp + * @property {number|null} total_uncompressed_size + * @property {number|null} total_compressed_size + * @property {number|null} num_files + * @property {number|null} num_messages + */ + + /** + * Queries compression stats. + * @returns {Promise} + * @throws {Error} on error. + */ + async getCompressionStats() { + const [queryStats] = await this.#sqlDbConnection.query( + `SELECT a.begin_timestamp AS begin_timestamp, + a.end_timestamp AS end_timestamp, + a.total_uncompressed_size AS total_uncompressed_size, + a.total_compressed_size AS total_compressed_size, + b.num_files AS num_files, + b.num_messages AS num_messages + FROM (SELECT MIN(${CLP_ARCHIVES_TABLE_COLUMN_NAMES.BEGIN_TIMESTAMP}) AS begin_timestamp, + MAX(${CLP_ARCHIVES_TABLE_COLUMN_NAMES.END_TIMESTAMP}) AS end_timestamp, + SUM(${CLP_ARCHIVES_TABLE_COLUMN_NAMES.UNCOMPRESSED_SIZE}) AS total_uncompressed_size, + SUM(${CLP_ARCHIVES_TABLE_COLUMN_NAMES.SIZE}) AS total_compressed_size + FROM ${this.#clpArchivesTableName}) a, + (SELECT NULLIF(COUNT(DISTINCT ${CLP_FILES_TABLE_COLUMN_NAMES.ORIG_FILE_ID}), 0) AS num_files, + SUM(${CLP_FILES_TABLE_COLUMN_NAMES.NUM_MESSAGES}) AS num_messages + FROM ${this.#clpFilesTableName}) b;`, + ); + return queryStats[0]; + } +} + +export default StatsDbManager; diff --git a/components/webui/imports/api/ingestion/server/publications.js b/components/webui/imports/api/ingestion/server/publications.js new file mode 100644 index 000000000..63d0405b5 --- /dev/null +++ b/components/webui/imports/api/ingestion/server/publications.js @@ -0,0 +1,92 @@ +import {logger} from "/imports/utils/logger"; +import {Meteor} from "meteor/meteor"; + +import {STATS_COLLECTION_ID_COMPRESSION, StatsCollection} from "../collections"; +import StatsDbManager from "./StatsDbManager"; + + +/** + * @type {number} + */ +const STATS_REFRESH_INTERVAL_MS = 5000; + +/** + * @type {StatsDbManager|null} + */ +let statsDbManager = null; + +/** + * @type {number|null} + */ +let refreshMeteorInterval = null; + +/** + * Updates the compression statistics in the StatsCollection. + * + * @returns {Promise} + */ +const refreshCompressionStats = async () => { + if (Meteor.server.stream_server.all_sockets().length === 0) { + return; + } + + const stats = await statsDbManager.getCompressionStats(); + const filter = { + id: STATS_COLLECTION_ID_COMPRESSION, + }; + const modifier = { + $set: stats, + }; + const options = { + upsert: true, + }; + + await StatsCollection.updateAsync(filter, modifier, options); +}; + +/** + * @param {mysql.Connection} sqlDbConnection + * @param {object} tableNames + * @param {string} tableNames.clpArchivesTableName + * @param {string} tableNames.clpFilesTableName + * @throws {Error} on error. + */ +const initStatsDbManager = (sqlDbConnection, { + clpArchivesTableName, + clpFilesTableName, +}) => { + statsDbManager = new StatsDbManager(sqlDbConnection, { + clpArchivesTableName, + clpFilesTableName, + }); + + refreshMeteorInterval = Meteor.setInterval(refreshCompressionStats, STATS_REFRESH_INTERVAL_MS); +}; + +const deinitStatsDbManager = () => { + if (null !== refreshMeteorInterval) { + Meteor.clearInterval(refreshMeteorInterval); + refreshMeteorInterval = null; + } +}; + +/** + * Updates and publishes compression statistics. + * + * @param {string} publicationName + * + * @returns {Mongo.Cursor} + */ +Meteor.publish(Meteor.settings.public.StatsCollectionName, async () => { + logger.debug(`Subscription '${Meteor.settings.public.SearchResultsCollectionName}'`); + + await refreshCompressionStats(); + + const filter = { + id: STATS_COLLECTION_ID_COMPRESSION, + }; + + return StatsCollection.find(filter); +}); + +export {initStatsDbManager, deinitStatsDbManager}; diff --git a/components/webui/imports/api/search/SearchJobCollectionsManager.js b/components/webui/imports/api/search/SearchJobCollectionsManager.js new file mode 100644 index 000000000..b057b4c69 --- /dev/null +++ b/components/webui/imports/api/search/SearchJobCollectionsManager.js @@ -0,0 +1,36 @@ +/** + * Class to keep track of MongoDB collections created for search jobs, ensuring all collections have + * unique names. + */ +class SearchJobCollectionsManager { + #collections; + + constructor() { + this.#collections = new Map(); + } + + /** + * Gets, or if it doesn't exist, creates a MongoDB collection named with the given job ID. + * + * @param {number} jobId + * @returns {Mongo.Collection} + */ + getOrCreateCollection(jobId) { + const name = jobId.toString(); + if (undefined === this.#collections.get(name)) { + this.#collections.set(name, new Mongo.Collection(name)); + } + return this.#collections.get(name); + } + + /** + * Removes the MongoDB collection with the given job ID. + * + * @param {number} jobId + */ + removeCollection(jobId) { + this.#collections.delete(jobId.toString()); + } +} + +export default SearchJobCollectionsManager; diff --git a/components/webui/imports/api/search/collections.js b/components/webui/imports/api/search/collections.js new file mode 100644 index 000000000..d8406188e --- /dev/null +++ b/components/webui/imports/api/search/collections.js @@ -0,0 +1,42 @@ +import {Mongo} from "meteor/mongo"; +import {INVALID_JOB_ID, SearchSignal} from "./constants"; + + +/** + * A MongoDB collection for storing metadata about search results. + */ +const SearchResultsMetadataCollection = new Mongo.Collection( + Meteor.settings.public.SearchResultsMetadataCollectionName); + +/** + * Initializes the search event collection by inserting a default document if the collection is + * empty. + */ +const initSearchEventCollection = () => { + // create the collection if not exists + if (SearchResultsMetadataCollection.countDocuments() === 0) { + SearchResultsMetadataCollection.insert({ + _id: INVALID_JOB_ID.toString(), + lastEvent: SearchSignal.NONE, + errorMsg: null, + numTotalResults: -1, + }); + } +}; + +/** + * Adds the given sort to the find options for a MongoDB collection. + * @param {Object|null} fieldToSortBy An object mapping field names to the direction to sort by + * (ASC = 1, DESC = -1). + * @param {Object} findOptions + */ +const addSortToMongoFindOptions = (fieldToSortBy, findOptions) => { + if (fieldToSortBy) { + findOptions["sort"] = { + [fieldToSortBy.name]: fieldToSortBy.direction, + _id: fieldToSortBy.direction, + }; + } +}; + +export {addSortToMongoFindOptions, initSearchEventCollection, SearchResultsMetadataCollection}; diff --git a/components/webui/imports/api/search/constants.js b/components/webui/imports/api/search/constants.js new file mode 100644 index 000000000..ee0e2b5f4 --- /dev/null +++ b/components/webui/imports/api/search/constants.js @@ -0,0 +1,64 @@ +let enumSearchSignal; +/** + * Enum of search-related signals. + * + * This includes request and response signals for various search operations and their respective + * states. + * + * @type {Object} + */ +const SearchSignal = Object.freeze({ + NONE: (enumSearchSignal = 0), + + REQ_MASK: (enumSearchSignal = 0x10000000), + REQ_CLEARING: ++enumSearchSignal, + REQ_CANCELLING: ++enumSearchSignal, + REQ_QUERYING: ++enumSearchSignal, + + RESP_MASK: (enumSearchSignal = 0x20000000), + RESP_DONE: ++enumSearchSignal, + RESP_QUERYING: ++enumSearchSignal, +}); + +const isSearchSignalReq = (s) => (0 !== (SearchSignal.REQ_MASK & s)); +const isSearchSignalResp = (s) => (0 !== (SearchSignal.RESP_MASK & s)); +const isSearchSignalQuerying = (s) => ( + [ + SearchSignal.REQ_QUERYING, + SearchSignal.RESP_QUERYING, + ].includes(s) +); + +let enumJobStatus; +/** + * Enum of job statuses, matching the `SearchJobStatus` class in + * `job_orchestration.search_scheduler.constants`. + * + * @type {Object} + */ +const JobStatus = Object.freeze({ + PENDING: (enumJobStatus = 0), + RUNNING: ++enumJobStatus, + SUCCESS: ++enumJobStatus, + FAILED: ++enumJobStatus, + CANCELLING: ++enumJobStatus, + CANCELLED: ++enumJobStatus, +}); + +const JOB_STATUS_WAITING_STATES = [ + JobStatus.PENDING, + JobStatus.RUNNING, + JobStatus.CANCELLING, +]; + +const INVALID_JOB_ID = -1; + +export { + SearchSignal, + isSearchSignalReq, + isSearchSignalResp, + isSearchSignalQuerying, + JobStatus, + JOB_STATUS_WAITING_STATES, + INVALID_JOB_ID, +}; diff --git a/components/webui/imports/api/search/server/SearchJobsDbManager.js b/components/webui/imports/api/search/server/SearchJobsDbManager.js new file mode 100644 index 000000000..b56377e61 --- /dev/null +++ b/components/webui/imports/api/search/server/SearchJobsDbManager.js @@ -0,0 +1,102 @@ +import msgpack from "@msgpack/msgpack"; + +import {sleep} from "../../../utils/misc"; +import {JOB_STATUS_WAITING_STATES, JobStatus} from "../constants"; + + +const SEARCH_JOBS_TABLE_COLUMN_NAMES = { + ID: "id", + STATUS: "status", + SEARCH_CONFIG: "search_config", +}; + +/** + * Class for submitting and monitoring search jobs in the database. + */ +class SearchJobsDbManager { + #sqlDbConnection; + #searchJobsTableName; + + /** + * @param {mysql.Connection} sqlDbConnection + * @param {object} tableNames + * @param {string} tableNames.searchJobsTableName + */ + constructor(sqlDbConnection, {searchJobsTableName}) { + this.#sqlDbConnection = sqlDbConnection; + this.#searchJobsTableName = searchJobsTableName; + } + + /** + * Submits a query job to the database. + * @param {Object} searchConfig The arguments for the query. + * @returns {Promise} The job's ID. + * @throws {Error} on error. + */ + async submitQuery(searchConfig) { + const [queryInsertResults] = await this.#sqlDbConnection.query( + `INSERT INTO ${this.#searchJobsTableName} + (${SEARCH_JOBS_TABLE_COLUMN_NAMES.SEARCH_CONFIG}) + VALUES (?)`, + [Buffer.from(msgpack.encode(searchConfig))], + ); + return queryInsertResults.insertId; + } + + /** + * Submits a query cancellation request to the database. + * @param {number} jobId ID of the job to cancel. + * @returns {Promise} + * @throws {Error} on error. + */ + async submitQueryCancellation(jobId) { + await this.#sqlDbConnection.query( + `UPDATE ${this.#searchJobsTableName} + SET ${SEARCH_JOBS_TABLE_COLUMN_NAMES.STATUS} = ${JobStatus.CANCELLING} + WHERE ${SEARCH_JOBS_TABLE_COLUMN_NAMES.ID} = ?`, + jobId, + ); + } + + /** + * Waits for the job to complete. + * @param {number} jobId + * @returns {Promise} + * @throws {Error} on MySQL error, if the job wasn't found in the database, if the job was + * cancelled, or if the job completed in an unexpected state. + */ + async awaitJobCompletion(jobId) { + while (true) { + let rows; + try { + const [queryRows, _] = await this.#sqlDbConnection.query( + `SELECT ${SEARCH_JOBS_TABLE_COLUMN_NAMES.STATUS} + FROM ${this.#searchJobsTableName} + WHERE ${SEARCH_JOBS_TABLE_COLUMN_NAMES.ID} = ?`, + jobId, + ); + rows = queryRows; + } catch (e) { + throw new Error(`Failed to query status for job ${jobId} - ${e}`); + } + if (rows.length < 1) { + throw new Error(`Job ${jobId} not found in database.`); + } + const status = rows[0][SEARCH_JOBS_TABLE_COLUMN_NAMES.STATUS]; + + if (false === JOB_STATUS_WAITING_STATES.includes(status)) { + if (JobStatus.CANCELLED === status) { + throw new Error(`Job ${jobId} was cancelled.`); + } else if (JobStatus.SUCCESS !== status) { + throw new Error(`Job ${jobId} exited with unexpected status=${status}: ` + + `${Object.keys(JobStatus)[status]}.`); + } + break; + } + + await sleep(0.5); + } + } +} + +export default SearchJobsDbManager; diff --git a/components/webui/imports/api/search/server/collections.js b/components/webui/imports/api/search/server/collections.js new file mode 100644 index 000000000..5792105d9 --- /dev/null +++ b/components/webui/imports/api/search/server/collections.js @@ -0,0 +1,5 @@ +import SearchJobCollectionsManager from "../SearchJobCollectionsManager"; + +const searchJobCollectionsManager = new SearchJobCollectionsManager(); + +export {searchJobCollectionsManager}; diff --git a/components/webui/imports/api/search/server/methods.js b/components/webui/imports/api/search/server/methods.js new file mode 100644 index 000000000..098acd220 --- /dev/null +++ b/components/webui/imports/api/search/server/methods.js @@ -0,0 +1,165 @@ +import {logger} from "/imports/utils/logger"; +import {Meteor} from "meteor/meteor"; +import {SearchResultsMetadataCollection} from "../collections"; +import {SearchSignal} from "../constants"; +import {searchJobCollectionsManager} from "./collections"; +import SearchJobsDbManager from "./SearchJobsDbManager"; + + +/** + * @type {SearchJobsDbManager|null} + */ +let searchJobsDbManager = null; + +/** + * @param {mysql.Connection} sqlDbConnection + * @param {object} tableNames + * @param {string} tableNames.searchJobsTableName + * @throws {Error} on error. + */ +const initSearchJobsDbManager = (sqlDbConnection, {searchJobsTableName}) => { + searchJobsDbManager = new SearchJobsDbManager(sqlDbConnection, {searchJobsTableName}); +}; + +/** + * Updates the search event when the specified job finishes. + * + * @param {number} jobId of the job to monitor + */ +const updateSearchEventWhenJobFinishes = async (jobId) => { + let errorMsg; + try { + await searchJobsDbManager.awaitJobCompletion(jobId); + } catch (e) { + errorMsg = e.message; + } + const filter = { + _id: jobId.toString(), + }; + const modifier = { + $set: { + lastSignal: SearchSignal.RESP_DONE, + errorMsg: errorMsg, + numTotalResults: + await searchJobCollectionsManager.getOrCreateCollection(jobId).countDocuments(), + }, + }; + + logger.debug("modifier = ", modifier); + SearchResultsMetadataCollection.update(filter, modifier); +}; + +/** + * Creates MongoDB indexes for a specific job's collection. + * + * @param {number} jobId used to identify the Mongo Collection to add indexes + */ +const createMongoIndexes = async (jobId) => { + const timestampAscendingIndex = { + key: { + timestamp: 1, + _id: 1, + }, + name: "timestamp-ascending", + }; + const timestampDescendingIndex = { + key: { + timestamp: -1, + _id: -1, + }, + name: "timestamp-descending", + }; + + const queryJobCollection = searchJobCollectionsManager.getOrCreateCollection(jobId); + const queryJobRawCollection = queryJobCollection.rawCollection(); + await queryJobRawCollection.createIndexes([timestampAscendingIndex, timestampDescendingIndex]); +}; + +Meteor.methods({ + /** + * Submits a search query and initiates the search process. + * + * @param {string} queryString + * @param {number} timestampBegin + * @param {number} timestampEnd + * @returns {Object} containing {jobId} of the submitted search job + */ + async "search.submitQuery"({ + queryString, + timestampBegin, + timestampEnd, + }) { + const args = { + query_string: queryString, + begin_timestamp: timestampBegin, + end_timestamp: timestampEnd, + }; + logger.info("search.submitQuery args =", args); + + let jobId; + try { + jobId = await searchJobsDbManager.submitQuery(args); + } catch (e) { + const errorMsg = "Unable to submit search job to the SQL database."; + logger.error(errorMsg, e.toString()); + throw new Meteor.Error("query-submit-error", errorMsg); + } + + SearchResultsMetadataCollection.insert({ + _id: jobId.toString(), + lastSignal: SearchSignal.RESP_QUERYING, + errorMsg: null, + }); + + Meteor.defer(async () => { + await updateSearchEventWhenJobFinishes(jobId); + }); + + await createMongoIndexes(jobId); + + return {jobId}; + }, + + /** + * Clears the results of a search operation identified by jobId. + * + * @param {number} jobId of the search results to clear + */ + async "search.clearResults"({ + jobId, + }) { + logger.info("search.clearResults jobId =", jobId); + + try { + const resultsCollection = searchJobCollectionsManager.getOrCreateCollection(jobId); + await resultsCollection.dropCollectionAsync(); + + searchJobCollectionsManager.removeCollection(jobId); + } catch (e) { + const errorMsg = `Failed to clear search results for jobId ${jobId}.`; + logger.error(errorMsg, e.toString()); + throw new Meteor.Error("clear-results-error", errorMsg); + } + }, + + /** + * Cancels an ongoing search operation identified by jobId. + * + * @param {number} jobId of the search operation to cancel + */ + async "search.cancelOperation"({ + jobId, + }) { + logger.info("search.cancelOperation jobId =", jobId); + + try { + await searchJobsDbManager.submitQueryCancellation(jobId); + } catch (e) { + const errorMsg = `Failed to submit cancel request for job ${jobId}.`; + logger.error(errorMsg, e.toString()); + throw new Meteor.Error("query-cancel-error", errorMsg); + } + }, +}); + +export {initSearchJobsDbManager}; diff --git a/components/webui/imports/api/search/server/publications.js b/components/webui/imports/api/search/server/publications.js new file mode 100644 index 000000000..af1b164a2 --- /dev/null +++ b/components/webui/imports/api/search/server/publications.js @@ -0,0 +1,56 @@ +import {logger} from "/imports/utils/logger"; +import {Meteor} from "meteor/meteor"; + +import {addSortToMongoFindOptions, SearchResultsMetadataCollection} from "../collections"; +import {searchJobCollectionsManager} from "./collections"; + + +/** + * Publishes search results metadata for a specific job. + * + * @param {string} publicationName + * @param {string} jobId of the search operation + * + * @returns {Mongo.Cursor} cursor that provides access to the search results metadata + */ +Meteor.publish(Meteor.settings.public.SearchResultsMetadataCollectionName, ({jobId}) => { + logger.debug(`Subscription '${Meteor.settings.public.SearchResultsMetadataCollectionName}'`, + `jobId=${jobId}`); + + const filter = { + _id: jobId.toString(), + }; + + return SearchResultsMetadataCollection.find(filter); +}); + +/** + * Publishes search results for a specific job with optional sorting and result limit. + * + * @param {string} publicationName + * @param {string} jobId of the search operation + * @param {Object} [fieldToSortBy] used for sorting results + * @param {number} visibleSearchResultsLimit limit of visible search results + * + * @returns {Mongo.Cursor} cursor that provides access to the search results + */ +Meteor.publish(Meteor.settings.public.SearchResultsCollectionName, ({ + jobId, + fieldToSortBy, + visibleSearchResultsLimit, +}) => { + logger.debug(`Subscription '${Meteor.settings.public.SearchResultsCollectionName}'`, + `jobId=${jobId}, fieldToSortBy=${fieldToSortBy}, ` + + `visibleSearchResultsLimit=${visibleSearchResultsLimit}`); + + const collection = searchJobCollectionsManager.getOrCreateCollection(jobId); + + const findOptions = { + limit: visibleSearchResultsLimit, + disableOplog: true, + pollingIntervalMs: 250, + }; + addSortToMongoFindOptions(fieldToSortBy, findOptions); + + return collection.find({}, findOptions); +}); diff --git a/components/webui/imports/api/user/client/methods.js b/components/webui/imports/api/user/client/methods.js new file mode 100644 index 000000000..c27586585 --- /dev/null +++ b/components/webui/imports/api/user/client/methods.js @@ -0,0 +1,84 @@ +import {Meteor} from "meteor/meteor"; +import {v4 as uuidv4} from "uuid"; + + +// TODO: implement a full-fledged registration sys +const LOCAL_STORAGE_KEY_USERNAME = "username"; +const DUMMY_PASSWORD = "DummyPassword"; + +let LoginRetryCount = 0; +const CONST_MAX_LOGIN_RETRY = 3; + +/** + * Registers a user with a provided username and then attempts to log in with that username. + * + * @param {string} username to register and log in with + * + * @returns {Promise} true if the registration and login are successful + * false if there's an error during registration or login + */ +const registerAndLoginWithUsername = async (username) => { + return new Promise((resolve) => { + Meteor.call("user.create", { + username, + password: DUMMY_PASSWORD, + }, (error) => { + if (error) { + console.log("create user error", error); + resolve(false); + } else { + localStorage.setItem(LOCAL_STORAGE_KEY_USERNAME, username); + resolve(true); + } + }); + }); +}; + +/** + * Attempts to log in a user with the provided username using a dummy password. + * + * @param {string} username to register and log in with + * + * @returns {Promise} true if the login is successful + * false if there's an error during login or if the maximum login + * retries are reached + */ +const loginWithUsername = (username) => { + return new Promise((resolve) => { + Meteor.loginWithPassword(username, DUMMY_PASSWORD, (error) => { + if (!error) { + resolve(true); + } else { + console.log("login error", error, "LOGIN_RETRY_COUNT:", LoginRetryCount); + if (LoginRetryCount < CONST_MAX_LOGIN_RETRY) { + LoginRetryCount++; + resolve(registerAndLoginWithUsername(username)); + } else { + resolve(false); + } + } + }); + }); +}; + +/** + * Attempts to log in a user using a stored username or register a new one if none is found. + * + * @returns {Promise} true if the login is successful + * false if there's an error during login or registration + */ +const login = async () => { + let username = localStorage.getItem(LOCAL_STORAGE_KEY_USERNAME); + let result; + + if (username === null) { + username = uuidv4(); + result = await registerAndLoginWithUsername(username); + } else { + result = await loginWithUsername(username); + } + + return result; +}; + +export {registerAndLoginWithUsername, loginWithUsername, login}; diff --git a/components/webui/imports/api/user/server/methods.js b/components/webui/imports/api/user/server/methods.js new file mode 100644 index 000000000..e60ecdec7 --- /dev/null +++ b/components/webui/imports/api/user/server/methods.js @@ -0,0 +1,23 @@ +import {logger} from "/imports/utils/logger"; +import {Accounts} from "meteor/accounts-base"; +import {Meteor} from "meteor/meteor"; + + +Meteor.methods({ + /** + * Creates a user account with a provided username and password. + * + * @param {string} username for the new user + * @param {string} password for the new user + */ + "user.create"({ + username, + password, + }) { + logger.info("user.create", `username=${username}`); + Accounts.createUser({ + username, + password, + }); + }, +}); diff --git a/components/webui/imports/ui/App.jsx b/components/webui/imports/ui/App.jsx new file mode 100644 index 000000000..1e812c123 --- /dev/null +++ b/components/webui/imports/ui/App.jsx @@ -0,0 +1,95 @@ +import React from "react"; + +import {faFileUpload, faSearch} from "@fortawesome/free-solid-svg-icons"; +import {Redirect, Route, Switch} from "react-router"; + +import {login} from "../api/user/client/methods"; +import {LOCAL_STORAGE_KEYS} from "./constants"; + +import IngestView from "./IngestView/IngestView.jsx"; +import SearchView from "./SearchView/SearchView.jsx"; +import Sidebar from "./Sidebar/Sidebar.jsx"; + +import "./App.scss"; + + +const ROUTES = [ + { + path: "/ingest", + label: "Ingest", + icon: faFileUpload, + component: IngestView, + }, + { + path: "/search", + label: "Search", + icon: faSearch, + component: SearchView, + }, +]; + +const App = () => { + const [loggedIn, setLoggedIn] = React.useState(false); + const [isSidebarCollapsed, setSidebarStateCollapsed] = React.useState( + "true" === localStorage.getItem(LOCAL_STORAGE_KEYS.IS_SIDEBAR_COLLAPSED), + ); + + React.useEffect(async () => { + const result = await login(); + setLoggedIn(result); + }, []); + + React.useEffect(() => { + localStorage.setItem(LOCAL_STORAGE_KEYS.IS_SIDEBAR_COLLAPSED, + isSidebarCollapsed.toString()); + }, [isSidebarCollapsed]); + + const handleSidebarToggle = () => { + setSidebarStateCollapsed(!isSidebarCollapsed); + }; + + const Spinner = () =>
+
+
+ Loading... +
+
+
; + + const Routes = () => + + + + + + + + + + ; + + return (
+ +
+
+ {!loggedIn ? : } +
+
+
); +}; + +export {App}; diff --git a/components/webui/imports/ui/App.scss b/components/webui/imports/ui/App.scss new file mode 100644 index 000000000..ebec5adff --- /dev/null +++ b/components/webui/imports/ui/App.scss @@ -0,0 +1,16 @@ +@import "bootstrap-customized.scss"; + +html { + font-size: 14px; + height: 100%; +} + +body { + background-image: linear-gradient(135deg, white, rgba(255, 255, 255, 0.5)); + font-family: 'Source Sans Pro', sans-serif; + height: 100%; +} + +@import "IngestView/IngestView.scss"; +@import "SearchView/SearchView.scss"; +@import "Sidebar/Sidebar.scss"; diff --git a/components/webui/imports/ui/IngestView/IngestView.jsx b/components/webui/imports/ui/IngestView/IngestView.jsx new file mode 100644 index 000000000..0d630b107 --- /dev/null +++ b/components/webui/imports/ui/IngestView/IngestView.jsx @@ -0,0 +1,183 @@ +import React from "react"; + +import {faChartBar, faClock, faEnvelope, faFileAlt, faHdd} from "@fortawesome/free-solid-svg-icons"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {DateTime} from "luxon"; +import {useTracker} from "meteor/react-meteor-data"; +import {Col, Container, ProgressBar, Row} from "react-bootstrap"; + +import {StatsCollection} from "../../api/ingestion/collections"; +import {computeHumanSize} from "../../utils/misc"; + + +/** + * Presents compression statistics. + * @returns {JSX.Element} + */ +const IngestView = () => { + const stats = useTracker(() => { + Meteor.subscribe(Meteor.settings.public.StatsCollectionName); + + return StatsCollection.findOne(); + }, []); + + return ( + + + + {stats ? ( + + + + +
+ + ) : (<>)} + + + + ); +}; + +/** + * Presents space savings from the given statistics. + * + * @param {Object} stats + * @param {string} stats.total_uncompressed_size + * @param {string} stats.total_compressed_size + * @returns {JSX.Element} + */ +const SpaceSavings = ({stats}) => { + const logsUncompressedSize = parseInt(stats.total_uncompressed_size) || 0; + const logsCompressedSize = parseInt(stats.total_compressed_size) || 0; + const spaceSavings = logsUncompressedSize > 0 ? + 100 * (1 - logsCompressedSize / logsUncompressedSize) : + 0; + + return ( +
+ +

Space Savings

+ +
+ + + + {spaceSavings.toFixed(2) + "%"} + + + + + +
+ {computeHumanSize(logsUncompressedSize)} before compression + +
+ +
+ + +
+ {computeHumanSize(logsCompressedSize)} after compression + +
+ +
+
+ ); +}; + +/** + * Presents details from the given statistics. + * + * @param {Object|null} stats - The statistics object. + * @param {number|null} stats.begin_timestamp + * @param {number|null} stats.end_timestamp + * @param {number|null} stats.num_files + * @param {number|null} stats.num_messages + * @returns {JSX.Element} + */ +const Details = ({stats}) => { + const { + begin_timestamp: beginTimestamp, + end_timestamp: endTimestamp, + num_files: numFiles, + num_messages: numMessages, + } = stats; + + let timeRangeRow = null; + if (null !== endTimestamp) { + let timestampFormat = "kkkk-MMM-dd HH:mm"; + timeRangeRow = ( +
+
+ +
+
+ + {DateTime.fromMillis(Number(beginTimestamp)).toFormat(timestampFormat)} + to + {DateTime.fromMillis(Number(endTimestamp)).toFormat(timestampFormat)} + + time range +
+
+ ); + } + + let numFilesRow = null; + if (null !== numFiles) { + numFilesRow = ( +
+
+ +
+
+ {Number(numFiles).toLocaleString()} + files +
+
+ ); + } + + let numMessagesRow = null; + if (null !== numMessages) { + numMessagesRow = ( +
+
+ +
+
+ {Number(numMessages).toLocaleString()} + messages +
+
+ ); + } + + if (!(timeRangeRow || numFilesRow || numMessagesRow)) { + // No details to display + return (<>); + } + + return ( +
+ +

Details

+ +
+ + + {timeRangeRow} + {numFilesRow} + {numMessagesRow} + + +
+ ); +}; + +export default IngestView; diff --git a/components/webui/imports/ui/IngestView/IngestView.scss b/components/webui/imports/ui/IngestView/IngestView.scss new file mode 100644 index 000000000..fffe6808d --- /dev/null +++ b/components/webui/imports/ui/IngestView/IngestView.scss @@ -0,0 +1,72 @@ +.ingest-container { + // Background highlight + background: linear-gradient(to bottom, #004850, #004850 160px, transparent 160px); + padding: 30px; +} + +.panel { + background-color: white; + border: 1px solid #ddd; + border-radius: 3px; + box-shadow: 0 1px 11px 0 rgba(0, 0, 0, 0.1); + padding: 15px; + margin: 10px 0; +} + +.panel-h1 { + font-size: 1.5rem; + line-height: 1.5rem; + margin: 0 0 15px 0; +} + +.panel-icon { + color: #004850; + font-size: 1.5rem; + line-height: 1.5rem; +} + +.ingest { + &-stats { + &-details { + &-icon-container { + width: 40px; + + flex: 0 0 auto; + margin-right: 10px; + + color: #ad1869; + font-size: 40px; + line-height: 40px; + text-align: center; + } + + &-text-container { + flex-basis: 0; + flex-grow: 1; + max-width: 100%; + } + + &-row { + border-top: 1px solid #eee; + display: flex; + flex-wrap: wrap; + padding: 15px 0; + } + } + + &-detail { + display: block; + font-size: 1.5rem; + line-height: 1; + } + } +} + +.ingest-stat-bar { + height: 4px; + margin-bottom: 0.25rem; +} + +.ingest-stat-bar > .progress-bar { + background-color: $info; +} diff --git a/components/webui/imports/ui/SearchView/SearchControls.jsx b/components/webui/imports/ui/SearchView/SearchControls.jsx new file mode 100644 index 000000000..2debf1f09 --- /dev/null +++ b/components/webui/imports/ui/SearchView/SearchControls.jsx @@ -0,0 +1,285 @@ +import React, {useEffect, useRef, useState} from "react"; + +import {faBars, faSearch, faTimes, faTrash} from "@fortawesome/free-solid-svg-icons"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import { + Button, + Col, + Container, + Dropdown, + DropdownButton, + Form, + InputGroup, + Row, +} from "react-bootstrap"; +import DatePicker from "react-datepicker"; +import {isSearchSignalQuerying, isSearchSignalReq, SearchSignal} from "../../api/search/constants"; + +import {computeTimeRange, TIME_RANGE_PRESET_LABEL} from "./datetime"; +import {LOCAL_STORAGE_KEYS} from "../constants"; + +import "react-datepicker/dist/react-datepicker.css"; +import "./SearchControls.scss"; + + +/** + * Renders a date picker control for selecting date and time. + * + * @param {Object} props to be passed to the DatePicker component + * @returns {JSX.Element} + */ +const SearchControlsDatePicker = (props) => (); + +/** + * Renders a label for a search filter control. + * + * @param {Object} props to be passed to the Form.Label component + * @returns {JSX.Element} + */ +const SearchControlsFilterLabel = (props) => (); + +/** + * Renders the controls for filtering search results by time range, including a date picker and + * preset time range options. + * + * @param {Object} timeRange for filtering. + * @param {function} setTimeRange callback to set timeRange + * @returns {JSX.Element} + */ +const SearchFilterControlsDrawer = ({ + timeRange, + setTimeRange, +}) => { + const updateBeginTimestamp = (date) => { + if (date.getTime() > timeRange.end.getTime()) { + setTimeRange({ + begin: date, + end: date, + }); + } else { + setTimeRange({ + begin: date, + end: timeRange.end, + }); + } + }; + const updateEndTimestamp = (date) => { + setTimeRange({ + begin: timeRange.begin, + end: date, + }); + }; + + const handleTimeRangePresetSelection = (event) => { + event.preventDefault(); + + let presetToken = event.target.getAttribute("data-preset"); + const timeRange = computeTimeRange(presetToken); + + setTimeRange(timeRange); + }; + + const timeRangePresetItems = Object.entries(TIME_RANGE_PRESET_LABEL).map(([token, label]) => + + {label} + ); + + // Compute range of end timestamp so that it's after the begin timestamp + let timestampEndMin = null; + let timestampEndMax = null; + if (timeRange.begin.getFullYear() === timeRange.end.getFullYear() && + timeRange.begin.getMonth() === timeRange.end.getMonth() && + timeRange.begin.getDate() === timeRange.end.getDate()) { + timestampEndMin = new Date(timeRange.begin); + // TODO This doesn't handle leap seconds + timestampEndMax = new Date(timeRange.end).setHours(23, 59, 59, 999); + } + + return (
+ + + + Time Range + + + + + {timeRangePresetItems} + + + + to + + + + + + +
); +}; + +/** + * Renders the search controls including query input, filter drawer toggle, and operation buttons + * like submit, clear, and cancel. It also manages the state of the drawer. + * + * @param {string} queryString for matching logs + * @param {function} setQueryString callback to set queryString + * @param {Object} timeRange for filtering + * @param {function} setTimeRange callback to set timeRange + * @param {Object} resultsMetadata which includes last request / response signal + * @param {function} onSubmitQuery callback to submit the search query + * @param {function} onClearResults callback to clear search results + * @param {function} onCancelOperation callback to cancel the ongoing search operation + * @returns {JSX.Element} + */ +const SearchControls = ({ + queryString, + setQueryString, + timeRange, + setTimeRange, + resultsMetadata, + onSubmitQuery, + onClearResults, + onCancelOperation, +}) => { + const [drawerOpen, setDrawerOpen] = useState( + "true" === localStorage.getItem(LOCAL_STORAGE_KEYS.SEARCH_CONTROLS_VISIBLE)); + const [canceling, setCanceling] = useState(false); + const inputRef = useRef(null); + + const isInputDisabled = + (true === isSearchSignalReq(resultsMetadata["lastSignal"])) || + (true === isSearchSignalQuerying(resultsMetadata["lastSignal"])); + + useEffect(() => { + if (false === isInputDisabled) { + inputRef.current?.focus(); + } + }, [isInputDisabled]); + + useEffect(() => { + localStorage.setItem(LOCAL_STORAGE_KEYS.SEARCH_CONTROLS_VISIBLE, drawerOpen.toString()); + }, [drawerOpen]); + + const queryChangeHandler = (e) => { + setQueryString(e.target.value); + }; + + const handleDrawerToggleClick = () => { + setDrawerOpen(!drawerOpen); + }; + + const handleQuerySubmission = (e) => { + e.preventDefault(); + + setCanceling(false); + onSubmitQuery(); + }; + + const handleCancelOperation = () => { + setCanceling(true); + onCancelOperation(); + }; + + return <> +
+ + + + + { + (SearchSignal.RESP_DONE === resultsMetadata["lastSignal"]) && + + } + { + (SearchSignal.RESP_QUERYING === resultsMetadata["lastSignal"]) ? + : + + } + + +
+ + {drawerOpen && } + ; +}; + +export default SearchControls; diff --git a/components/webui/imports/ui/SearchView/SearchControls.scss b/components/webui/imports/ui/SearchView/SearchControls.scss new file mode 100644 index 000000000..9c0fb1295 --- /dev/null +++ b/components/webui/imports/ui/SearchView/SearchControls.scss @@ -0,0 +1,25 @@ +.search-filter { + &-controls-drawer { + background-color: #efefef; + + padding: 1rem 0 0.5rem 0; + position: relative; + + width: 100%; + } + + &-control-label { + color: #666; + } +} + +.timestamp-picker { + border: 1px solid rgb(206, 212, 218); + border-radius: 0; + + font-size: 0.875rem; + line-height: 1.5; + + height: calc(1.5em + 0.5rem + 2px); + padding: 0.25rem 0.5rem; +} diff --git a/components/webui/imports/ui/SearchView/SearchResults.jsx b/components/webui/imports/ui/SearchView/SearchResults.jsx new file mode 100644 index 000000000..e850ab607 --- /dev/null +++ b/components/webui/imports/ui/SearchView/SearchResults.jsx @@ -0,0 +1,60 @@ +import React from "react"; + +import SearchResultsHeader from "./SearchResultsHeader.jsx"; +import {SearchResultsTable} from "./SearchResultsTable.jsx"; + + +/** + * Renders the search results, which includes the search results header and the search results + * table. + * + * @param {number} jobId of the search job + * @param {Object[]} searchResults results to display + * @param {Object} resultsMetadata which includes total results count and last request / response signal + * @param {Object} fieldToSortBy used for sorting results + * @param {function} setFieldToSortBy callback to set fieldToSortBy + * @param {number} visibleSearchResultsLimit limit of visible search results + * @param {function} setVisibleSearchResultsLimit callback to set visibleSearchResultsLimit + * @param {number} maxLinesPerResult to display + * @param {function} setMaxLinesPerResult callback to set maxLinesPerResult + * @returns {JSX.Element} + */ +const SearchResults = ({ + jobId, + searchResults, + resultsMetadata, + fieldToSortBy, + setFieldToSortBy, + visibleSearchResultsLimit, + setVisibleSearchResultsLimit, + maxLinesPerResult, + setMaxLinesPerResult, +}) => { + const numResultsOnServer = resultsMetadata["numTotalResults"] || searchResults.length; + + return <> +
+ +
+ {(0 < searchResults.length) &&
+ +
} + ; +}; + +export default SearchResults; diff --git a/components/webui/imports/ui/SearchView/SearchResultsHeader.jsx b/components/webui/imports/ui/SearchView/SearchResultsHeader.jsx new file mode 100644 index 000000000..bce6765e6 --- /dev/null +++ b/components/webui/imports/ui/SearchView/SearchResultsHeader.jsx @@ -0,0 +1,95 @@ +import React from "react"; + +import {faCog} from "@fortawesome/free-solid-svg-icons"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import { + Button, + Col, + Container, + Form, + InputGroup, + OverlayTrigger, + Popover, + Row, +} from "react-bootstrap"; +import {SearchSignal} from "../../api/search/constants"; + +import "./SearchResultsHeader.scss"; + + +/** + * Renders the header for the search results, which includes the job ID, the number of results + * found, and a control for setting the maximum number of lines per search result. + * + * @param {number} jobId of the search job + * @param {Object} resultsMetadata which includes last request / response signal + * @param {number} numResultsOnServer of the search job + * @param {number} maxLinesPerResult to display + * @param {function} setMaxLinesPerResult callback to set setMaxLinesPerResult + * @returns {JSX.Element} + */ +const SearchResultsHeader = ({ + jobId, + resultsMetadata, + numResultsOnServer, + maxLinesPerResult, + setMaxLinesPerResult, +}) => { + const handleMaxLinesPerResultSubmission = (e) => { + e.preventDefault(); + const value = parseInt(e.target.elements["maxLinesPerResult"].value); + if (value > 0) { + setMaxLinesPerResult(value); + } + }; + + let numResultsText = `Job ID ${jobId}: `; + if (0 === numResultsOnServer) { + numResultsText += SearchSignal.RESP_DONE !== resultsMetadata["lastSignal"] ? + "Query is running" : + "No results found"; + } else if (1 === numResultsOnServer) { + numResultsText += "1 result found"; + } else { + numResultsText += `${numResultsOnServer} results found`; + } + + return (<> + + + + + Job ID {jobId} | Results count: {numResultsOnServer} + + + + +
+ + Max lines per result + + +
+ }> + {(0 < numResultsOnServer) ? : <>} +
+ +
+
+ ); +}; + +export default SearchResultsHeader; diff --git a/components/webui/imports/ui/SearchView/SearchResultsHeader.scss b/components/webui/imports/ui/SearchView/SearchResultsHeader.scss new file mode 100644 index 000000000..4a70717f8 --- /dev/null +++ b/components/webui/imports/ui/SearchView/SearchResultsHeader.scss @@ -0,0 +1,12 @@ +.search-results-title-bar { + background-color: #fff; + padding: 2px 0; +} + +.search-results-count { + padding: 0.375rem 0; + + color: #999; + font-size: 1rem; + line-height: 1.5; +} diff --git a/components/webui/imports/ui/SearchView/SearchResultsTable.jsx b/components/webui/imports/ui/SearchView/SearchResultsTable.jsx new file mode 100644 index 000000000..4803c4e31 --- /dev/null +++ b/components/webui/imports/ui/SearchView/SearchResultsTable.jsx @@ -0,0 +1,147 @@ +import React, {useEffect, useState} from "react"; + +import {faSort, faSortDown, faSortUp} from "@fortawesome/free-solid-svg-icons"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {Spinner, Table} from "react-bootstrap"; +import ReactVisibilitySensor from "react-visibility-sensor"; + +import "./SearchResultsTable.scss"; + + +/** + * The initial visible results limit. + * + * @type {number} + * @constant + */ +const VISIBLE_RESULTS_LIMIT_INITIAL = 10; +/** + * The increment value for the visible results limit. + * + * @type {number} + * @constant + */ +const VISIBLE_RESULTS_LIMIT_INCREMENT = 10; + +/** + * Represents a table component to display search results. + * + * @param {Object} searchResults - The array of search results. + * @param {number} maxLinesPerResult - The maximum number of lines to show per search result. + * @param {Object} fieldToSortBy - The field to sort the search results by. + * @param {function} setFieldToSortBy - The function to set the field to sort by. + * @param {number} numResultsOnServer - The total number of search results on the server. + * @param {number} visibleSearchResultsLimit - The number of search results currently visible. + * @param {function} setVisibleSearchResultsLimit - The function to set the number of visible search results. + * @returns {JSX.Element} - The rendered SearchResultsTable component. + */ +const SearchResultsTable = ({ + searchResults, + maxLinesPerResult, + fieldToSortBy, + setFieldToSortBy, + numResultsOnServer, + visibleSearchResultsLimit, + setVisibleSearchResultsLimit, +}) => { + const [visibilitySensorVisible, setVisibilitySensorVisible] = useState(false); + + useEffect(() => { + if (true === visibilitySensorVisible && visibleSearchResultsLimit <= numResultsOnServer) { + setVisibleSearchResultsLimit( + visibleSearchResultsLimit + VISIBLE_RESULTS_LIMIT_INCREMENT); + } + }, [visibilitySensorVisible, numResultsOnServer]); + + const getSortIcon = (fieldToSortBy, fieldName) => { + if (fieldToSortBy && fieldName === fieldToSortBy.name) { + return (1 === fieldToSortBy.direction ? faSortDown : faSortUp); + } else { + return faSort; + } + }; + + const toggleSortDirection = (event) => { + const columnName = event.currentTarget.dataset.columnName; + if (null === fieldToSortBy || fieldToSortBy.name !== columnName) { + setFieldToSortBy({ + name: columnName, + direction: 1, + }); + } else if (1 === fieldToSortBy.direction) { + // Switch to descending + setFieldToSortBy({ + name: columnName, + direction: -1, + }); + } else if (-1 === fieldToSortBy.direction) { + // Switch to unsorted + setFieldToSortBy(null); + } + }; + + let rows = []; + + // Construct rows + for (let i = 0; i < searchResults.length; ++i) { + let searchResult = searchResults[i]; + rows.push( + {searchResult.timestamp ? new Date(searchResult.timestamp).toISOString(). + slice(0, 19). + replace("T", " ") : "N/A"} + +
+                    {searchResult.message}
+                
+ + ); + } + + return (
+ + + + + + + + + {rows} + +
+
+ Timestamp +
+
+
+ Log message +
+
+ +
+ + Loading +
+
+
); +}; + +export {SearchResultsTable, VISIBLE_RESULTS_LIMIT_INITIAL}; diff --git a/components/webui/imports/ui/SearchView/SearchResultsTable.scss b/components/webui/imports/ui/SearchView/SearchResultsTable.scss new file mode 100644 index 000000000..f622e94d7 --- /dev/null +++ b/components/webui/imports/ui/SearchView/SearchResultsTable.scss @@ -0,0 +1,34 @@ +.search-results-container { + position: relative; +} + +// NOTE: We use a hierarchical selector to override Bootstrap's hierarchical selector (classes can't override hierarchical selectors without !important) +.search-results thead th { + border: none; + padding: 0; +} + +.search-results-th { + position: sticky; + top: 0; + + &-sortable { + cursor: pointer; + user-select: none; + } +} + +.search-results-table-header { + background-color: #fff; + border-bottom: 1px solid #ccc; + border-top: 1px solid #dee2e6; + padding: 10px; +} + +.search-results-message { + font-size: 0.93rem; + line-height: 1.4rem; + margin: 0; + white-space: pre-wrap; + word-break: break-word; +} diff --git a/components/webui/imports/ui/SearchView/SearchView.jsx b/components/webui/imports/ui/SearchView/SearchView.jsx new file mode 100644 index 000000000..81c90c4b9 --- /dev/null +++ b/components/webui/imports/ui/SearchView/SearchView.jsx @@ -0,0 +1,277 @@ +import React, {useEffect, useRef, useState} from "react"; + +import {faExclamationCircle} from "@fortawesome/free-solid-svg-icons"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {Meteor} from "meteor/meteor"; +import {useTracker} from "meteor/react-meteor-data"; +import {ProgressBar} from "react-bootstrap"; + +import { + addSortToMongoFindOptions, + SearchResultsMetadataCollection, +} from "../../api/search/collections"; +import {INVALID_JOB_ID, isSearchSignalQuerying, SearchSignal} from "../../api/search/constants"; +import SearchJobCollectionsManager from "../../api/search/SearchJobCollectionsManager"; +import {LOCAL_STORAGE_KEYS} from "../constants"; +import {changeTimezoneToUtcWithoutChangingTime, DEFAULT_TIME_RANGE} from "./datetime"; + +import SearchControls from "./SearchControls.jsx"; +import SearchResults from "./SearchResults.jsx"; +import {VISIBLE_RESULTS_LIMIT_INITIAL} from "./SearchResultsTable.jsx"; + + +// for pseudo progress bar +const PROGRESS_INCREMENT = 5; +const PROGRESS_INTERVAL_MS = 100; + +/** + * Provides a search interface, which search queries and visualizes search results. + */ +const SearchView = () => { + // Query states + const [jobId, setJobId] = useState(INVALID_JOB_ID); + const [operationErrorMsg, setOperationErrorMsg] = useState(""); + const [localLastSearchSignal, setLocalLastSearchSignal] = useState(SearchSignal.NONE); + const dbRef = useRef(new SearchJobCollectionsManager()); + // gets updated as soon as localLastSearchSignal is updated + // to avoid reading old localLastSearchSignal value from Closures + const localLastSearchSignalRef = useRef(localLastSearchSignal); + + // Query options + const [queryString, setQueryString] = useState(""); + const [timeRange, setTimeRange] = useState(DEFAULT_TIME_RANGE); + const [visibleSearchResultsLimit, setVisibleSearchResultsLimit] = useState( + VISIBLE_RESULTS_LIMIT_INITIAL); + const [fieldToSortBy, setFieldToSortBy] = useState({ + name: "timestamp", + direction: -1, + }); + + // Visuals + const [maxLinesPerResult, setMaxLinesPerResult] = useState( + Number(localStorage.getItem(LOCAL_STORAGE_KEYS.MAX_LINES_PER_RESULT) || 2)); + + // Subscriptions + const resultsMetadata = useTracker(() => { + let result = {lastSignal: localLastSearchSignal}; + + if (INVALID_JOB_ID !== jobId) { + const args = {jobId}; + const subscription = Meteor.subscribe( + Meteor.settings.public.SearchResultsMetadataCollectionName, args); + const doc = SearchResultsMetadataCollection.findOne(); + + const isReady = subscription.ready(); + if (true === isReady) { + result = doc; + } + } + + return result; + }, [jobId, localLastSearchSignal]); + + const searchResults = useTracker(() => { + if (INVALID_JOB_ID === jobId) { + return []; + } + + Meteor.subscribe(Meteor.settings.public.SearchResultsCollectionName, { + jobId: jobId, + fieldToSortBy: fieldToSortBy, + visibleSearchResultsLimit: visibleSearchResultsLimit, + }); + + const findOptions = {}; + addSortToMongoFindOptions(fieldToSortBy, findOptions); + + // NOTE: Although we publish and subscribe using the name + // `Meteor.settings.public.SearchResultsCollectionName`, the rows are still returned in the + // job-specific collection (e.g., "1"); this is because on the server, we're returning a + // cursor from the job-specific collection and Meteor creates a collection with the same + // name on the client rather than returning the rows in a collection with the published + // name. + return dbRef.current.getOrCreateCollection(jobId).find({}, findOptions).fetch(); + }, [jobId, fieldToSortBy, visibleSearchResultsLimit]); + + // State transitions + useEffect(() => { + localStorage.setItem(LOCAL_STORAGE_KEYS.MAX_LINES_PER_RESULT, maxLinesPerResult.toString()); + }, [maxLinesPerResult]); + + useEffect(() => { + localLastSearchSignalRef.current = localLastSearchSignal; + }, [localLastSearchSignal]); + + // Handlers + const resetVisibleResultSettings = () => { + setVisibleSearchResultsLimit(VISIBLE_RESULTS_LIMIT_INITIAL); + }; + + const submitQuery = () => { + if (INVALID_JOB_ID !== jobId) { + // Clear result caches before starting a new query + handleClearResults(); + } + + setOperationErrorMsg(""); + setLocalLastSearchSignal(SearchSignal.REQ_QUERYING); + resetVisibleResultSettings(); + + const timestampBeginMillis = changeTimezoneToUtcWithoutChangingTime(timeRange.begin) + .getTime(); + const timestampEndMillis = changeTimezoneToUtcWithoutChangingTime(timeRange.end).getTime(); + + const args = { + queryString: queryString, + timestampBegin: timestampBeginMillis, + timestampEnd: timestampEndMillis, + }; + Meteor.call("search.submitQuery", args, (error, result) => { + if (error) { + setJobId(INVALID_JOB_ID); + setOperationErrorMsg(error.reason); + return; + } + + setJobId(result["jobId"]); + }); + }; + + const handleClearResults = () => { + dbRef.current.removeCollection(jobId); + + setJobId(INVALID_JOB_ID); + setOperationErrorMsg(""); + setLocalLastSearchSignal(SearchSignal.REQ_CLEARING); + resetVisibleResultSettings(); + + const args = { + jobId: jobId, + }; + Meteor.call("search.clearResults", args, (error) => { + if (error) { + setOperationErrorMsg(error.reason); + return; + } + + if (SearchSignal.REQ_CLEARING === localLastSearchSignalRef.current) { + // The check prevents clearing `localLastSearchSignal = SearchSignal.REQ_QUERYING` + // when `handleClearResults` is called by submitQuery. + setLocalLastSearchSignal(SearchSignal.NONE); + } + }); + }; + + const cancelOperation = () => { + setOperationErrorMsg(""); + setLocalLastSearchSignal(SearchSignal.REQ_CANCELLING); + + const args = { + jobId: jobId, + }; + Meteor.call("search.cancelOperation", args, (error) => { + if (error) { + setOperationErrorMsg(error.reason); + } + }); + }; + + const showSearchResults = INVALID_JOB_ID !== jobId; + return (
+
+ + + +
+ + {showSearchResults && } +
); +}; + +/** + * Displays the status of a search operation, which shows error messages if any, and otherwise + * displays the current status of the search. + * + * @param {Object} resultsMetadata including the last search signal + * @param {string} [errorMsg] - message if there is an error + * @returns {JSX.Element} + */ +const SearchStatus = ({ + resultsMetadata, + errorMsg, +}) => { + const [progress, setProgress] = useState(0); + const timerIntervalRef = useRef(null); + + useEffect(() => { + if (true === isSearchSignalQuerying(resultsMetadata["lastSignal"])) { + timerIntervalRef.current = timerIntervalRef.current ?? setInterval(() => { + setProgress((progress) => (progress + PROGRESS_INCREMENT)); + }, PROGRESS_INTERVAL_MS); + } else { + if (null !== timerIntervalRef.current) { + clearInterval(timerIntervalRef.current); + timerIntervalRef.current = null; + } + setProgress(0); + } + }, [resultsMetadata["lastSignal"]]); + + if ("" !== errorMsg && null !== errorMsg && undefined !== errorMsg) { + return (
+ + {errorMsg} +
); + } else { + let message = null; + switch (resultsMetadata["lastSignal"]) { + case SearchSignal.NONE: + message = "Ready"; + break; + case SearchSignal.REQ_CLEARING: + message = "Clearing..."; + break; + default: + break; + } + + return <> + + {null !== message && +
{message}
} + ; + } +}; + +export default SearchView; diff --git a/components/webui/imports/ui/SearchView/SearchView.scss b/components/webui/imports/ui/SearchView/SearchView.scss new file mode 100644 index 000000000..40ce38af8 --- /dev/null +++ b/components/webui/imports/ui/SearchView/SearchView.scss @@ -0,0 +1,25 @@ +.search-no-results-status { + color: #ccc; + font-size: 5rem; + line-height: 1; + + padding: 1rem 2rem; +} + +.search-error { + padding: 0.5rem; + + background-color: #ffc0c0; + color: #8e0000; + border-top: 1px solid #ca9b9b; + border-bottom: 1px solid #ecb4b4; +} + +.search-error-icon { + margin-right: 0.5rem; + vertical-align: middle; +} + +.search-progress-bar { + height: 4px; +} diff --git a/components/webui/imports/ui/SearchView/datetime.js b/components/webui/imports/ui/SearchView/datetime.js new file mode 100644 index 000000000..adb41bd26 --- /dev/null +++ b/components/webui/imports/ui/SearchView/datetime.js @@ -0,0 +1,124 @@ +import {DateTime} from "luxon"; + + +const TIME_RANGE_UNIT = Object.freeze({ + ALL: "all", + MINUTE: "minute", + HOUR: "hour", + DAY: "day", + WEEK: "week", + MONTH: "month", + YEAR: "year", +}); + +const TIME_RANGE_MODIFIER = Object.freeze({ + NONE: "none", + TODAY: "today", + LAST: "last", + PREV: "prev", + TO_DATE: "to-date", +}); + +// TODO Switch date pickers so we don't have to do this hack +/** + * Converts a DateTime object into a JavaScript Date object without changing the timestamp (hour, + * minute, second, etc.). + * @param dateTime + * @returns {Date} The corresponding Date object + */ +const dateTimeToDateWithoutChangingTimestamp = (dateTime) => { + return dateTime.toLocal().set({ + year: dateTime.year, + month: dateTime.month, + day: dateTime.day, + hour: dateTime.hour, + minute: dateTime.minute, + second: dateTime.second, + millisecond: dateTime.millisecond, + }).toJSDate(); +}; + +const TIME_RANGE_PRESET_LABEL = Object.freeze({ + [`${TIME_RANGE_UNIT.MINUTE}_${TIME_RANGE_MODIFIER.LAST}_15`]: "Last 15 Minutes", + [`${TIME_RANGE_UNIT.MINUTE}_${TIME_RANGE_MODIFIER.LAST}_60`]: "Last 60 Minutes", + [`${TIME_RANGE_UNIT.HOUR}_${TIME_RANGE_MODIFIER.LAST}_4`]: "Last 4 Hours", + [`${TIME_RANGE_UNIT.HOUR}_${TIME_RANGE_MODIFIER.LAST}_24`]: "Last 24 Hours", + [`${TIME_RANGE_UNIT.DAY}_${TIME_RANGE_MODIFIER.PREV}_1`]: "Previous Day", + [`${TIME_RANGE_UNIT.WEEK}_${TIME_RANGE_MODIFIER.PREV}_1`]: "Previous Week", + [`${TIME_RANGE_UNIT.MONTH}_${TIME_RANGE_MODIFIER.PREV}_1`]: "Previous Month", + [`${TIME_RANGE_UNIT.YEAR}_${TIME_RANGE_MODIFIER.PREV}_1`]: "Previous Year", + [`${TIME_RANGE_UNIT.DAY}_${TIME_RANGE_MODIFIER.TODAY}_0`]: "Today", + [`${TIME_RANGE_UNIT.WEEK}_${TIME_RANGE_MODIFIER.TO_DATE}_0`]: "Week to Date", + [`${TIME_RANGE_UNIT.MONTH}_${TIME_RANGE_MODIFIER.TO_DATE}_0`]: "Month to Date", + [`${TIME_RANGE_UNIT.YEAR}_${TIME_RANGE_MODIFIER.TO_DATE}_0`]: "Year to Date", + [`${TIME_RANGE_UNIT.ALL}_${TIME_RANGE_MODIFIER.NONE}_0`]: "All Time", +}); + +/** + * Computes a time range based on a token. + * + * @param {string} token representing the time range to compute; format: `unit_modifier_amount` + * @returns {Object} containing Date objects representing the computed begin and end time range + */ +const computeTimeRange = (token) => () => { + const [unit, modifier, amount] = token.split("_"); + let endTime; + let beginTime; + + if (TIME_RANGE_UNIT.ALL === unit) { + endTime = DateTime.utc().plus({years: 1}); + beginTime = DateTime.fromMillis(0, {zone: "UTC"}); + } else { + const isEndingNow = [ + TIME_RANGE_MODIFIER.LAST, + TIME_RANGE_MODIFIER.TODAY, + TIME_RANGE_MODIFIER.TO_DATE, + ].includes(modifier); + const isBeginStartOfUnit = [ + TIME_RANGE_MODIFIER.PREV, + TIME_RANGE_MODIFIER.TODAY, + TIME_RANGE_MODIFIER.TO_DATE, + ].includes(modifier); + + endTime = (true === isEndingNow) ? + DateTime.utc() : + DateTime.utc().minus({[unit]: amount}).endOf(unit); + beginTime = (true === isBeginStartOfUnit) ? + endTime.startOf(unit) : + endTime.minus({[unit]: amount}); + } + + return { + begin: dateTimeToDateWithoutChangingTimestamp(beginTime), + end: dateTimeToDateWithoutChangingTimestamp(endTime), + }; +}; + +/** + * Changes the timezone of a given Date object to UTC without changing the time. + * + * @param {Date} date Date object to convert to UTC + * @returns {Date} A new Date object with the same time values in UTC timezone + */ +const changeTimezoneToUtcWithoutChangingTime = (date) => { + return new Date(Date.UTC( + date.getFullYear(), + date.getMonth(), + date.getDate(), + date.getHours(), + date.getMinutes(), + date.getSeconds(), + date.getMilliseconds(), + )); +}; + +const DEFAULT_TIME_RANGE = computeTimeRange( + `${TIME_RANGE_UNIT.ALL}_${TIME_RANGE_MODIFIER.NONE}_0`, +); + +export { + TIME_RANGE_PRESET_LABEL, + computeTimeRange, + changeTimezoneToUtcWithoutChangingTime, + DEFAULT_TIME_RANGE, +}; diff --git a/components/webui/imports/ui/Sidebar/Sidebar.jsx b/components/webui/imports/ui/Sidebar/Sidebar.jsx new file mode 100644 index 000000000..f92b19615 --- /dev/null +++ b/components/webui/imports/ui/Sidebar/Sidebar.jsx @@ -0,0 +1,59 @@ +import React from "react"; + +import {faAngleDoubleLeft, faAngleDoubleRight} from "@fortawesome/free-solid-svg-icons"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {NavLink} from "react-router-dom"; + + +/** + * Renders a sidebar navigation component, which includes navigation links and a toggle for + * collapsing or expanding the sidebar. + * + * @param {boolean} isSidebarCollapsed indicates whether the sidebar is collapsed + * @param {Object[]} routes objects for navigation links + * @param {function} onSidebarToggle callback to toggle the sidebar's collapsed state + * @returns {JSX.Element} + */ +const Sidebar = ({ + isSidebarCollapsed, + routes, + onSidebarToggle, +}) => { + return ( + + ); +}; + +export default Sidebar; diff --git a/components/webui/imports/ui/Sidebar/Sidebar.scss b/components/webui/imports/ui/Sidebar/Sidebar.scss new file mode 100644 index 000000000..c41fa4f0b --- /dev/null +++ b/components/webui/imports/ui/Sidebar/Sidebar.scss @@ -0,0 +1,153 @@ +#sidebar { + height: 100%; + min-width: 190px; + width: 190px; + + display: flex; + flex-direction: column; + + background: #080808; + color: #dedede; + + transition: min-width 0.5s, width 0.5s; +} + +#sidebar.collapsed { + min-width: 3rem; + width: 3rem; +} + +#sidebar .brand { + align-items: center; /* center element vertically */ + display: flex; + flex: 0 0 3rem; /* cannot grow, cannot shrink, 44px height */ + justify-content: center; /* center element horizontally */ + + border-left: 1px solid #111; + + font-size: 1.25rem; + white-space: nowrap; /* proper animation */ + overflow: hidden; /* proper animation */ +} + +.sidebar-menu { + flex: 1 1 auto; /* can shrink, can grow, auto height */ + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.sidebar-item-icon { + display: inline-block; + margin-left: -1px; + + width: 3rem; + + text-align: center; +} + +.sidebar-item-text { + opacity: inherit; + transition: opacity 0.5s; +} + +#sidebar.collapsed .sidebar-item-text { + opacity: 0; +} + +.sidebar-menu a { + border-left: 1px solid #333; + color: #aaa; + text-decoration: none; + + height: 3rem; + width: 100%; + + display: block; + margin-top: 2px; + overflow: hidden; + + line-height: 3rem; + white-space: nowrap; + + transition: width 0.5s; +} + +.sidebar-menu a:hover, .sidebar-menu .active { + border-left: 1px solid #dedede; + color: #dedede; + text-decoration: none; +} + +.sidebar-menu a:hover { + background-color: #0d5259; +} + +.sidebar-menu .active { + background-color: #004952; +} + +.sidebar-collapse-toggle { + border-left: 1px solid #333; + + line-height: 3rem; + overflow: hidden; + text-align: center; + white-space: nowrap; + + cursor: pointer; + user-select: none; +} + +.sidebar-collapse-icon { + display: inline-block; + + width: 1.5rem; + + text-align: center; + + transition: width 0.5s; +} + +#sidebar.collapsed .sidebar-collapse-icon { + width: 3rem; +} + +.sidebar-collapse-text { + opacity: inherit; + transition: opacity 0.5s; +} + +#sidebar.collapsed .sidebar-collapse-text { + opacity: 0; +} + +.sidebar-collapse-toggle:hover { + background: #0d5259; + border-left: 1px solid #dedede; +} + +.sidebar-menu a.logout:hover { + background: #580000; +} + +#sidebar .sidebar-menu::-webkit-scrollbar { + width: 4px; + border-radius: 2px; +} + +#sidebar .sidebar-menu::-webkit-scrollbar-track { + background: #f1f1f1; + border-right: 1px solid rgb(34, 45, 50); + border-radius: 2px; +} + +#sidebar .sidebar-menu::-webkit-scrollbar-thumb { + background: #888; + border-radius: 2px; +} + +#sidebar .sidebar-menu::-webkit-scrollbar-thumb:hover { + background: #555; + border-radius: 2px; +} diff --git a/components/webui/imports/ui/bootstrap-customized.scss b/components/webui/imports/ui/bootstrap-customized.scss new file mode 100644 index 000000000..e66685621 --- /dev/null +++ b/components/webui/imports/ui/bootstrap-customized.scss @@ -0,0 +1,5 @@ +$primary: #007380; +$secondary: #e9ecef; +$info: #a60058; + +@import "~bootstrap/scss/bootstrap"; diff --git a/components/webui/imports/ui/constants.js b/components/webui/imports/ui/constants.js new file mode 100644 index 000000000..0c2394a34 --- /dev/null +++ b/components/webui/imports/ui/constants.js @@ -0,0 +1,12 @@ +/** + * Dictionary for local storage items used in the application. + * + * @type {Object} + */ +const LOCAL_STORAGE_KEYS = Object.freeze({ + IS_SIDEBAR_COLLAPSED: "isSidebarCollapsed", + MAX_LINES_PER_RESULT: "maxLinesPerResult", + SEARCH_CONTROLS_VISIBLE: "searchFilterControlsVisible", +}); + +export {LOCAL_STORAGE_KEYS}; diff --git a/components/webui/imports/utils/DbManager.js b/components/webui/imports/utils/DbManager.js new file mode 100644 index 000000000..a460c2f59 --- /dev/null +++ b/components/webui/imports/utils/DbManager.js @@ -0,0 +1,83 @@ +import {logger} from "/imports/utils/logger"; +import mysql from "mysql2/promise"; + +import {deinitStatsDbManager, initStatsDbManager} from "../api/ingestion/server/publications"; +import {initSearchJobsDbManager} from "../api/search/server/methods"; + + +/** + * @type {mysql.Connection|null} + */ +let dbConnection = null; + +/** + * Creates a new database connection and initializes DB managers with it. + * + * @param {object} dbConfig + * @param {string} dbConfig.dbHost + * @param {number} dbConfig.dbPort + * @param {string} dbConfig.dbName + * @param {string} dbConfig.dbUser + * @param {string} dbConfig.dbPassword + * + * @param {object} tableNames + * @param {string} tableNames.searchJobsTableName + * @param {string} tableNames.clpArchivesTableName + * @param {string} tableNames.clpFilesTableName + * + * @returns {Promise} + * @throws {Error} on error. + */ +const initDbManagers = async ({ + dbHost, + dbPort, + dbName, + dbUser, + dbPassword, +}, { + searchJobsTableName, + clpArchivesTableName, + clpFilesTableName, +}) => { + if (null !== dbConnection) { + logger.error("This method should not be called twice."); + return; + } + + try { + dbConnection = await mysql.createConnection({ + host: dbHost, + port: dbPort, + database: dbName, + user: dbUser, + password: dbPassword, + bigNumberStrings: true, + supportBigNumbers: true, + }); + await dbConnection.connect(); + + initSearchJobsDbManager(dbConnection, { + searchJobsTableName, + }); + initStatsDbManager(dbConnection, { + clpArchivesTableName, + clpFilesTableName, + }); + } catch (e) { + logger.error("Unable to create MySQL / mariadb connection.", e.toString()); + throw e; + } +}; + +/** + * De-initialize database managers. + * @returns {Promise} + * @throws {Error} on error. + */ +const deinitDbManagers = async () => { + deinitStatsDbManager(); + + await dbConnection.end(); +}; + +export {initDbManagers, deinitDbManagers}; diff --git a/components/webui/imports/utils/logger.js b/components/webui/imports/utils/logger.js new file mode 100644 index 000000000..276aca80f --- /dev/null +++ b/components/webui/imports/utils/logger.js @@ -0,0 +1,133 @@ +import JSON5 from "json5"; +import winston from "winston"; +import "winston-daily-rotate-file"; + + +const MAX_LOGS_FILE_SIZE = "100m"; +const MAX_LOGS_RETENTION_DAYS = "30d"; + +let winstonLogger = null; +let isTraceEnabled = false; + +// attribute names should match clp_py_utils.clp_logging.LOGGING_LEVEL_MAPPING +const webuiLoggingLevelToWinstonMap = { + DEBUG: "debug", + INFO: "info", + WARN: "warn", + WARNING: "warn", + ERROR: "error", + CRITICAL: "error", +}; + +/** + * Retrieves information about the calling function's stack trace. + * + * @returns {Object|null} an object containing method, filePath, and line information, + * or null if the information couldn't be extracted + */ +const getStackInfo = () => { + let info = null; + + const stackList = (new Error()).stack.split("\n"); + const stackInfo = stackList[4]; + const stackRegex = /at\s+(.*)\s+\((.*):(\d+):(\d+)\)/i; + const stackMatch = stackRegex.exec(stackInfo); + + if (null !== stackMatch && stackMatch.length === 5) { + info = { + method: stackMatch[1], + filePath: stackMatch[2], + line: stackMatch[3], + }; + } else { + const stackRegex2 = /at\s+(.*):(\d*):(\d*)/i; + const stackMatch2 = stackRegex2.exec(stackInfo); + info = { + method: "", + filePath: stackMatch2[1], + line: stackMatch2[2], + }; + } + + return info; +}; + + +/** + * Logs a message with the specified log level, including optional trace information. + * + * @param {string} level of the log message + * @param {...any} args message or data to be logged + */ +const fileLineFuncLog = (level, ...args) => { + let logMessage = `${args.map(a => ("string" === typeof a) ? a : JSON5.stringify(a)).join(" ")}`; + let logLabel = ""; + + if (true === isTraceEnabled) { + const stackInfo = getStackInfo(); + + if (null !== stackInfo) { + logMessage = `[${stackInfo.filePath}:${stackInfo.line}] ` + logMessage; + logLabel = stackInfo.method; + } + } + + winstonLogger.log({ + level, + message: logMessage, + label: logLabel, + }); +}; + +let logger = Object.freeze({ + error: (...args) => (fileLineFuncLog("error", ...args)), + warn: (...args) => (fileLineFuncLog("warn", ...args)), + help: (...args) => (fileLineFuncLog("help", ...args)), + data: (...args) => (fileLineFuncLog("data", ...args)), + info: (...args) => (fileLineFuncLog("info", ...args)), + debug: (...args) => (fileLineFuncLog("debug", ...args)), + prompt: (...args) => (fileLineFuncLog("prompt", ...args)), + verbose: (...args) => (fileLineFuncLog("verbose", ...args)), + input: (...args) => (fileLineFuncLog("input", ...args)), + silly: (...args) => (fileLineFuncLog("silly", ...args)), +}); + +/** + * Initializes winston logger with the specified configuration. + * + * @param {string} logsDir where log files will be stored. + * @param {string} webuiLoggingLevel messages higher than this level will be logged + * @param {boolean} [_isTraceEnabled=false] whether to log function & file names and line numbers + */ +const initLogger = (logsDir, webuiLoggingLevel, _isTraceEnabled = false) => { + isTraceEnabled = _isTraceEnabled; + + winstonLogger = winston.createLogger({ + level: webuiLoggingLevelToWinstonMap[webuiLoggingLevel], + format: winston.format.combine( + winston.format.timestamp(), + winston.format.printf((info) => { + return JSON.stringify({ + timestamp: info.timestamp, + level: info.level, + label: info.label, + message: info.message, + }); + }), + ), + transports: [ + new winston.transports.Console(), + new winston.transports.DailyRotateFile({ + filename: "webui-%DATE%.log", + dirname: logsDir, + datePattern: "YYYY-MM-DD-HH", + maxSize: MAX_LOGS_FILE_SIZE, + maxFiles: MAX_LOGS_RETENTION_DAYS, + }), + ], + }); + + logger.info("logger has been initialized"); +}; + +export {logger, initLogger}; diff --git a/components/webui/imports/utils/misc.js b/components/webui/imports/utils/misc.js new file mode 100644 index 000000000..6598fe196 --- /dev/null +++ b/components/webui/imports/utils/misc.js @@ -0,0 +1,26 @@ +/** + * Creates a promise that resolves after a specified number of seconds. + * + * @param {number} seconds to wait before resolving the promise + * @returns {Promise} that resolves after the specified delay + */ +const sleep = (seconds) => new Promise(r => setTimeout(r, seconds * 1000)); + +/** + * Computes a human-readable representation of a size in bytes. + * + * @param {number} num + * @returns {string} + */ +const computeHumanSize = (num) => { + const siPrefixes = ["", "K", "M", "G", "T", "P", "E", "Z"]; + for (let i = 0; i < siPrefixes.length; ++i) { + if (Math.abs(num) < 1024.0) { + return `${Math.round(num)} ${siPrefixes[i]}B`; + } + num /= 1024.0; + } + return `${Math.round(num)} B`; +}; + +export {computeHumanSize, sleep}; diff --git a/components/webui/launcher.js b/components/webui/launcher.js new file mode 100644 index 000000000..e4a974cf7 --- /dev/null +++ b/components/webui/launcher.js @@ -0,0 +1,110 @@ +/** + * Production launcher for CLP WebUI, which redirects Meteor server stderr to rotated error logs + * files in a specified directory for error monitoring. + + * To avoid duplicated installations of dependencies, use the same `node_modules` for the server + * by setting envvar NODE_PATH="./programs/server/npm/node_modules", assuming this script is + * placed under the same directory where the bundled `main.js` is located. + * + * This is not intended for development use. For development, please refer to README.md in the + * component root for launching a development server with Meteor-specific error messages print + * to the console. + * + * ENVIRONMENT VARIABLES: + * - NODE_PATH: path to node_modules including "winston" and "winston-daily-rotate-file" + * - WEBUI_LOGS_DIR: path to error logs directory + * SCRIPT USAGE: + * - usage: node /path/to/launcher.js /path/to/main.js + */ + +const {spawn} = require("child_process"); +const winston = require("winston"); +require("winston-daily-rotate-file"); + + +const DEFAULT_LOGS_DIR = "."; + +const MAX_LOGS_FILE_SIZE = "100m"; +const MAX_LOGS_RETENTION_DAYS = "30d"; + +/** + * Creates a logger using winston module. + * + * @param {string} logsDir directory where the log files will be saved. + * @returns {object} the logger object + */ +const getLogger = (logsDir) => { + return winston.createLogger({ + format: winston.format.combine( + winston.format.timestamp(), + winston.format.printf((info) => { + return JSON.stringify({ + timestamp: info.timestamp, + level: info.level, + label: info.label, + message: info.message, + }); + }), + ), + transports: [ + new winston.transports.DailyRotateFile({ + filename: "webui_error-%DATE%.log", + dirname: logsDir, + datePattern: "YYYY-MM-DD-HH", + maxSize: MAX_LOGS_FILE_SIZE, + maxFiles: MAX_LOGS_RETENTION_DAYS, + }), + ], + }); +}; + + +/** + * Runs a script with logging support. + * + * @param {string} logsDir path where the logs will be stored + * @param {string} scriptPath path of the script to be executed + */ +const runScript = (logsDir, scriptPath) => { + const logger = getLogger(logsDir); + const script = spawn(process.argv0, [scriptPath]); + + script.stderr.on("data", (data) => { + logger.error(data.toString()); + }); + + script.on("close", (code) => { + console.log(`Child process exited with code ${code}`); + }); +}; + +/** + * Parses the command line arguments and retrieves the values for the + * WEBUI_LOGS_DIR and scriptPath variables. + * + * @returns {Object} containing the values for WEBUI_LOGS_DIR and scriptPath + */ +const parseArgs = () => { + const WEBUI_LOGS_DIR = process.env["WEBUI_LOGS_DIR"] || DEFAULT_LOGS_DIR; + const scriptPath = process.argv[2]; + + return { + WEBUI_LOGS_DIR, + scriptPath, + }; +}; + +/** + * The main function of the program. + * + * This function is the entry point of the program. + * + * @returns {void} + */ +const main = () => { + const args = parseArgs(); + + runScript(args.WEBUI_LOGS_DIR, args.scriptPath); +}; + +main(); diff --git a/components/webui/package-lock.json b/components/webui/package-lock.json new file mode 100644 index 000000000..9b65d6b99 --- /dev/null +++ b/components/webui/package-lock.json @@ -0,0 +1,1751 @@ +{ + "name": "webui", + "requires": true, + "lockfileVersion": 1, + "dependencies": { + "@babel/parser": { + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.9.tgz", + "integrity": "sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==", + "dev": true + }, + "@babel/runtime": { + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz", + "integrity": "sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==", + "requires": { + "regenerator-runtime": "^0.14.0" + } + }, + "@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==" + }, + "@dabh/diagnostics": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", + "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "requires": { + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "@fortawesome/fontawesome-common-types": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.1.tgz", + "integrity": "sha512-GkWzv+L6d2bI5f/Vk6ikJ9xtl7dfXtoRu3YGE6nq0p/FFqA1ebMOAWg3XgRyb0I6LYyYkiAo+3/KrwuBp8xG7A==" + }, + "@fortawesome/fontawesome-svg-core": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.1.tgz", + "integrity": "sha512-MfRCYlQPXoLlpem+egxjfkEuP9UQswTrlCOsknus/NcMoblTH2g0jPrapbcIb04KGA7E2GZxbAccGZfWoYgsrQ==", + "requires": { + "@fortawesome/fontawesome-common-types": "6.5.1" + } + }, + "@fortawesome/free-solid-svg-icons": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.5.1.tgz", + "integrity": "sha512-S1PPfU3mIJa59biTtXJz1oI0+KAXW6bkAb31XKhxdxtuXDiUIFsih4JR1v5BbxY7hVHsD1RKq+jRkVRaf773NQ==", + "requires": { + "@fortawesome/fontawesome-common-types": "6.5.1" + } + }, + "@fortawesome/react-fontawesome": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.0.tgz", + "integrity": "sha512-uHg75Rb/XORTtVt7OS9WoK8uM276Ufi7gCzshVWkUJbHhh3svsUUeqXerrM96Wm7fRiDzfKRwSoahhMIkGAYHw==", + "requires": { + "prop-types": "^15.8.1" + } + }, + "@jsdoc/salty": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.7.tgz", + "integrity": "sha512-mh8LbS9d4Jq84KLw8pzho7XC2q2/IJGiJss3xwRoLD1A+EE16SjN4PfaG4jRCzKegTFLlN0Zd8SdUPE6XdoPFg==", + "dev": true, + "requires": { + "lodash": "^4.17.21" + } + }, + "@msgpack/msgpack": { + "version": "3.0.0-beta2", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.0.0-beta2.tgz", + "integrity": "sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw==" + }, + "@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==" + }, + "@react-aria/ssr": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.1.tgz", + "integrity": "sha512-NqzkLFP8ZVI4GSorS0AYljC13QW2sc8bDqJOkBvkAt3M8gbcAXJWVRGtZBCRscki9RZF+rNlnPdg0G0jYkhJcg==", + "requires": { + "@swc/helpers": "^0.5.0" + } + }, + "@restart/hooks": { + "version": "0.4.15", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.15.tgz", + "integrity": "sha512-cZFXYTxbpzYcieq/mBwSyXgqnGMHoBVh3J7MU0CCoIB4NRZxV9/TuwTBAaLMqpNhC3zTPMCgkQ5Ey07L02Xmcw==", + "requires": { + "dequal": "^2.0.3" + } + }, + "@restart/ui": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.6.6.tgz", + "integrity": "sha512-eC3puKuWE1SRYbojWHXnvCNHGgf3uzHCb6JOhnF4OXPibOIPEkR1sqDSkL643ydigxwh+ruCa1CmYHlzk7ikKA==", + "requires": { + "@babel/runtime": "^7.21.0", + "@popperjs/core": "^2.11.6", + "@react-aria/ssr": "^3.5.0", + "@restart/hooks": "^0.4.9", + "@types/warning": "^3.0.0", + "dequal": "^2.0.3", + "dom-helpers": "^5.2.0", + "uncontrollable": "^8.0.1", + "warning": "^4.0.3" + }, + "dependencies": { + "uncontrollable": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz", + "integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==" + } + } + }, + "@swc/helpers": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.6.tgz", + "integrity": "sha512-aYX01Ke9hunpoCexYAgQucEpARGQ5w/cqHFrIR+e9gdKb1QWTsVJuTJ2ozQzIAxLyRQe/m+2RqzkyOOGiMKRQA==", + "requires": { + "tslib": "^2.4.0" + } + }, + "@types/linkify-it": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz", + "integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==", + "dev": true + }, + "@types/markdown-it": { + "version": "12.2.3", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", + "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", + "dev": true, + "requires": { + "@types/linkify-it": "*", + "@types/mdurl": "*" + } + }, + "@types/mdurl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.5.tgz", + "integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==", + "dev": true + }, + "@types/prop-types": { + "version": "15.7.11", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", + "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" + }, + "@types/react": { + "version": "18.2.55", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.55.tgz", + "integrity": "sha512-Y2Tz5P4yz23brwm2d7jNon39qoAtMMmalOQv6+fEFt1mT+FcM3D841wDpoUvFXhaYenuROCy3FZYqdTjM7qVyA==", + "requires": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "@types/react-transition-group": { + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", + "integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==", + "requires": { + "@types/react": "*" + } + }, + "@types/scheduler": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==" + }, + "@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==" + }, + "@types/warning": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", + "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==" + }, + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "async": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", + "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" + }, + "bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true + }, + "bootstrap": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.2.tgz", + "integrity": "sha512-D32nmNWiQHo94BKHLmOrdjlL05q1c8oxbtBphQFb9Z5to6eGRDCm0QgeaZ4zFBHzfg2++rqa2JkqCcxDy0sH0g==" + }, + "catharsis": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/catharsis/-/catharsis-0.9.0.tgz", + "integrity": "sha512-prMTQVpcns/tzFgFVkVp6ak6RykZyWb3gu8ckUpd6YkTlacOd3DXGJjIpD4Q6zJirizvaiAjSSHlOsA+6sNh2A==", + "dev": true, + "requires": { + "lodash": "^4.17.15" + } + }, + "classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, + "color": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", + "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "requires": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "requires": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "colorspace": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", + "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", + "requires": { + "color": "^3.1.3", + "text-hex": "1.0.x" + } + }, + "csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "requires": { + "@babel/runtime": "^7.21.0" + } + }, + "denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==" + }, + "dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==" + }, + "dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "requires": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" + }, + "entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", + "dev": true + }, + "escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true + }, + "fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" + }, + "file-stream-rotator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz", + "integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==", + "requires": { + "moment": "^2.29.1" + } + }, + "fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" + }, + "generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "requires": { + "is-property": "^1.0.2" + } + }, + "graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "requires": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + } + }, + "hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "requires": { + "react-is": "^16.7.0" + } + }, + "iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "requires": { + "loose-envify": "^1.0.0" + } + }, + "is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" + }, + "is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==" + }, + "is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==" + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "js2xmlparser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-4.0.2.tgz", + "integrity": "sha512-6n4D8gLlLf1n5mNLQPRfViYzu9RATblzPEtm1SthMX1Pjao0r9YI9nw7ZIfRxQMERS87mcswrg+r/OYrPRX6jA==", + "dev": true, + "requires": { + "xmlcreate": "^2.0.4" + } + }, + "jsdoc": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-4.0.2.tgz", + "integrity": "sha512-e8cIg2z62InH7azBBi3EsSEqrKx+nUtAS5bBcYTSpZFA+vhNPyhv8PTFZ0WsjOPDj04/dOLlm08EDcQJDqaGQg==", + "dev": true, + "requires": { + "@babel/parser": "^7.20.15", + "@jsdoc/salty": "^0.2.1", + "@types/markdown-it": "^12.2.3", + "bluebird": "^3.7.2", + "catharsis": "^0.9.0", + "escape-string-regexp": "^2.0.0", + "js2xmlparser": "^4.0.2", + "klaw": "^3.0.0", + "markdown-it": "^12.3.2", + "markdown-it-anchor": "^8.4.1", + "marked": "^4.0.10", + "mkdirp": "^1.0.4", + "requizzle": "^0.2.3", + "strip-json-comments": "^3.1.0", + "underscore": "~1.13.2" + } + }, + "json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==" + }, + "klaw": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", + "integrity": "sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.9" + } + }, + "kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" + }, + "linkify-it": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", + "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==", + "dev": true, + "requires": { + "uc.micro": "^1.0.1" + } + }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, + "logform": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.6.0.tgz", + "integrity": "sha512-1ulHeNPp6k/LD8H91o7VYFBng5i1BDE7HoKxVbZiGFidS1Rj65qcywLxX+pVfAPoQJEjRdvKcusKwOupHCVOVQ==", + "requires": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + } + }, + "long": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "lru-cache": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-8.0.5.tgz", + "integrity": "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==" + }, + "luxon": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", + "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==" + }, + "markdown-it": { + "version": "12.3.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", + "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==", + "dev": true, + "requires": { + "argparse": "^2.0.1", + "entities": "~2.1.0", + "linkify-it": "^3.0.1", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + } + }, + "markdown-it-anchor": { + "version": "8.6.7", + "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-8.6.7.tgz", + "integrity": "sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==", + "dev": true + }, + "marked": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", + "dev": true + }, + "mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", + "dev": true + }, + "meteor-node-stubs": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/meteor-node-stubs/-/meteor-node-stubs-1.2.7.tgz", + "integrity": "sha512-20bAFUhEIOD/Cos2nmvhqf2NOKpTf63WVQ+nwuaX2OFj31sU6GL4KkNylkWum8McwsH0LsMr/F+UHhduTX7KRg==", + "requires": { + "assert": "^2.0.0", + "browserify-zlib": "^0.2.0", + "buffer": "^5.7.1", + "console-browserify": "^1.2.0", + "constants-browserify": "^1.0.0", + "crypto-browserify": "^3.12.0", + "domain-browser": "^4.22.0", + "elliptic": "^6.5.4", + "events": "^3.3.0", + "https-browserify": "^1.0.0", + "os-browserify": "^0.3.0", + "path-browserify": "^1.0.0", + "process": "^0.11.10", + "punycode": "^1.4.1", + "querystring-es3": "^0.2.1", + "readable-stream": "^3.6.0", + "stream-browserify": "^3.0.0", + "stream-http": "^3.2.0", + "string_decoder": "^1.3.0", + "timers-browserify": "^2.0.12", + "tty-browserify": "0.0.1", + "url": "^0.11.0", + "util": "^0.12.4", + "vm-browserify": "^1.1.2" + }, + "dependencies": { + "asn1.js": { + "version": "5.4.1", + "bundled": true, + "requires": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + }, + "dependencies": { + "bn.js": { + "version": "4.12.0", + "bundled": true + } + } + }, + "assert": { + "version": "2.0.0", + "bundled": true, + "requires": { + "es6-object-assign": "^1.1.0", + "is-nan": "^1.2.1", + "object-is": "^1.0.1", + "util": "^0.12.0" + } + }, + "available-typed-arrays": { + "version": "1.0.4", + "bundled": true + }, + "base64-js": { + "version": "1.5.1", + "bundled": true + }, + "bn.js": { + "version": "5.2.0", + "bundled": true + }, + "brorand": { + "version": "1.1.0", + "bundled": true + }, + "browserify-aes": { + "version": "1.2.0", + "bundled": true, + "requires": { + "buffer-xor": "^1.0.3", + "cipher-base": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.3", + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "browserify-cipher": { + "version": "1.0.1", + "bundled": true, + "requires": { + "browserify-aes": "^1.0.4", + "browserify-des": "^1.0.0", + "evp_bytestokey": "^1.0.0" + } + }, + "browserify-des": { + "version": "1.0.2", + "bundled": true, + "requires": { + "cipher-base": "^1.0.1", + "des.js": "^1.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "browserify-rsa": { + "version": "4.1.0", + "bundled": true, + "requires": { + "bn.js": "^5.0.0", + "randombytes": "^2.0.1" + } + }, + "browserify-sign": { + "version": "4.2.1", + "bundled": true, + "requires": { + "bn.js": "^5.1.1", + "browserify-rsa": "^4.0.1", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "elliptic": "^6.5.3", + "inherits": "^2.0.4", + "parse-asn1": "^5.1.5", + "readable-stream": "^3.6.0", + "safe-buffer": "^5.2.0" + } + }, + "browserify-zlib": { + "version": "0.2.0", + "bundled": true, + "requires": { + "pako": "~1.0.5" + } + }, + "buffer": { + "version": "5.7.1", + "bundled": true, + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "buffer-xor": { + "version": "1.0.3", + "bundled": true + }, + "builtin-status-codes": { + "version": "3.0.0", + "bundled": true + }, + "call-bind": { + "version": "1.0.2", + "bundled": true, + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, + "cipher-base": { + "version": "1.0.4", + "bundled": true, + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "console-browserify": { + "version": "1.2.0", + "bundled": true + }, + "constants-browserify": { + "version": "1.0.0", + "bundled": true + }, + "create-ecdh": { + "version": "4.0.4", + "bundled": true, + "requires": { + "bn.js": "^4.1.0", + "elliptic": "^6.5.3" + }, + "dependencies": { + "bn.js": { + "version": "4.12.0", + "bundled": true + } + } + }, + "create-hash": { + "version": "1.2.0", + "bundled": true, + "requires": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "create-hmac": { + "version": "1.1.7", + "bundled": true, + "requires": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "crypto-browserify": { + "version": "3.12.0", + "bundled": true, + "requires": { + "browserify-cipher": "^1.0.0", + "browserify-sign": "^4.0.0", + "create-ecdh": "^4.0.0", + "create-hash": "^1.1.0", + "create-hmac": "^1.1.0", + "diffie-hellman": "^5.0.0", + "inherits": "^2.0.1", + "pbkdf2": "^3.0.3", + "public-encrypt": "^4.0.0", + "randombytes": "^2.0.0", + "randomfill": "^1.0.3" + } + }, + "define-properties": { + "version": "1.1.3", + "bundled": true, + "requires": { + "object-keys": "^1.0.12" + } + }, + "des.js": { + "version": "1.0.1", + "bundled": true, + "requires": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "diffie-hellman": { + "version": "5.0.3", + "bundled": true, + "requires": { + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" + }, + "dependencies": { + "bn.js": { + "version": "4.12.0", + "bundled": true + } + } + }, + "domain-browser": { + "version": "4.22.0", + "bundled": true + }, + "elliptic": { + "version": "6.5.4", + "bundled": true, + "requires": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + }, + "dependencies": { + "bn.js": { + "version": "4.12.0", + "bundled": true + } + } + }, + "es-abstract": { + "version": "1.18.3", + "bundled": true, + "requires": { + "call-bind": "^1.0.2", + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "get-intrinsic": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.2", + "is-callable": "^1.2.3", + "is-negative-zero": "^2.0.1", + "is-regex": "^1.1.3", + "is-string": "^1.0.6", + "object-inspect": "^1.10.3", + "object-keys": "^1.1.1", + "object.assign": "^4.1.2", + "string.prototype.trimend": "^1.0.4", + "string.prototype.trimstart": "^1.0.4", + "unbox-primitive": "^1.0.1" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "bundled": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "es6-object-assign": { + "version": "1.1.0", + "bundled": true + }, + "events": { + "version": "3.3.0", + "bundled": true + }, + "evp_bytestokey": { + "version": "1.0.3", + "bundled": true, + "requires": { + "md5.js": "^1.3.4", + "safe-buffer": "^5.1.1" + } + }, + "foreach": { + "version": "2.0.5", + "bundled": true + }, + "function-bind": { + "version": "1.1.1", + "bundled": true + }, + "get-intrinsic": { + "version": "1.1.1", + "bundled": true, + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1" + } + }, + "has": { + "version": "1.0.3", + "bundled": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-bigints": { + "version": "1.0.1", + "bundled": true + }, + "has-symbols": { + "version": "1.0.2", + "bundled": true + }, + "hash-base": { + "version": "3.1.0", + "bundled": true, + "requires": { + "inherits": "^2.0.4", + "readable-stream": "^3.6.0", + "safe-buffer": "^5.2.0" + } + }, + "hash.js": { + "version": "1.1.7", + "bundled": true, + "requires": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "hmac-drbg": { + "version": "1.0.1", + "bundled": true, + "requires": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "https-browserify": { + "version": "1.0.0", + "bundled": true + }, + "ieee754": { + "version": "1.2.1", + "bundled": true + }, + "inherits": { + "version": "2.0.4", + "bundled": true + }, + "is-arguments": { + "version": "1.1.0", + "bundled": true, + "requires": { + "call-bind": "^1.0.0" + } + }, + "is-bigint": { + "version": "1.0.2", + "bundled": true + }, + "is-boolean-object": { + "version": "1.1.1", + "bundled": true, + "requires": { + "call-bind": "^1.0.2" + } + }, + "is-callable": { + "version": "1.2.3", + "bundled": true + }, + "is-date-object": { + "version": "1.0.4", + "bundled": true + }, + "is-generator-function": { + "version": "1.0.9", + "bundled": true + }, + "is-nan": { + "version": "1.3.2", + "bundled": true, + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + } + }, + "is-negative-zero": { + "version": "2.0.1", + "bundled": true + }, + "is-number-object": { + "version": "1.0.5", + "bundled": true + }, + "is-regex": { + "version": "1.1.3", + "bundled": true, + "requires": { + "call-bind": "^1.0.2", + "has-symbols": "^1.0.2" + } + }, + "is-string": { + "version": "1.0.6", + "bundled": true + }, + "is-symbol": { + "version": "1.0.4", + "bundled": true, + "requires": { + "has-symbols": "^1.0.2" + } + }, + "is-typed-array": { + "version": "1.1.5", + "bundled": true, + "requires": { + "available-typed-arrays": "^1.0.2", + "call-bind": "^1.0.2", + "es-abstract": "^1.18.0-next.2", + "foreach": "^2.0.5", + "has-symbols": "^1.0.1" + } + }, + "md5.js": { + "version": "1.3.5", + "bundled": true, + "requires": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "miller-rabin": { + "version": "4.0.1", + "bundled": true, + "requires": { + "bn.js": "^4.0.0", + "brorand": "^1.0.1" + }, + "dependencies": { + "bn.js": { + "version": "4.12.0", + "bundled": true + } + } + }, + "minimalistic-assert": { + "version": "1.0.1", + "bundled": true + }, + "minimalistic-crypto-utils": { + "version": "1.0.1", + "bundled": true + }, + "object-inspect": { + "version": "1.10.3", + "bundled": true + }, + "object-is": { + "version": "1.1.5", + "bundled": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + }, + "object-keys": { + "version": "1.1.1", + "bundled": true + }, + "object.assign": { + "version": "4.1.2", + "bundled": true, + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "has-symbols": "^1.0.1", + "object-keys": "^1.1.1" + } + }, + "os-browserify": { + "version": "0.3.0", + "bundled": true + }, + "pako": { + "version": "1.0.11", + "bundled": true + }, + "parse-asn1": { + "version": "5.1.6", + "bundled": true, + "requires": { + "asn1.js": "^5.2.0", + "browserify-aes": "^1.0.0", + "evp_bytestokey": "^1.0.0", + "pbkdf2": "^3.0.3", + "safe-buffer": "^5.1.1" + } + }, + "path-browserify": { + "version": "1.0.1", + "bundled": true + }, + "pbkdf2": { + "version": "3.1.2", + "bundled": true, + "requires": { + "create-hash": "^1.1.2", + "create-hmac": "^1.1.4", + "ripemd160": "^2.0.1", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "process": { + "version": "0.11.10", + "bundled": true + }, + "public-encrypt": { + "version": "4.0.3", + "bundled": true, + "requires": { + "bn.js": "^4.1.0", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "parse-asn1": "^5.0.0", + "randombytes": "^2.0.1", + "safe-buffer": "^5.1.2" + }, + "dependencies": { + "bn.js": { + "version": "4.12.0", + "bundled": true + } + } + }, + "punycode": { + "version": "1.4.1", + "bundled": true + }, + "querystring": { + "version": "0.2.0", + "bundled": true + }, + "querystring-es3": { + "version": "0.2.1", + "bundled": true + }, + "randombytes": { + "version": "2.1.0", + "bundled": true, + "requires": { + "safe-buffer": "^5.1.0" + } + }, + "randomfill": { + "version": "1.0.4", + "bundled": true, + "requires": { + "randombytes": "^2.0.5", + "safe-buffer": "^5.1.0" + } + }, + "readable-stream": { + "version": "3.6.0", + "bundled": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "ripemd160": { + "version": "2.0.2", + "bundled": true, + "requires": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1" + } + }, + "safe-buffer": { + "version": "5.2.1", + "bundled": true + }, + "safer-buffer": { + "version": "2.1.2", + "bundled": true + }, + "setimmediate": { + "version": "1.0.5", + "bundled": true + }, + "sha.js": { + "version": "2.4.11", + "bundled": true, + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "stream-browserify": { + "version": "3.0.0", + "bundled": true, + "requires": { + "inherits": "~2.0.4", + "readable-stream": "^3.5.0" + } + }, + "stream-http": { + "version": "3.2.0", + "bundled": true, + "requires": { + "builtin-status-codes": "^3.0.0", + "inherits": "^2.0.4", + "readable-stream": "^3.6.0", + "xtend": "^4.0.2" + } + }, + "string.prototype.trimend": { + "version": "1.0.4", + "bundled": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + }, + "string.prototype.trimstart": { + "version": "1.0.4", + "bundled": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + }, + "string_decoder": { + "version": "1.3.0", + "bundled": true, + "requires": { + "safe-buffer": "~5.2.0" + } + }, + "timers-browserify": { + "version": "2.0.12", + "bundled": true, + "requires": { + "setimmediate": "^1.0.4" + } + }, + "tty-browserify": { + "version": "0.0.1", + "bundled": true + }, + "unbox-primitive": { + "version": "1.0.1", + "bundled": true, + "requires": { + "function-bind": "^1.1.1", + "has-bigints": "^1.0.1", + "has-symbols": "^1.0.2", + "which-boxed-primitive": "^1.0.2" + } + }, + "url": { + "version": "0.11.0", + "bundled": true, + "requires": { + "punycode": "1.3.2", + "querystring": "0.2.0" + }, + "dependencies": { + "punycode": { + "version": "1.3.2", + "bundled": true + } + } + }, + "util": { + "version": "0.12.4", + "bundled": true, + "requires": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "safe-buffer": "^5.1.2", + "which-typed-array": "^1.1.2" + } + }, + "util-deprecate": { + "version": "1.0.2", + "bundled": true + }, + "vm-browserify": { + "version": "1.1.2", + "bundled": true + }, + "which-boxed-primitive": { + "version": "1.0.2", + "bundled": true, + "requires": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + } + }, + "which-typed-array": { + "version": "1.1.4", + "bundled": true, + "requires": { + "available-typed-arrays": "^1.0.2", + "call-bind": "^1.0.0", + "es-abstract": "^1.18.0-next.1", + "foreach": "^2.0.5", + "function-bind": "^1.1.1", + "has-symbols": "^1.0.1", + "is-typed-array": "^1.1.3" + } + }, + "xtend": { + "version": "4.0.2", + "bundled": true + } + } + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true + }, + "moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==" + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "mysql2": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.1.tgz", + "integrity": "sha512-3njoWAAhGBYy0tWBabqUQcLtczZUxrmmtc2vszQUekg3kTJyZ5/IeLC3Fo04u6y6Iy5Sba7pIIa2P/gs8D3ZeQ==", + "requires": { + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.6.3", + "long": "^5.2.1", + "lru-cache": "^8.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + } + }, + "named-placeholders": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", + "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", + "requires": { + "lru-cache": "^7.14.1" + }, + "dependencies": { + "lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==" + } + } + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + }, + "object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==" + }, + "one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "requires": { + "fn.name": "1.x.x" + } + }, + "path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "requires": { + "isarray": "0.0.1" + } + }, + "prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "prop-types-extra": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", + "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", + "requires": { + "react-is": "^16.3.2", + "warning": "^4.0.0" + } + }, + "react": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", + "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "react-bootstrap": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.0.tgz", + "integrity": "sha512-87gRP69VAfeU2yKgp8RI3HvzhPNrnYIV2QNranYXataz3ef+k7OhvKGGdxQLQfUsQ2RTmlY66tn4pdFrZ94hNg==", + "requires": { + "@babel/runtime": "^7.22.5", + "@restart/hooks": "^0.4.9", + "@restart/ui": "^1.6.6", + "@types/react-transition-group": "^4.4.6", + "classnames": "^2.3.2", + "dom-helpers": "^5.2.1", + "invariant": "^2.2.4", + "prop-types": "^15.8.1", + "prop-types-extra": "^1.1.0", + "react-transition-group": "^4.4.5", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + } + }, + "react-datepicker": { + "version": "4.25.0", + "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-4.25.0.tgz", + "integrity": "sha512-zB7CSi44SJ0sqo8hUQ3BF1saE/knn7u25qEMTO1CQGofY1VAKahO8k9drZtp0cfW1DMfoYLR3uSY1/uMvbEzbg==", + "requires": { + "@popperjs/core": "^2.11.8", + "classnames": "^2.2.6", + "date-fns": "^2.30.0", + "prop-types": "^15.7.2", + "react-onclickoutside": "^6.13.0", + "react-popper": "^2.3.0" + } + }, + "react-dom": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", + "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "scheduler": "^0.20.2" + } + }, + "react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" + }, + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" + }, + "react-onclickoutside": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.13.0.tgz", + "integrity": "sha512-ty8So6tcUpIb+ZE+1HAhbLROvAIJYyJe/1vRrrcmW+jLsaM+/powDRqxzo6hSh9CuRZGSL1Q8mvcF5WRD93a0A==" + }, + "react-popper": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz", + "integrity": "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==", + "requires": { + "react-fast-compare": "^3.0.1", + "warning": "^4.0.2" + } + }, + "react-router": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", + "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", + "requires": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "hoist-non-react-statics": "^3.1.0", + "loose-envify": "^1.3.1", + "path-to-regexp": "^1.7.0", + "prop-types": "^15.6.2", + "react-is": "^16.6.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + } + }, + "react-router-dom": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz", + "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", + "requires": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "loose-envify": "^1.3.1", + "prop-types": "^15.6.2", + "react-router": "5.3.4", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + } + }, + "react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + } + }, + "react-visibility-sensor": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/react-visibility-sensor/-/react-visibility-sensor-5.1.1.tgz", + "integrity": "sha512-cTUHqIK+zDYpeK19rzW6zF9YfT4486TIgizZW53wEZ+/GPBbK7cNS0EHyJVyHYacwFEvvHLEKfgJndbemWhB/w==", + "requires": { + "prop-types": "^15.7.2" + } + }, + "readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + }, + "requizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/requizzle/-/requizzle-0.2.4.tgz", + "integrity": "sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw==", + "dev": true, + "requires": { + "lodash": "^4.17.21" + } + }, + "resolve-pathname": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", + "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==" + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "safe-stable-stringify": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz", + "integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "scheduler": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", + "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, + "simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "requires": { + "is-arrayish": "^0.3.1" + } + }, + "sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==" + }, + "stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==" + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "requires": { + "safe-buffer": "~5.2.0" + } + }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true + }, + "text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" + }, + "tiny-invariant": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", + "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==" + }, + "tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + }, + "triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==" + }, + "tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, + "uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", + "dev": true + }, + "uncontrollable": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "requires": { + "@babel/runtime": "^7.6.3", + "@types/react": ">=16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + } + }, + "underscore": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz", + "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==", + "dev": true + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==" + }, + "value-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", + "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==" + }, + "warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "requires": { + "loose-envify": "^1.0.0" + } + }, + "winston": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.11.0.tgz", + "integrity": "sha512-L3yR6/MzZAOl0DsysUXHVjOwv8mKZ71TrA/41EIduGpOOV5LQVodqN+QdQ6BS6PJ/RdIshZhq84P/fStEZkk7g==", + "requires": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.4.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.5.0" + } + }, + "winston-daily-rotate-file": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-4.7.1.tgz", + "integrity": "sha512-7LGPiYGBPNyGHLn9z33i96zx/bd71pjBn9tqQzO3I4Tayv94WPmBNwKC7CO1wPHdP9uvu+Md/1nr6VSH9h0iaA==", + "requires": { + "file-stream-rotator": "^0.6.1", + "object-hash": "^2.0.1", + "triple-beam": "^1.3.0", + "winston-transport": "^4.4.0" + } + }, + "winston-transport": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.6.0.tgz", + "integrity": "sha512-wbBA9PbPAHxKiygo7ub7BYRiKxms0tpfU2ljtWzb3SjRjv5yl6Ozuy/TkXf00HTAt+Uylo3gSkNwzc4ME0wiIg==", + "requires": { + "logform": "^2.3.2", + "readable-stream": "^3.6.0", + "triple-beam": "^1.3.0" + } + }, + "xmlcreate": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-2.0.4.tgz", + "integrity": "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==", + "dev": true + } + } +} diff --git a/components/webui/package.json b/components/webui/package.json new file mode 100644 index 000000000..ec07db999 --- /dev/null +++ b/components/webui/package.json @@ -0,0 +1,43 @@ +{ + "name": "webui", + "private": true, + "scripts": { + "start": "meteor run", + "build-docs": "jsdoc -r client imports server tests launcher.js -d docs", + "test": "meteor test --once --driver-package meteortesting:mocha", + "test-app": "TEST_WATCH=1 meteor test --full-app --driver-package meteortesting:mocha", + "visualize": "meteor --production --extra-packages bundle-visualizer" + }, + "dependencies": { + "@babel/runtime": "^7.23.9", + "@fortawesome/fontawesome-svg-core": "^6.5.1", + "@fortawesome/free-solid-svg-icons": "^6.5.1", + "@fortawesome/react-fontawesome": "^0.2.0", + "@msgpack/msgpack": "^3.0.0-beta2", + "bootstrap": "^5.3.2", + "json5": "^2.2.3", + "luxon": "^3.4.4", + "meteor-node-stubs": "^1.2.7", + "mysql2": "^3.9.1", + "react": "^17.0.2", + "react-bootstrap": "^2.10.0", + "react-datepicker": "^4.25.0", + "react-dom": "^17.0.2", + "react-router": "^5.3.4", + "react-router-dom": "^5.3.4", + "react-visibility-sensor": "^5.1.1", + "uuid": "^9.0.1", + "winston": "^3.11.0", + "winston-daily-rotate-file": "^4.7.1" + }, + "devDependencies": { + "jsdoc": "^4.0.2" + }, + "meteor": { + "mainModule": { + "client": "client/main.jsx", + "server": "server/main.js" + }, + "testModule": "tests/main.js" + } +} diff --git a/components/webui/server/main.js b/components/webui/server/main.js new file mode 100644 index 000000000..94d7b547a --- /dev/null +++ b/components/webui/server/main.js @@ -0,0 +1,69 @@ +import {initSearchEventCollection} from "/imports/api/search/collections"; +import {initLogger} from "/imports/utils/logger"; +import {Meteor} from "meteor/meteor"; + +import "/imports/api/ingestion/collections"; +import "/imports/api/ingestion/server/publications"; +import "/imports/api/search/server/collections"; +import "/imports/api/search/server/methods"; +import "/imports/api/search/server/publications"; +import "/imports/api/user/server/methods"; +import {deinitDbManagers, initDbManagers} from "../imports/utils/DbManager"; + + +const DEFAULT_LOGS_DIR = "."; +const DEFAULT_LOGGING_LEVEL = Meteor.isDevelopment ? "DEBUG" : "INFO"; + +/** + * Parses environment variables into config values for the application. + * + * @returns {Object} An object containing config values including the SQL database credentials, + * logs directory, and logging level. + * @throws {Error} if the required environment variables are undefined, it exits the process with an + * error. + */ +const parseEnvVars = () => { + const CLP_DB_USER = process.env["CLP_DB_USER"]; + const CLP_DB_PASS = process.env["CLP_DB_PASS"]; + + if ([CLP_DB_USER, CLP_DB_PASS].includes(undefined)) { + console.error("Environment variables CLP_DB_USER and CLP_DB_PASS must be defined"); + process.exit(1); + } + + const WEBUI_LOGS_DIR = process.env["WEBUI_LOGS_DIR"] || DEFAULT_LOGS_DIR; + const WEBUI_LOGGING_LEVEL = process.env["WEBUI_LOGGING_LEVEL"] || DEFAULT_LOGGING_LEVEL; + + return { + CLP_DB_USER, + CLP_DB_PASS, + WEBUI_LOGS_DIR, + WEBUI_LOGGING_LEVEL, + }; +}; + +Meteor.startup(async () => { + const envVars = parseEnvVars(); + + initLogger(envVars.WEBUI_LOGS_DIR, envVars.WEBUI_LOGGING_LEVEL, Meteor.isDevelopment); + + await initDbManagers({ + dbHost: Meteor.settings.private.SqlDbHost, + dbPort: Meteor.settings.private.SqlDbPort, + dbName: Meteor.settings.private.SqlDbName, + dbUser: envVars.CLP_DB_USER, + dbPassword: envVars.CLP_DB_PASS, + }, { + searchJobsTableName: Meteor.settings.private.SqlDbSearchJobsTableName, + clpArchivesTableName: Meteor.settings.private.SqlDbClpArchivesTableName, + clpFilesTableName: Meteor.settings.private.SqlDbClpFilesTableName, + }, + ); + + initSearchEventCollection(); +}); + +process.on("exit", async (code) => { + console.log(`Node.js is about to exit with code: ${code}`); + await deinitDbManagers(); +}); diff --git a/components/webui/settings.json b/components/webui/settings.json new file mode 100644 index 000000000..0084bc902 --- /dev/null +++ b/components/webui/settings.json @@ -0,0 +1,15 @@ +{ + "private": { + "SqlDbHost": "localhost", + "SqlDbPort": 3306, + "SqlDbName": "clp-db", + "SqlDbSearchJobsTableName": "search_jobs", + "SqlDbClpArchivesTableName": "clp_archives", + "SqlDbClpFilesTableName": "clp_files" + }, + "public": { + "SearchResultsCollectionName": "results", + "SearchResultsMetadataCollectionName": "results-metadata", + "StatsCollectionName": "stats" + } +} diff --git a/components/webui/tests/main.js b/components/webui/tests/main.js new file mode 100644 index 000000000..7d6ad28a5 --- /dev/null +++ b/components/webui/tests/main.js @@ -0,0 +1,20 @@ +import assert from "assert"; + +describe("webui", function () { + it("package.json has correct name", async function () { + const { name } = await import("../package.json"); + assert.strictEqual(name, "webui"); + }); + + if (Meteor.isClient) { + it("client is not server", function () { + assert.strictEqual(Meteor.isServer, false); + }); + } + + if (Meteor.isServer) { + it("server is not client", function () { + assert.strictEqual(Meteor.isClient, false); + }); + } +}); diff --git a/docs/Building.md b/docs/Building.md index 750ee91b6..99107f73e 100644 --- a/docs/Building.md +++ b/docs/Building.md @@ -12,6 +12,9 @@ prebuilt version instead, check out the [releases](https://github.com/y-scope/cl extra configuration. * Python 3.8 or newer * python3-venv +* [Node.js 14](https://nodejs.org/download/release/v14.21.3/) (Meteor.js only + [supports](https://docs.meteor.com/install#prereqs-node) Node.js versions >= 10 and <= 14) +* [Meteor.js](https://docs.meteor.com/install.html#installation) * [Task](https://taskfile.dev/) # Setup